rufus-decision 0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+