rufus-decision 0.9

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,105 @@
1
+
2
+ = rufus-decision
3
+
4
+
5
+ == getting it
6
+
7
+ sudo gem install rufus-decision
8
+
9
+ or at
10
+
11
+ http://rubyforge.org/frs/?group_id=4812
12
+
13
+
14
+ == usage
15
+
16
+ An example where a few rules determine which salesperson should interact with a customer with given characteristics.
17
+
18
+
19
+ require 'rubygems'
20
+ require 'rufus/decision'
21
+
22
+ include Rufus
23
+
24
+ TABLE = DecisionTable.new("""
25
+ in:age,in:trait,out:salesperson
26
+
27
+ 18..35,,adeslky
28
+ 25..35,,bronco
29
+ 36..50,,espadas
30
+ 51..78,,thorsten
31
+ 44..120,,ojiisan
32
+
33
+ 25..35,rich,kerfelden
34
+ ,cheerful,swanson
35
+ ,maniac,korolev
36
+ """)
37
+
38
+ #
39
+ # Given a customer (a Hash instance directly, for
40
+ # convenience), returns the name of the first
41
+ # corresponding salesman.
42
+ #
43
+ def determine_salesperson (customer)
44
+
45
+ TABLE.transform(customer)["salesperson"]
46
+ end
47
+
48
+ puts determine_salesperson(
49
+ "age" => 72)
50
+ # => thorsten
51
+
52
+ puts determine_salesperson(
53
+ "age" => 25, "trait" => "rich")
54
+ # => adeslky
55
+
56
+ puts determine_salesperson(
57
+ "age" => 23, "trait" => "cheerful")
58
+ # => adeslky
59
+
60
+ puts determine_salesperson(
61
+ "age" => 25, "trait" => "maniac")
62
+ # => adeslky
63
+
64
+ puts determine_salesperson(
65
+ "age" => 44, "trait" => "maniac")
66
+ # => espadas
67
+
68
+
69
+ More at Rufus::DecisionTable
70
+
71
+
72
+ = dependencies
73
+
74
+ The gem 'rufus-dollar' (http://rufus.rubyforge.org/rufus-dollar) and the 'rufus-eval' gem (http://rufus.rubyforge.org/rufus-eval).
75
+
76
+
77
+ == mailing list
78
+
79
+ On the rufus-ruby list[http://groups.google.com/group/rufus-ruby] :
80
+
81
+ http://groups.google.com/group/rufus-ruby
82
+
83
+
84
+ == issue tracker
85
+
86
+ http://rubyforge.org/tracker/?atid=18584&group_id=4812&func=browse
87
+
88
+
89
+ == source
90
+
91
+ http://rufus.rubyforge.org/svn/trunk/mnemo
92
+
93
+ svn checkout http://rufus.rubyforge.org/svn/trunk/mnemo
94
+
95
+
96
+ == author
97
+
98
+ John Mettraux, jmettraux@gmail.com
99
+ http://jmettraux.wordpress.com
100
+
101
+
102
+ == license
103
+
104
+ MIT
105
+
@@ -0,0 +1,623 @@
1
+ #
2
+ #--
3
+ # Copyright (c) 2007-2008, John Mettraux, jmettraux@gmail.com
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in
13
+ # all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ # THE SOFTWARE.
22
+ #++
23
+ #
24
+
25
+ #
26
+ # "made in Japan"
27
+ #
28
+ # John Mettraux at openwfe.org
29
+ #
30
+
31
+ require 'csv'
32
+ require 'open-uri'
33
+
34
+ require 'rubygems'
35
+ require 'rufus/dollar'
36
+
37
+ require 'rufus/hashes'
38
+
39
+
40
+ module Rufus
41
+
42
+
43
+ #
44
+ # Does s starts with prefix ?
45
+ #
46
+ def Rufus.starts_with? (s, prefix)
47
+
48
+ return false unless s
49
+ (s[0, prefix.length] == prefix)
50
+ end
51
+
52
+
53
+ #
54
+ # A decision table is a description of a set of rules as a CSV (comma
55
+ # separated values) file. Such a file can be edited / generated by
56
+ # a spreadsheet (Excel, Google spreadsheets, Gnumeric, ...)
57
+ #
58
+ # == Disclaimer
59
+ #
60
+ # The decision / CSV table system is no replacement for
61
+ # full rule engines with forward and backward chaining, RETE implementation
62
+ # and the like...
63
+ #
64
+ #
65
+ # == Usage
66
+ #
67
+ # The following CSV file
68
+ #
69
+ # in:topic,in:region,out:team_member
70
+ # sports,europe,Alice
71
+ # sports,,Bob
72
+ # finance,america,Charly
73
+ # finance,europe,Donald
74
+ # finance,,Ernest
75
+ # politics,asia,Fujio
76
+ # politics,america,Gilbert
77
+ # politics,,Henry
78
+ # ,,Zach
79
+ #
80
+ # embodies a rule for distributing items (piece of news) labelled with a
81
+ # topic and a region to various members of a team.
82
+ # For example, all news about finance from Europe are to be routed to
83
+ # Donald.
84
+ #
85
+ # Evaluation occurs row by row. The "in out" row states which field
86
+ # is considered at input and which are to be modified if the "ins" do
87
+ # match.
88
+ #
89
+ # The default behaviour is to change the value of the "outs" if all the
90
+ # "ins" match and then terminate.
91
+ # An empty "in" cell means "matches any".
92
+ #
93
+ # Enough words, some code :
94
+ #
95
+ # table = DecisionTable.new("""
96
+ # in:topic,in:region,out:team_member
97
+ # sports,europe,Alice
98
+ # sports,,Bob
99
+ # finance,america,Charly
100
+ # finance,europe,Donald
101
+ # finance,,Ernest
102
+ # politics,asia,Fujio
103
+ # politics,america,Gilbert
104
+ # politics,,Henry
105
+ # ,,Zach
106
+ # """)
107
+ #
108
+ # h = {}
109
+ # h["topic"] = "politics"
110
+ #
111
+ # table.transform! h
112
+ #
113
+ # puts h["team_member"]
114
+ # # will yield "Henry" who takes care of all the politics stuff,
115
+ # # except for Asia and America
116
+ #
117
+ # '>', '>=', '<' and '<=' can be put in front of individual cell values :
118
+ #
119
+ # table = DecisionTable.new("""
120
+ # ,
121
+ # in:fx, out:fy
122
+ # ,
123
+ # >100,a
124
+ # >=10,b
125
+ # ,c
126
+ # """)
127
+ #
128
+ # h = { 'fx' => '10' }
129
+ # table.transform! h
130
+ #
131
+ # require 'pp'; pp h
132
+ # # will yield { 'fx' => '10', 'fy' => 'b' }
133
+ #
134
+ # Such comparisons are done after the elements are transformed to float
135
+ # numbers. By default, non-numeric arguments will get compared as Strings.
136
+ #
137
+ #
138
+ # == transform and transform!
139
+ #
140
+ # The method transform! acts directly on its parameter hash, the method
141
+ # transform will act on a copy of it. Both methods return their transformed
142
+ # hash.
143
+ #
144
+ #
145
+ # == Ruby ranges
146
+ #
147
+ # Ruby ranges are also accepted in cells.
148
+ #
149
+ # in:f0,out:result
150
+ # ,
151
+ # 0..32,low
152
+ # 33..66,medium
153
+ # 67..100,high
154
+ #
155
+ # will set the field 'result' to 'low' for f0 => 24
156
+ #
157
+ #
158
+ # == Options
159
+ #
160
+ # You can put options on their own in a cell BEFORE the line containing
161
+ # "in:xxx" and "out:yyy" (ins and outs).
162
+ #
163
+ # Currently, two options are supported, "ignorecase" and "through".
164
+ #
165
+ # * "ignorecase", if found by the DecisionTable will make any match (in the "in"
166
+ # columns) case unsensitive.
167
+ #
168
+ # * "through", will make sure that EVERY row is evaluated and potentially
169
+ # applied. The default behaviour (without "through"), is to stop the
170
+ # evaluation after applying the results of the first matching row.
171
+ #
172
+ # * "accumulate", behaves as with "through" set but instead of overriding
173
+ # values each time a match is found, will gather them in an array.
174
+ #
175
+ # accumulate
176
+ # in:f0,out:result
177
+ # ,
178
+ # ,normal
179
+ # >10,large
180
+ # >100,xl
181
+ #
182
+ # will yield { result => [ 'normal', 'large' ]} for f0 => 56
183
+ #
184
+ #
185
+ # == Cross references
186
+ #
187
+ # By using the 'dollar notation', it's possible to reference a value
188
+ # already in the hash.
189
+ #
190
+ # in:value,in:roundup,out:newvalue
191
+ # 0..32,true,32
192
+ # 33..65,true,65
193
+ # 66..99,true,99
194
+ # ,,${value}
195
+ #
196
+ # Here, if 'roundup' is set to true, newvalue will hold 32, 65 or 99
197
+ # as value, else it will simply hold the 'value'.
198
+ #
199
+ # The value is the value as currently found in the transformed hash, not
200
+ # as found in the original (non-transformed) hash.
201
+ #
202
+ #
203
+ # == Ruby code evaluation
204
+ #
205
+ # The dollar notation can be used for yet another trick, evaluation of
206
+ # ruby code at transform time.
207
+ #
208
+ # Note though that this feature is only enabled via the :ruby_eval
209
+ # option of the transform!() method.
210
+ #
211
+ # decisionTable.transform! h, :ruby_eval => true
212
+ #
213
+ # That decision table may look like :
214
+ #
215
+ # in:value,in:result
216
+ # 0..32,${r:Time.now.to_f}
217
+ # 33..65,${r:call_that_other_function()}
218
+ # 66..99,${r:${value} * 3}
219
+ #
220
+ # (It's a very simplistic example, but I hope it demonstrates the
221
+ # capabilities of this technique)
222
+ #
223
+ #
224
+ # == See also
225
+ #
226
+ # * http://jmettraux.wordpress.com/2007/02/11/ruby-decision-tables/
227
+ #
228
+ class DecisionTable
229
+
230
+ #
231
+ # when set to true, the transformation process stops after the
232
+ # first match got applied.
233
+ #
234
+ attr_accessor :first_match
235
+
236
+ #
237
+ # when set to true, matches evaluation ignores case.
238
+ #
239
+ attr_accessor :ignore_case
240
+
241
+ #
242
+ # when set to true, multiple matches result get accumulated in
243
+ # an array.
244
+ #
245
+ attr_accessor :accumulate
246
+
247
+ #
248
+ # The constructor for DecisionTable, you can pass a String, an Array
249
+ # (of arrays), a File object. The CSV parser coming with Ruby will take
250
+ # care of it and a DecisionTable instance will be built.
251
+ #
252
+ def initialize (csv_data)
253
+
254
+ @first_match = true
255
+ @ignore_case = false
256
+ @accumulate = false
257
+
258
+ @header = nil
259
+ @rows = []
260
+
261
+ csv_array = to_csv_array(csv_data)
262
+
263
+ csv_array.each do |row|
264
+
265
+ next if empty_row? row
266
+
267
+ if @header
268
+
269
+ @rows << row.collect { |c| c.strip if c }
270
+ else
271
+
272
+ parse_header_row row
273
+ end
274
+ end
275
+ end
276
+
277
+ #
278
+ # Like transform, but the original hash doesn't get touched,
279
+ # a copy of it gets transformed and finally returned.
280
+ #
281
+ def transform (hash, options={})
282
+
283
+ transform! hash.dup, options
284
+ end
285
+
286
+ #
287
+ # Passes the hash through the decision table and returns it,
288
+ # transformed.
289
+ #
290
+ def transform! (hash, options={})
291
+
292
+ hash = Rufus::EvalHashFilter.new(hash) \
293
+ if options[:ruby_eval] == true
294
+
295
+ @rows.each do |row|
296
+
297
+ if matches?(row, hash)
298
+
299
+ apply row, hash
300
+ break if @first_match
301
+ end
302
+ end
303
+
304
+ hash
305
+ end
306
+
307
+ #
308
+ # Outputs back this table as a CSV String
309
+ #
310
+ def to_csv
311
+
312
+ s = ""
313
+ s << @header.to_csv
314
+ s << "\n"
315
+ @rows.each do |row|
316
+ s << row.join(",")
317
+ s << "\n"
318
+ end
319
+ s
320
+ end
321
+
322
+ private
323
+
324
+ def parse_uri (string)
325
+
326
+ return nil if string.split("\n").size > 1
327
+
328
+ begin
329
+ return URI::parse(string)
330
+ rescue Exception => e
331
+ end
332
+
333
+ nil
334
+ end
335
+
336
+ def to_csv_array (csv_data)
337
+
338
+ return csv_data if csv_data.kind_of?(Array)
339
+
340
+ csv_data = csv_data.to_s if csv_data.is_a?(URI)
341
+
342
+ csv_data = open(csv_data) if parse_uri(csv_data)
343
+
344
+ CSV::Reader.parse csv_data
345
+ end
346
+
347
+ def matches? (row, hash)
348
+
349
+ return false if empty_row?(row)
350
+
351
+ #puts
352
+ #puts "__row match ?"
353
+ #p row
354
+
355
+ @header.ins.each_with_index do |in_header, icol|
356
+
357
+ in_header = resolve_in_header(in_header)
358
+
359
+ value = Rufus::dsub in_header, hash
360
+
361
+ cell = row[icol]
362
+
363
+ next if not cell
364
+
365
+ cell = cell.strip
366
+
367
+ next if cell.length < 1
368
+
369
+ cell = Rufus::dsub cell, hash
370
+
371
+ #puts "__does '#{value}' match '#{cell}' ?"
372
+
373
+ c = cell[0, 1]
374
+
375
+ b = if c == '<' or c == '>'
376
+
377
+ numeric_compare value, cell
378
+ else
379
+
380
+ range = to_ruby_range cell
381
+
382
+ if range
383
+ range.include?(value)
384
+ else
385
+ regex_compare value, cell
386
+ end
387
+ end
388
+
389
+ return false unless b
390
+ end
391
+
392
+ #puts "__row matches"
393
+
394
+ true
395
+ end
396
+
397
+ def regex_compare (value, cell)
398
+
399
+ modifiers = 0
400
+ modifiers += Regexp::IGNORECASE if @ignore_case
401
+
402
+ rcell = Regexp.new(cell, modifiers)
403
+
404
+ rcell.match(value)
405
+ end
406
+
407
+ def numeric_compare (value, cell)
408
+
409
+ comparator = cell[0, 1]
410
+ comparator += "=" if cell[1, 1] == "="
411
+ cell = cell[comparator.length..-1]
412
+
413
+ nvalue = narrow(value)
414
+ ncell = narrow(cell)
415
+
416
+ if nvalue.is_a? String or ncell.is_a? String
417
+ value = '"' + value + '"'
418
+ cell = '"' + cell + '"'
419
+ else
420
+ value = nvalue
421
+ cell = ncell
422
+ end
423
+
424
+ s = "#{value} #{comparator} #{cell}"
425
+
426
+ #puts "...>>>#{s}<<<"
427
+
428
+ begin
429
+ return Rufus::eval_safely(s, 4)
430
+ rescue Exception => e
431
+ end
432
+
433
+ false
434
+ end
435
+
436
+ def narrow (s)
437
+ begin
438
+ return Float(s)
439
+ rescue Exception => e
440
+ end
441
+ s
442
+ end
443
+
444
+ def resolve_in_header (in_header)
445
+
446
+ "${#{in_header}}"
447
+ end
448
+
449
+ def apply (row, hash)
450
+
451
+ @header.outs.each_with_index do |out_header, icol|
452
+
453
+ next unless out_header
454
+
455
+ value = row[icol]
456
+
457
+ next unless value
458
+ #next unless value.strip.length > 0
459
+ next unless value.length > 0
460
+
461
+ value = Rufus::dsub value, hash
462
+
463
+ hash[out_header] = if @accumulate
464
+ #
465
+ # accumulate
466
+
467
+ v = hash[out_header]
468
+ if v and v.is_a?(Array)
469
+ v + Array(value)
470
+ elsif v
471
+ [ v, value ]
472
+ else
473
+ value
474
+ end
475
+ else
476
+ #
477
+ # override
478
+
479
+ value
480
+ end
481
+ end
482
+ end
483
+
484
+ def parse_header_row (row)
485
+
486
+ row.each_with_index do |cell, icol|
487
+
488
+ next unless cell
489
+
490
+ cell = cell.strip
491
+ s = cell.downcase
492
+
493
+ if s == "ignorecase" or s == "ignore_case"
494
+ @ignore_case = true
495
+ next
496
+ end
497
+
498
+ if s == "through"
499
+ @first_match = false
500
+ next
501
+ end
502
+
503
+ if s == "accumulate"
504
+ @first_match = false
505
+ @accumulate = true
506
+ next
507
+ end
508
+
509
+ if Rufus::starts_with?(cell, "in:") or \
510
+ Rufus::starts_with?(cell, "out:")
511
+
512
+ @header = Header.new unless @header
513
+ @header.add cell, icol
514
+ end
515
+ end
516
+ end
517
+
518
+ def empty_row? (row)
519
+
520
+ return true unless row
521
+ return true if (row.length == 1 and not row[0])
522
+ row.each do |cell|
523
+ return false if cell
524
+ end
525
+ true
526
+ end
527
+
528
+ #
529
+ # A regexp for checking if a string is a numeric Ruby range
530
+ #
531
+ RUBY_NUMERIC_RANGE_REGEXP = Regexp.compile(
532
+ "^\\d+(\\.\\d+)?\\.{2,3}\\d+(\\.\\d+)?$")
533
+
534
+ #
535
+ # A regexp for checking if a string is an alpha Ruby range
536
+ #
537
+ RUBY_ALPHA_RANGE_REGEXP = Regexp.compile(
538
+ "^([A-Za-z])(\\.{2,3})([A-Za-z])$")
539
+
540
+ #
541
+ # If the string contains a Ruby range definition
542
+ # (ie something like "93.0..94.5" or "56..72"), it will return
543
+ # the Range instance.
544
+ # Will return nil else.
545
+ #
546
+ # The Ruby range returned (if any) will accept String or Numeric,
547
+ # ie (4..6).include?("5") will yield true.
548
+ #
549
+ def to_ruby_range (s)
550
+
551
+ range = if RUBY_NUMERIC_RANGE_REGEXP.match(s)
552
+
553
+ eval s
554
+
555
+ else
556
+
557
+ m = RUBY_ALPHA_RANGE_REGEXP.match(s)
558
+
559
+ if m
560
+ eval "'#{m[1]}'#{m[2]}'#{m[3]}'"
561
+ else
562
+ nil
563
+ end
564
+ end
565
+
566
+ class << range
567
+
568
+ alias :old_include? :include?
569
+
570
+ def include? (elt)
571
+
572
+ elt = if first.is_a?(Numeric)
573
+ Float(elt)
574
+ else
575
+ elt
576
+ end
577
+
578
+ old_include?(elt)
579
+ end
580
+
581
+ end if range
582
+
583
+ range
584
+ end
585
+
586
+ class Header
587
+
588
+ attr_accessor :ins, :outs
589
+
590
+ def initialize
591
+
592
+ @ins = []
593
+ @outs = []
594
+ end
595
+
596
+ def add (cell, icol)
597
+
598
+ if Rufus::starts_with?(cell, "in:")
599
+ @ins[icol] = cell[3..-1]
600
+ #puts "i added #{@ins[icol]}"
601
+ elsif Rufus::starts_with?(cell, "out:")
602
+ @outs[icol] = cell[4..-1]
603
+ #puts "o added #{@outs[icol]}"
604
+ end
605
+ # else don't add
606
+ end
607
+
608
+ def to_csv
609
+
610
+ s = ""
611
+ @ins.each do |_in|
612
+ s << "in:#{_in}," if _in
613
+ end
614
+ @outs.each do |out|
615
+ s << "out:#{out}," if out
616
+ end
617
+ s[0..-2]
618
+ end
619
+ end
620
+ end
621
+
622
+ end
623
+