[QUIZ] Statistician II (#168)

Apologies if this appears as a repost, but the mailing list complained it
was too large. Posting now in two parts:

···

-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-

The three rules of Ruby Quiz 2:

1. Please do not post any solutions or spoiler discussion for this
quiz until 48 hours have passed from the time on this message.

2. Support Ruby Quiz 2 by submitting ideas as often as you can! (A
permanent, new website is in the works for Ruby Quiz 2. Until then,
please visit the temporary website at

     <http://splatbang.com/rubyquiz/>.

3. Enjoy!
Suggestion: A [QUIZ] in the subject of emails about the problem
helps everyone on Ruby Talk follow the discussion. Please reply to
the original quiz message, if you can.

-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-

This week's quiz might look a little scary, but it's not as bad as it seems.
Give it a shot, and ask for help if you get stuck!

## Statistician II

Last week's quiz started the creation of a line-based pattern-matching
system: our statistician. This week, your task is to further develop a
solution from last week: organize the code and provide a more interesting
interface.

The first thing is organization. This little library should be reusable and
not tied to any particular parsing need. So we want to separate out the
"Statistician" from the client. To do this means moving the appropriate code
into a separate file called `statistician.rb`, containing:

    # statistician.rb
    module Statistician
      # This module is your task! Your code goes here...
    end

Meanwhile, the client code will now begin with:

    # client.rb
    require 'statistician'

Simple, eh?

Next, we will move the rules from their own data file and bring them into
the code. Admittedly, moving data into code usually is not a wise thing to
do, but as the primary data is that which the rules parse, we're going to do
it anyway. Besides, this is Ruby Quiz, so why not?

Simultaneously, we're going to group rules together: rules that while may
differ somewhat in appearance, essentially represent the same kind or
category of data. As the rules and category are client data, they will go
into the client's code. Here's an example to begin, borrowing the LotRO
rules used last week.

    # client.rb
    class Offense < Statistician::Reportable
      rule "You wound[ the] <name>[ with <attack>] for <amount> point[s] of
<kind>[ damage]."
      rule "You reflect <amount> point[s] of <kind> damage to[ the] <name>."
    end

    class Victory < Statistician::Reportable
      rule "Your mighty blow defeated[ the] <name>."
    end

Next, we need a parser (or Reporter, as I like to call it) that can manage
these rules and classes, read the input data and process it all line by
line. Such client code looks like this:

    # client.rb
    lotro = Statistician::Reporter.new(Offense, Victory)
    lotro.parse(File.read(ARGV[0]))

Finally, we need to begin getting useful information out of all the records
that have been read and parsed by the Reporter. After the data is parsed,
the final bit will be to support code such as this:

    # client.rb
    num = Offense.records.size
    dmg = Offense.records.inject(0) { |s, x| s + x.amount.to_i }
    puts "Average damage inflicted: #{dmg.to_f / num}"

    puts Offense.records[0].class # outputs "Offense"

What is going on here? The class `Offense` serves three purposes.

  1. Its declaration contains the rules for offensive related records.
  2. After parsing, the class method `records` returns an array of records
that matched those rules.
  3. Those records are instances of the class, and instance methods that
match the field names (extracted from the rules) provide access to a
record's data.

Hopefully this isn't too confusing. I could have broken up some of these
responsibilities into other classes or sections of code, but since the three
tasks are rather related, I thought it convenient and pleasing to group them
all into the client's declared class.

Below I'll give the full, sample client file I'm using, as well as the
output it generates when run over the [hunter.txt][1] file we used last
week. A few hints, first...

  1. You are welcome to make `statistician.rb` depend on other Ruby modules.
I personally found `OpenStruct` to be quite useful here.

  2. Personally, I found making `Offense` inherit from `Reportable` to be
the cleanest method. At least, it is in my own code. There may be other ways
to accomplish this goal: by `include` or `extend` methods. If you find those
techniques more appealing, please go ahead, but make a note of it in your
submission, since it does require changing how client code is written.

  3. Metaprogramming can get a bit tricky to explain in a couple sentences,
so I'll leave such hints and discussion for the mailing list. Aside from
that, there are some good examples of metaprogramming looking back through
past Ruby Quizzes. Of particular interest would be the [metakoans.rb
quiz][2].

  4. Finally, my own solution for this week's quiz is just under 80 lines
long, so it need not be overly complex to support the client file below.

((( Remainder of this Ruby Quiz to come as a reply to this message. )))

Here is the complete, sample client file:

    require 'statistician'

    class Defense < Statistician::Reportable
      rule "[The ]<name> wounds you[ with <attack>] for <amount>
point[s] of <kind>[ damage]."
      rule "You are wounded for <amount> point[s] of <kind> damage."
    end

    class Offense < Statistician::Reportable
      rule "You wound[ the] <name>[ with <attack>] for <amount>
point[s] of <kind>[ damage]."
      rule "You reflect <amount> point[s] of <kind> damage to[ the]
<name>."
    end

    class Defeat < Statistician::Reportable
      rule "You succumb to your wounds."
    end

    class Victory < Statistician::Reportable
      rule "Your mighty blow defeated[ the] <name>."
    end

    class Healing < Statistician::Reportable
      rule "You heal <amount> points of your wounds."
      rule "<player> heals you for <amount> of wound damagepoints."
    end

    class Regen < Statistician::Reportable
      rule "You heal yourself for <amount> Power points."
      rule "<player> heals you for <amount> Power points."
    end

    class Comment < Statistician::Reportable
      rule "### <comment> ###"
    end

    class Ignored < Statistician::Reportable
      rule "<player> defeated[ the] <name>."
      rule "<player> has succumbed to his wounds."
      rule "You have spotted a creature attempting to move stealthily
about."
      rule "You sense that a creature is nearby but hidden from your
sight."
      rule "[The ]<name> incapacitated you."
    end

    if __FILE__ == $0
      lotro = Statistician::Reporter.new(Defense, Offense, Defeat,
Victory,
                                         Healing, Regen, Comment,
Ignored)
      lotro.parse(File.read(ARGV[0]))

      num = Offense.records.size
      dmg = Offense.records.inject(0) { |sum, off| sum +
Integer(off.amount.gsub(',', '_')) }
      d = Defense.records[3]

      puts <<-EOT
    Number of Offense records: #{num}
    Total damage inflicted: #{dmg}
    Average damage per Offense: #{(100.0 * dmg / num).round / 100.0}

    Defense record 3 indicates that a #{d.name} attacked me
    using #{d.attack}, doing #{d.amount} points of damage.

    Unmatched rules:
    #{lotro.unmatched.join("\n")}

    Comments:
    #{Comment.records.map { |c| c.comment }.join("\n")}

      EOT
    end

And here is the output it generates, using the [hunter.txt][1] data
file:

    Number of Offense records: 1300
    Total damage inflicted: 127995
    Average damage per Offense: 98.46

    Defense record 3 indicates that a Tempest Warg attacked me
    using Melee Double, doing 108 points of damage.

    Unmatched rules:
    The Trap wounds Goblin-town Guard for 128 points of Common damage.
    Nothing to cure.

    Comments:
    Chat Log: Combat 04/04 00:34 AM

[1]: http://www.splatbang.com/rubyquiz/files/hunter.zip
[2]: http://www.rubyquiz.com/quiz67.html

Thanks for this quiz, it was actually quite fun! You can find my
solution here:

http://pastie.org/228592

I didn't expect my code to become this concise when I first read the
problem statement, but I guess that's just the way Ruby works :slight_smile:

Matthias.

···

--
Posted via http://www.ruby-forum.com/.

Here's my own solution for this quiz (which I made sure I could do
reasonably before posting the quiz!). Very similar in appearance to
Matthias' solution above.

As a pastie: http://pastie.org/228598

require 'ostruct'

module Statistician

  class Reportable < OpenStruct
    def Reportable.inherited(klass)
      # Give each individual Reportable some instance data.
      # Doing it this way ensures each klass gets it's own rules/
records, rather
      # than sharing amongst all Reportables.
      klass.instance_eval %{
        @reportable_rules = []
        @reportable_records = []
      }
    end

    # Class methods

    def self.rule(str)
      r = Rule.new(str)
      @reportable_rules << r
    end

    def self.match(str)
      data = nil
      if @reportable_rules.find { |rule| data = rule.match(str) }
        return data
      end
    end

    def self.records
      @reportable_records
    end

    # Helpers

    class Rule
      def initialize(str)
        patt = Regexp.escape(str).gsub('\[', '(?:').gsub('\]',
')?').gsub(/<(.+?)>/, '(.+?)')
        @pattern = Regexp.new("^#{patt}$")
        @fields = str.scan(/<(.+?)>/).flatten.map { |f| f.to_sym }
      end

      def match(str)
        if md = @pattern.match(str)
          Hash[*@fields.zip(md.captures).flatten]
        else
          nil
        end
      end
    end
  end # class Reportable

  class Reporter
    attr_reader :unmatched

    def initialize(*reportables)
      @reportables = reportables
      @unmatched = []
    end

    def parse(text)
      text.each do |line|
        line.strip!
        data = nil
        if reportable = @reportables.find { |k| data = k.match(line) }
          reportable.records << reportable.new(data)
        else
          @unmatched << line
        end
      end
    end
  end # class Reporter

end # module Statistician

One minor note, about this little bit of code:

Integer(off.amount.gsub(',', '_')) }

I found that there was at least one instance of a number over one
thousand in the source file, using commas to separate. Neither the
Integer initializer nor the to_i methods recognize that, so this gsub
makes it safe to convert.