Module: LazyGraph::Node::DerivedRules

Included in:
LazyGraph::Node
Defined in:
lib/lazy_graph/node/derived_rules.rb

Constant Summary collapse

PLACEHOLDER_VAR_REGEX =
/\$\{[^}]+\}/

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.extract_expr_from_source_location(source_location) ⇒ Object


90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/lazy_graph/node/derived_rules.rb', line 90

def self.extract_expr_from_source_location(source_location)
  @derived_proc_cache ||= {}
  mtime = File.mtime(source_location.first).to_i
  if @derived_proc_cache[source_location]&.last.to_i.< mtime
    @derived_proc_cache[source_location] = begin
      source_lines = get_file_body(source_location.first)

      proc_line = source_location.last - 1
      first_line = source_lines[proc_line]
      until first_line =~ /(?:lambda|proc|->)/ || proc_line.zero?
        proc_line -= 1
        first_line = source_lines[proc_line]
      end
      lines = source_lines[proc_line..]
      lines[0] = lines[0][/(?:lambda|proc|->).*/]
      src_str = ''.dup
      intermediate = nil
      lines.each do |line|
        token_count = 0
        line.split(/(?=\s|;|\)|\})/).each do |token|
          src_str << token
          token_count += 1
          intermediate = Prism.parse(src_str)
          next unless intermediate.success? && token_count > 1

          break
        end
        break if intermediate.success?
      end

      raise 'Source Extraction Failed' unless intermediate.success?

      src = intermediate.value.statements.body.first.yield_self do |s|
        s.type == :call_node ? s.block : s
      end
      requireds = (src.parameters&.parameters&.requireds || []).map(&:name)
      optionals = src.parameters&.parameters&.optionals || []
      keywords =  (src.parameters&.parameters&.keywords || []).map do |kw|
        [kw.name, kw.value.slice.gsub(/^_\./, '$.')]
      end.to_h
      [src, requireds, optionals, keywords, proc_line, mtime]
    end
  end

  @derived_proc_cache[source_location]
rescue StandardError => e
  LazyGraph.logger.error(e.message)
  LazyGraph.logger.error(e.backtrace.join("\n"))
  raise "Failed to extract expression from source location: #{source_location}. Ensure the file exists and the line number is correct. Extraction from a REPL is not supported"
end

.get_file_body(file_path) ⇒ Object


82
83
84
85
86
87
88
# File 'lib/lazy_graph/node/derived_rules.rb', line 82

def self.get_file_body(file_path)
  @file_body_cache ||= {}
  if @file_body_cache[file_path]&.last.to_i < File.mtime(file_path).to_i
    @file_body_cache[file_path] = [IO.readlines(file_path), File.mtime(file_path).to_i]
  end
  @file_body_cache[file_path]&.first
end

Instance Method Details

#build_derived_inputs(derived, helpers) ⇒ Object

Derived input rules can be provided in a wide variety of formats, this function handles them all.

  1. A simple string or symbol: ‘a.b.c’. The value at the nodes is simply set to the resolved value

  2. Alternatively, you must split the inputs and the rule.

derived[:inputs]
a. Inputs as strings or symbols, e.g. inputs: ['position', 'velocity'].
 These paths are resolved and made available within the rule by the same name
b. Inputs as a map of key-value pairs, e.g. inputs: { position: 'a.b.c', velocity: 'd.e.f' },
 These are resolved and made available within the rule by the mapped name
  1. derived

The rule can be a simple string of Ruby code OR (this way we can encode entire lazy graphs as pure JSON)
A ruby block.

25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# File 'lib/lazy_graph/node/derived_rules.rb', line 25

def build_derived_inputs(derived, helpers)
  @resolvers = {}.compare_by_identity
  @path_cache = {}.compare_by_identity
  @resolution_stack = {}.compare_by_identity

  derived = interpret_derived_proc(derived) if derived.is_a?(Proc)
  derived = { inputs: derived.to_s } if derived.is_a?(String) || derived.is_a?(Symbol)
  derived[:inputs] = parse_derived_inputs(derived)
  @fixed_result = derived[:fixed_result]
  @copy_input = true if !derived[:calc] && derived[:inputs].size == 1
  extract_derived_src(derived) if @debug

  @inputs_optional = derived[:calc].is_a?(Proc)
  derived[:calc] = parse_rule_string(derived) if derived[:calc].is_a?(String) || derived[:calc].is_a?(Symbol)

  @node_context = create_derived_input_context(derived, helpers)
  @inputs = map_derived_inputs_to_paths(derived[:inputs])
  @conditions = derived[:conditions]
  @derived = true
end

#create_derived_input_context(derived, helpers) ⇒ Object


200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
# File 'lib/lazy_graph/node/derived_rules.rb', line 200

def create_derived_input_context(derived, helpers)
  return if @copy_input

  Struct.new(*(derived[:inputs].keys.map(&:to_sym) + %i[itself stack_ptr])) do
    def missing?(value) = value.is_a?(LazyGraph::MissingValue) || value.nil?
    helpers&.each { |h| include h }

    define_method(:process!, &derived[:calc]) if derived[:calc].is_a?(Proc)
    def method_missing(name, *args, &block)
      stack_ptr.send(name, *args, &block)
    end

    def respond_to_missing?(name, include_private = false)
      stack_ptr.respond_to?(name, include_private)
    end
  end.new
end

#extract_derived_src(derived) ⇒ Object


169
170
171
172
173
174
175
176
177
# File 'lib/lazy_graph/node/derived_rules.rb', line 169

def extract_derived_src(derived)
  return @src ||= derived[:calc].to_s.lines unless derived[:calc].is_a?(Proc)

  @src ||= begin
    extract_expr_from_source_location(derived[:calc].source_location).body.slice.lines.map(&:strip)
  rescue StandardError
    ["Failed to extract source from proc #{derived}"]
  end
end

#interpret_derived_proc(derived) ⇒ Object


46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/lazy_graph/node/derived_rules.rb', line 46

def interpret_derived_proc(derived)
  src, requireds, optionals, keywords, loc = DerivedRules.extract_expr_from_source_location(derived.source_location)
  body = src.body&.slice || ''
  @src = body.lines.map(&:strip)
  offset = src.slice.lines.length - body.lines.length
  inputs, conditions = parse_args_with_conditions(requireds, optionals, keywords)

  {
    inputs: inputs,
    mtime: File.mtime(derived.source_location.first),
    conditions: conditions,
    calc: instance_eval(
      "->(#{inputs.keys.map { |k| "#{k}=self.#{k}" }.join(', ')}){ #{body}}",
      # rubocop:disable:next-line
      derived.source_location.first,
      # rubocop:enable
      derived.source_location.last + offset
    )
  }
end

#map_derived_inputs_to_paths(inputs) ⇒ Object


237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
# File 'lib/lazy_graph/node/derived_rules.rb', line 237

def map_derived_inputs_to_paths(inputs)
  inputs.values.map.with_index do |path, idx|
    segments = path.parts.map.with_index do |segment, i|
      if segment.is_a?(PathParser::PathGroup) &&
         segment.options.length == 1 && !((resolver = resolver_for(segment.options.first)) || segment.options.first.segment.part.to_s =~ /\d+/)
        raise(ValidationError.new(
          "Invalid dependency in #{@path}: #{segment.options.first.to_path_str}  cannot be resolved."
        ).tap { |e| e.set_backtrace(rule_definition_backtrace) })
      end

      resolver ? [i, resolver] : nil
    end.compact
    resolver = resolver_for(path)

    unless resolver
      raise(ValidationError.new(
        "Invalid dependency in #{@path}: #{path.to_path_str}  cannot be resolved."
      ).tap { |e| e.set_backtrace(rule_definition_backtrace) })
    end

    [path, resolver, idx, segments.any? ? segments : nil]
  end
end

#parse_args_with_conditions(requireds, optionals_with_conditions, keywords_with_conditions) ⇒ Object


67
68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/lazy_graph/node/derived_rules.rb', line 67

def parse_args_with_conditions(requireds, optionals_with_conditions, keywords_with_conditions)
  keywords = requireds.map { |r| [r, r] }.to_h
  conditions = {}
  keywords_with_conditions.map do |k, v|
    path, condition = v.split('=')
    keywords[k] = path
    conditions[k] = eval(condition) if condition
  end
  optionals_with_conditions.each do |optional_with_conditions|
    keywords[optional_with_conditions.name] = optional_with_conditions.name
    conditions[optional_with_conditions.name] = eval(optional_with_conditions.value.slice)
  end
  [keywords, conditions.any? ? conditions : nil]
end

#parse_derived_inputs(derived) ⇒ Object


141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/lazy_graph/node/derived_rules.rb', line 141

def parse_derived_inputs(derived)
  inputs = derived[:inputs]
  case inputs
  when Symbol, String
    if !derived[:calc]
      @src ||= inputs
      input_hash = {}
      @input_mapper = {}
      calc = inputs.gsub(PLACEHOLDER_VAR_REGEX) do |match|
        sub = input_hash[match[2...-1]] ||= "a#{::SecureRandom.hex(8)}"
        @input_mapper[sub.to_sym] = match[2...-1].to_sym
        sub
      end
      derived[:calc] = calc unless calc == input_hash.values.first
      input_hash.invert
    else
      { inputs.to_s.gsub(/[^(?:[A-Za-z][A-Za-z0-9_])]/, '__') => inputs.to_s.freeze }
    end
  when Array
    pairs = inputs.last.is_a?(Hash) ? inputs.pop : {}
    inputs.map { |v| { v.to_s.gsub(/[^(?:[A-Za-z][A-Za-z0-9_])]/, '__') => v } }.reduce(pairs, :merge)
  when Hash
    inputs
  else
    {}
  end.transform_values { |v| PathParser.parse(v) }
end

#parse_rule_string(derived) ⇒ Object


179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/lazy_graph/node/derived_rules.rb', line 179

def parse_rule_string(derived)
  calc_str = derived[:calc]
  node_path = path

  src = <<~RUBY, @rule_location&.first, @rule_location&.last.to_i - 2
    ->{
      begin
        #{calc_str}
      rescue StandardError => e;
        LazyGraph.logger.error("Exception in \#{calc_str} => \#{node_path}. \#{e.message}")
        raise e
      end
      }
  RUBY

  instance_eval(*src)
rescue SyntaxError
  missing_value = MissingValue { "Syntax error in #{derived[:src]}" }
  -> { missing_value }
end

#resolver_for(path) ⇒ Object


218
219
220
221
222
223
# File 'lib/lazy_graph/node/derived_rules.rb', line 218

def resolver_for(path)
  segment = path.segment.part
  return root.properties[path.next.segment.part] if segment == :'$'

  (segment == name ? parent.parent : @parent).find_resolver_for(segment)
end

#rule_definition_backtraceObject


225
226
227
228
229
230
231
232
233
234
235
# File 'lib/lazy_graph/node/derived_rules.rb', line 225

def rule_definition_backtrace
  if @rule_location && @rule_location.size >= 2
    rule_file, rule_line = @rule_location
    rule_entry = "#{rule_file}:#{rule_line}:in `rule`"
  else
    rule_entry = 'unknown_rule_location'
  end

  current_backtrace = caller.reverse.take_while { |line| !line.include?('/lib/lazy_graph/') }.reverse
  [rule_entry] + current_backtrace
end