[SUMMARY] Math Captcha (#48)

It's always great running the Ruby Quiz, because I quite literally learn
something new every single week. This week's big lesson: Steal Glenn Parker's
code, whenever possible!

Both Gavin and I "borrowed" Glenn's solution to Ruby Quiz #25 to help us convert
digits to English words. Thanks Glenn, you made us both look good.

I'm going to show my solution below, mostly because it's shorter and I'm pretty
lazy. However, Gavin's code had some interesting things in it you really
shouldn't miss. So I'll talk about my favorite feature in that code a little
first.

I wasn't too keen on the extra credit idea of difficultly, because it sounded a
little too subjective to me. Neither Gavin or I really did that part, but Gavin
did add categorization for the questions and I have to tell you that is one cool
feature, both in idea and implementation. Here's an explanation of how it
works, from the author:

  I created a framework where you categorize types of captchas in an
  hierarchy, and you can ask for a specific type of captcha by using the
  desired subclass.
  
  For example, in my code below, I have:

  class Captcha::Zoology < Captcha ... end
  class Captcha::Math < Captcha
     class Basic < Math ... end
     class Algebra < Math ... end
  end

  This allows you to do:

  Captcha.create_question # a question from any framework, while
  Captcha::Zoology.create_question # only questions in this class
  Captcha::Math.create_question # any question in Math or its subclasses
  Captcha::Math::Basic.create_question # only Basic math questions

Sounds clever, right? I was far more impressed when I looked under the hood to
find out how it is done. It's really just a few simple methods doing the work:

  class Captcha
    # ...
  
    # Returns a hash with two values:
    # _question_:: A string with the question that the user should answer
    # _answer_id_:: A unique ID for this question that should be passed to
    # #check_answer or #get_answers
    def self.create_question
      question, answers = factories.random.call
      answer_id = AnswerStore.instance.store( answers )
      return { :question => question, :answer_id => answer_id }
    end
  
    # ...
  
    # Add the block to my store of question factories
    def self.add_factory( &block )
      ( @factories ||= [] ) << block
    end
  
    # Keep track of the classes that inherit from me
    def self.inherited( subklass )
      ( @subclasses ||= [] ) << subklass
    end
  
    # All the question factories in myself and subclasses
    def self.factories
      @factories ||= []
      @subclasses ||= []
      @factories + @subclasses.map{ |sub| sub.factories }.flatten
    end
  
    # ...
  end

First notice the self.inherited() class method. That's a hook Ruby calls
whenever your class is subclassed. This method just tracks all known
subclasses, as you can see.

The self.add_factory() method is used by subclasses to add "question factories"
for the topic that class represents. Again, these are just collected into an
Array.

The real magic is self.factories(), though it too is trivial. When asked to
return its factories, it returns its own and all the factories for subclasses.
All of that comes together in one more interface method.

When user code asks for a question with self.create_question(), a call to
self.factories() ensures that anything added by self.add_factory() can be
selected in addition to any factories of known subclasses tracked by
self.inherited().

I just think that's too smooth. Thanks for the lesson, Gavin!

Alright, let's examine a complete example solution. Here's the start of my
code:

  #!/usr/local/bin/ruby -w
  
  require "erb"
  
  # Glenn Parker's code from Ruby Quiz 25...
  require "english_numerals"
  class Integer
    alias_method :to_en, :to_english
  end
  
  class Array
    def insert_at_nil( obj )
      if i = index(nil)
        self[i] = obj
        i
      else
        self << obj
        size - 1
      end
    end
  end
  
  # ...

Just a little setup work here. I pull in ERb for question templates, load
Glenn's Ruby Quiz #25 solution and add a shortcut to it, and finally add a
method to Array for inserting an object at the first nil position and returning
the index of where it ended up.

Now we get to some captcha code:

  # ...
  
  module MathCaptcha
    @@captchas = Array.new
    @@answers = Array.new
    
    def self.add_captcha( template, &validator )
      @@captchas << Array[template, validator]
    end
    
    def self.create_question
      raise "No captchas loaded." if @@captchas.empty?
      
      captcha = @@captchas[rand(@@captchas.size)]
      
      args = Array.new
      class << args
        def arg( value )
          push(value)
          value
        end
        
        def resolve( template )
          ERB.new(template).result(binding)
        end
      end
      question = args.resolve(captcha.first)
      index = @@answers.insert_at_nil(Array[captcha.first, *args])
      
      Hash[:question => question, :answer_id => index]
    end
    
    # ...

The first method, self.add_captchas(), is my answer to the first extra credit.
My code reads a configuration file in which you can call this method as much as
you want to prepare your captcha system. You pass in two things: An ERb
template of the question and a block that will validate answers.

Your template can embed whatever code is needed to produce a question. Inside
the template you have access to the magic arg() method, which returns whatever
object is passed in, but ensures that that object will also be passed to the
validation block along with the answer. The point is that you can randomize
whatever you like, and ensure that you have the pieces to validate answers for
the resulting questions when the time comes.

The block is passed the answer given and everything that was filtered through
arg(). It is expected to return true or false, indicating if the answer is
acceptable.

The other method, self.create_question(), is what resolves the ERb template and
tucks the answer details away for later use. First, one of the added captchas
is selected at random. That template is then resolved in the context of a
modified Array, with the previously mentioned arg() method. The template and
Array of arguments are then stored in the @@answers variable for later
validation. (We can't store the validator because it's a Proc and I serialize
the answers.) Finally, the question and answer id are returned.

Here's the other half of the module:

    # ...
    
    def self.check_answer( answer )
      raise "Answer id required." unless answer.include? :answer_id
      
      template, *args = @@answers[answer[:answer_id]]
      raise "Answer not found." if template.nil?
      
      validator = @@captchas.assoc(template).last
      raise "Unable to match captcha." if validator.nil?
      
      if validator[answer[:answer], *args]
        @@answers[answer[:answer_id]] = nil
        true
      else
        false
      end
    end
    
    def self.load_answers( file )
      @@answers = File.open(file) { |answers| Marshal.load(answers) }
    end
    
    def self.load_captchas( file )
      code = File.read(file)
      eval(code, binding)
    end
    
    def self.save_answers( file )
      File.open(file, "w") { |answers| Marshal.dump(@@answers, answers) }
    end
  end
  
  # ...

The other side of the equation is self.check_answer(), which uses the provided
id to look up the answer details. The template from those is used to fetch the
validation block for that question and the block is called. If the answer
validates, we clear that answer out and return true. The answer isn't cleared
in the event of a false response, in case a typo was made.

The other three methods are for loading and saving the data the program relies
on. Answers are serialized and read with the help of Marshal. The captcha file
is simply eval()ed in the context of the module, so it can add captchas using
Ruby code.

Here's the interface code:

  # ...
  
  if __FILE__ == $0
    captchas = File.join(ENV["HOME"], ".math_captchas")
    unless File.exists? captchas
      File.open(captchas, "w") { |file| file << DATA.read }
    end
    MathCaptcha.load_captchas(captchas)
    
    answers = File.join(ENV["HOME"], ".math_captcha_answers")
    MathCaptcha.load_answers(answers) if File.exists? answers
    
    END { MathCaptcha.save_answers(answers) }
    
    if ARGV.empty?
      question = MathCaptcha.create_question
      puts "#{question[:answer_id]} : #{question[:question]}"
    else
      args = Hash.new
      while ARGV.size >= 2 and ARGV.first =~ /^--\w+$/
        key = ARGV.shift[2..-1].to_sym
        value = ARGV.first =~ /^\d+$/ ? ARGV.shift.to_i : ARGV.shift
        args[key] = value
      end
      
      answer = MathCaptcha.check_answer(args)
      puts answer
      
      exit(answer ? 0 : -1)
    end
  end
  
  # ...

The first part of that code reads the captcha and answer files into memory. If
the captcha file didn't exist, a sample file is created for the user to modify.
Once the answer file is loaded, an END { ... } block is registered so the
program will be sure to update it on exit.

Finally, we've come to the quiz interface. If no arguments were given, create a
question. If we got arguments, check the answer for the indicated question and
print true or false. I also use the exit code to notify success or failure.

I used the DATA section of the source to store the sample captcha file:

  # ...
  
  __END__
  add_captcha(
    "<%= arg(rand(10)).to_en.capitalize %> plus <%= arg(2).to_en %>?"
  ) do |answer, *opers|
    if answer.is_a?(String) and answer =~ /^\d+$/
      answer = answer.to_i.to_en
    elsif answer.is_a?(Integer)
      answer = answer.to_en
    end
    answer == opers.inject { |sum, var| sum + var }.to_en
  end

There's only one example there, but it does show how to use arg() and to_en().
The block only looks so complicated because I allow three different forms for
the answer. I think these templates are pretty flexible, since you get to use
Ruby at both ends to build questions and check answers.

My thanks to Gavin Kistner for a great problem and a clever solution.

Tomorrow's challenge is for all you language junkies out there. Bring your
translation skills...