Class: Philiprehberger::RuleEngine::Engine

Inherits:
Object
  • Object
show all
Includes:
Helpers
Defined in:
lib/philiprehberger/rule_engine/engine.rb

Overview

A lightweight rule engine with declarative conditions and actions.

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from Helpers

#all?, #any?, #none?

Constructor Details

#initialize(mode: :all) {|engine| ... } ⇒ Engine

Create a new rule engine.

Parameters:

  • mode (Symbol) (defaults to: :all)

    :all to run all matching rules, :first to stop after first match

Yields:

  • (engine)

    block for defining rules using the DSL

Raises:



19
20
21
22
23
24
25
26
# File 'lib/philiprehberger/rule_engine/engine.rb', line 19

def initialize(mode: :all, &block)
  raise Error, 'mode must be :all or :first' unless %i[all first].include?(mode)

  @rules = []
  @mode = mode
  @stats = {}
  instance_eval(&block) if block
end

Instance Attribute Details

#modeSymbol (readonly)

Returns the evaluation mode (:all or :first).

Returns:

  • (Symbol)

    the evaluation mode (:all or :first)



13
14
15
# File 'lib/philiprehberger/rule_engine/engine.rb', line 13

def mode
  @mode
end

#rulesArray<Rule> (readonly)

Returns the registered rules.

Returns:

  • (Array<Rule>)

    the registered rules



10
11
12
# File 'lib/philiprehberger/rule_engine/engine.rb', line 10

def rules
  @rules
end

Instance Method Details

#add_rule(name, tags: []) {|rule| ... } ⇒ Rule

Add a rule after engine creation.

Parameters:

  • name (String)

    the rule name

  • tags (Array<Symbol>) (defaults to: [])

    optional tags

Yields:

  • (rule)

    block for configuring the rule

Returns:

  • (Rule)

    the created rule



47
48
49
# File 'lib/philiprehberger/rule_engine/engine.rb', line 47

def add_rule(name, tags: [], &block)
  rule(name, tags: tags, &block)
end

#chain(*rule_names) ⇒ Object

Execute rules sequentially as a pipeline. Each rule’s action result is passed as input: to the next rule.

Parameters:

  • rule_names (Array<String>)

    ordered rule names to chain

Returns:

  • (Object)

    the final action’s result



216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/philiprehberger/rule_engine/engine.rb', line 216

def chain(*rule_names)
  chain_rules = rule_names.map do |name|
    found = @rules.find { |r| r.name == name }
    raise Error, "rule not found: #{name}" unless found

    found
  end

  input = nil
  chain_rules.each do |r|
    facts = { input: input }
    input = r.execute(facts)
  end

  input
end

#clear_rules!self

Remove all rules and reset execution statistics.

Clears the internal rule list and discards all per-rule stat entries, returning the engine to a fresh state while preserving its mode.

Returns:

  • (self)

    the engine, for chaining



100
101
102
103
104
# File 'lib/philiprehberger/rule_engine/engine.rb', line 100

def clear_rules!
  @rules.clear
  @stats.clear
  self
end

#detect_conflictsArray<Hash>

Find rules with potentially overlapping conditions

Returns:

  • (Array<Hash>)

    pairs of rule names that could both match



180
181
182
183
184
185
186
187
# File 'lib/philiprehberger/rule_engine/engine.rb', line 180

def detect_conflicts
  pairs = []
  sorted = enabled_rules_sorted
  sorted.combination(2) do |a, b|
    pairs << { rules: [a.name, b.name], priorities: [a.priority, b.priority] }
  end
  pairs
end

#disable_rule(name) ⇒ void

This method returns an undefined value.

Disable a rule by name (skipped during evaluation).

Parameters:

  • name (String)

    the rule name to disable

Raises:



68
69
70
71
72
73
# File 'lib/philiprehberger/rule_engine/engine.rb', line 68

def disable_rule(name)
  found = @rules.find { |r| r.name == name }
  raise Error, "rule not found: #{name}" unless found

  found.enabled = false
end

#dry_run(facts, tags: nil) ⇒ Array<Hash>

Evaluate rules without executing actions, return matched rule info

Parameters:

  • facts (Hash)

    the facts to evaluate

Returns:

  • (Array<Hash>)

    matched rules with name and priority



171
172
173
174
175
# File 'lib/philiprehberger/rule_engine/engine.rb', line 171

def dry_run(facts, tags: nil)
  matched = filter_by_tags(enabled_rules_sorted, tags).select { |rule| rule.matches?(facts) }
  matched = [matched.first].compact if @mode == :first
  matched.map { |rule| { name: rule.name, priority: rule.priority } }
end

#enable_rule(name) ⇒ void

This method returns an undefined value.

Enable a rule by name.

Parameters:

  • name (String)

    the rule name to enable

Raises:



79
80
81
82
83
84
# File 'lib/philiprehberger/rule_engine/engine.rb', line 79

def enable_rule(name)
  found = @rules.find { |r| r.name == name }
  raise Error, "rule not found: #{name}" unless found

  found.enabled = true
end

#evaluate(facts, tags: nil) ⇒ Array<Hash>

Evaluate all rules against the given facts.

Parameters:

  • facts (Object)

    the facts to evaluate

  • tags (Array<Symbol>, nil) (defaults to: nil)

    only evaluate rules with at least one matching tag

Returns:

  • (Array<Hash>)

    results with :rule and :result for each matched rule



118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/philiprehberger/rule_engine/engine.rb', line 118

def evaluate(facts, tags: nil)
  sorted = filter_by_tags(@rules.select(&:enabled), tags).sort_by(&:priority)
  results = []

  sorted.each do |r|
    stat = @stats[r.name] ||= new_stat_entry
    stat[:evaluations] += 1

    next unless r.matches?(facts)

    stat[:matches] += 1
    start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
    result = r.execute(facts)
    elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time

    stat[:executions] += 1
    stat[:total_time] += elapsed
    stat[:avg_time] = stat[:total_time] / stat[:executions]
    stat[:last_triggered] = Time.now

    results << { rule: r.name, result: result }
    break if @mode == :first
  end

  results
end

#remove_rule(name) ⇒ Rule?

Remove a rule by name.

Parameters:

  • name (String)

    the rule name to remove

Returns:

  • (Rule, nil)

    the removed rule, or nil if not found



55
56
57
58
59
60
61
62
# File 'lib/philiprehberger/rule_engine/engine.rb', line 55

def remove_rule(name)
  index = @rules.index { |r| r.name == name }
  return nil unless index

  removed = @rules.delete_at(index)
  @stats.delete(name)
  removed
end

#reset_stats!void

This method returns an undefined value.

Reset all execution statistics.



163
164
165
# File 'lib/philiprehberger/rule_engine/engine.rb', line 163

def reset_stats!
  @stats.each_key { |k| @stats[k] = new_stat_entry }
end

#rule(name, tags: []) {|rule| ... } ⇒ Rule

Define a rule using the DSL.

Parameters:

  • name (String)

    the rule name

Yields:

  • (rule)

    block for configuring the rule

Returns:

  • (Rule)

    the created rule



33
34
35
36
37
38
39
# File 'lib/philiprehberger/rule_engine/engine.rb', line 33

def rule(name, tags: [], &block)
  r = Rule.new(name, tags: tags)
  r.instance_eval(&block) if block
  @rules << r
  @stats[name] = new_stat_entry
  r
end

#rule_namesArray<String>

Return the names of all registered rules in declaration order.

Returns:

  • (Array<String>)

    rule names in the order they were added



109
110
111
# File 'lib/philiprehberger/rule_engine/engine.rb', line 109

def rule_names
  @rules.map(&:name)
end

#rules_by_tag(tag) ⇒ Array<Rule>

Return rules matching a specific tag.

Parameters:

  • tag (Symbol)

    the tag to filter by

Returns:



90
91
92
# File 'lib/philiprehberger/rule_engine/engine.rb', line 90

def rules_by_tag(tag)
  @rules.select { |r| r.tags.include?(tag.to_sym) }
end

#statsHash

Return per-rule execution statistics.

Returns:

  • (Hash)

    stats keyed by rule name



148
149
150
151
152
153
154
155
156
157
158
# File 'lib/philiprehberger/rule_engine/engine.rb', line 148

def stats
  @stats.transform_values do |s|
    {
      evaluations: s[:evaluations],
      matches: s[:matches],
      executions: s[:executions],
      avg_time: s[:avg_time],
      last_triggered: s[:last_triggered]
    }
  end
end

#to_hHash

Serialize the engine configuration to a hash.

Returns:

  • (Hash)

    engine metadata including mode and rules



204
205
206
207
208
209
# File 'lib/philiprehberger/rule_engine/engine.rb', line 204

def to_h
  {
    mode: @mode,
    rules: @rules.map(&:to_h)
  }
end

#validate_rulesHash

Validate all rules have conditions and actions defined

Returns:

  • (Hash)

    { valid: Boolean, issues: Array<String> }



192
193
194
195
196
197
198
199
# File 'lib/philiprehberger/rule_engine/engine.rb', line 192

def validate_rules
  issues = []
  @rules.each do |rule|
    issues << "Rule '#{rule.name}' has no condition" unless rule.instance_variable_get(:@condition)
    issues << "Rule '#{rule.name}' has no action" unless rule.instance_variable_get(:@action)
  end
  { valid: issues.empty?, issues: issues }
end