lazy_graph 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LazyGraph
4
+ module HashUtils
5
+ module_function
6
+
7
+ def deep_dup!(hash)
8
+ case hash
9
+ when Hash
10
+ hash = hash.dup
11
+ hash.each do |key, value|
12
+ hash[key] = deep_dup!(value)
13
+ end
14
+ when Array
15
+ hash.map { |value| deep_dup!(value) }
16
+ end
17
+ hash
18
+ end
19
+
20
+ def deep_merge(hash, other_hash, path = :'')
21
+ hash.merge(other_hash.transform_keys(&:to_sym)) do |key, this_val, other_val|
22
+ if this_val.is_a?(Hash) && other_val.is_a?(Hash) && other_val != this_val
23
+ deep_merge(this_val, other_val, :"#{path}.#{key}")
24
+ elsif this_val.is_a?(Array) && other_val.is_a?(Array) && other_val != this_val
25
+ this_val.concat(other_val).uniq
26
+ else
27
+ if this_val != other_val && !(this_val.is_a?(Proc) && other_val.is_a?(Proc))
28
+ LazyGraph.logger.warn("Warning: Conflicting values at #{path}.#{key}. #{this_val} != #{other_val} ")
29
+ end
30
+ other_val
31
+ end
32
+ end
33
+ end
34
+
35
+ def strip_invalid(obj, parent_list = {}.compare_by_identity)
36
+ return { '^ref': :circular } if (circular_dependency = parent_list[obj])
37
+
38
+ parent_list[obj] = true
39
+ case obj
40
+ when Hash
41
+ obj.each_with_object({}) do |(key, value), obj|
42
+ next if value.is_a?(MissingValue)
43
+
44
+ obj[key] = strip_invalid(value, parent_list)
45
+ end
46
+ when Struct
47
+ obj.members.each_with_object({}) do |key, res|
48
+ next if obj[key].is_a?(MissingValue)
49
+ next if obj.invisible.include?(key)
50
+
51
+ res[key] = strip_invalid(obj[key], parent_list)
52
+ end
53
+ when Array
54
+ obj.map! { |value| strip_invalid(value, parent_list) }
55
+ else
56
+ obj
57
+ end
58
+ ensure
59
+ parent_list.delete(obj) unless circular_dependency
60
+ end
61
+
62
+ def deep_symbolize!(obj)
63
+ case obj
64
+ when Hash
65
+ hash = 0
66
+ obj.to_a.each do |key, value|
67
+ hash ^= deep_symbolize!(value)
68
+ unless key.is_a?(Symbol)
69
+ key.to_s.to_sym
70
+ obj[key.to_s.to_sym] = obj.delete(key)
71
+ end
72
+ hash ^= key.object_id
73
+ end
74
+ obj.compare_by_identity
75
+ hash
76
+ when Array
77
+ hash = 0
78
+ obj.each { |item| hash ^= deep_symbolize!(item) }
79
+ hash
80
+ when String, Numeric, TrueClass, FalseClass, NilClass
81
+ obj.hash
82
+ else
83
+ 0
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,148 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-06/schema#",
3
+ "$id": "http://json-schema.org/draft-06/schema#",
4
+ "title": "Core schema meta-schema",
5
+ "definitions": {
6
+ "schemaArray": {
7
+ "type": "array",
8
+ "minItems": 1,
9
+ "items": { "$ref": "#" }
10
+ },
11
+ "nonNegativeInteger": {
12
+ "type": "integer",
13
+ "minimum": 0
14
+ },
15
+ "nonNegativeIntegerDefault0": {
16
+ "allOf": [
17
+ { "$ref": "#/definitions/nonNegativeInteger" },
18
+ { "default": 0 }
19
+ ]
20
+ },
21
+ "simpleTypes": {
22
+ "enum": [
23
+ "array",
24
+ "boolean",
25
+ "integer",
26
+ "null",
27
+ "number",
28
+ "object",
29
+ "string",
30
+ "decimal",
31
+ "timestamp",
32
+ "date",
33
+ "time"
34
+ ]
35
+ },
36
+ "stringArray": {
37
+ "type": "array",
38
+ "items": { "type": "string" },
39
+ "uniqueItems": true,
40
+ "default": []
41
+ }
42
+ },
43
+ "type": ["object", "boolean"],
44
+ "properties": {
45
+ "$id": {
46
+ "type": "string",
47
+ "format": "uri-reference"
48
+ },
49
+ "$schema": {
50
+ "type": "string",
51
+ "format": "uri"
52
+ },
53
+ "$ref": {
54
+ "type": "string",
55
+ "format": "uri-reference"
56
+ },
57
+ "title": {
58
+ "type": "string"
59
+ },
60
+ "description": {
61
+ "type": "string"
62
+ },
63
+ "default": {},
64
+ "multipleOf": {
65
+ "type": "number",
66
+ "exclusiveMinimum": 0
67
+ },
68
+ "maximum": {
69
+ "type": "number"
70
+ },
71
+ "exclusiveMaximum": {
72
+ "type": "number"
73
+ },
74
+ "minimum": {
75
+ "type": "number"
76
+ },
77
+ "exclusiveMinimum": {
78
+ "type": "number"
79
+ },
80
+ "maxLength": { "$ref": "#/definitions/nonNegativeInteger" },
81
+ "minLength": { "$ref": "#/definitions/nonNegativeIntegerDefault0" },
82
+ "pattern": {
83
+ "type": "string",
84
+ "format": "regex"
85
+ },
86
+ "additionalItems": { "$ref": "#" },
87
+ "items": {
88
+ "anyOf": [{ "$ref": "#" }, { "$ref": "#/definitions/schemaArray" }],
89
+ "default": {}
90
+ },
91
+ "maxItems": { "$ref": "#/definitions/nonNegativeInteger" },
92
+ "minItems": { "$ref": "#/definitions/nonNegativeIntegerDefault0" },
93
+ "uniqueItems": {
94
+ "type": "boolean",
95
+ "default": false
96
+ },
97
+ "contains": { "$ref": "#" },
98
+ "maxProperties": { "$ref": "#/definitions/nonNegativeInteger" },
99
+ "minProperties": { "$ref": "#/definitions/nonNegativeIntegerDefault0" },
100
+ "required": { "$ref": "#/definitions/stringArray" },
101
+ "additionalProperties": { "$ref": "#" },
102
+ "definitions": {
103
+ "type": "object",
104
+ "additionalProperties": { "$ref": "#" },
105
+ "default": {}
106
+ },
107
+ "properties": {
108
+ "type": "object",
109
+ "additionalProperties": { "$ref": "#" },
110
+ "default": {}
111
+ },
112
+ "patternProperties": {
113
+ "type": "object",
114
+ "additionalProperties": { "$ref": "#" },
115
+ "default": {}
116
+ },
117
+ "dependencies": {
118
+ "type": "object",
119
+ "additionalProperties": {
120
+ "anyOf": [{ "$ref": "#" }, { "$ref": "#/definitions/stringArray" }]
121
+ }
122
+ },
123
+ "propertyNames": { "$ref": "#" },
124
+ "const": {},
125
+ "enum": {
126
+ "type": "array",
127
+ "minItems": 1,
128
+ "uniqueItems": true
129
+ },
130
+ "type": {
131
+ "anyOf": [
132
+ { "$ref": "#/definitions/simpleTypes" },
133
+ {
134
+ "type": "array",
135
+ "items": { "$ref": "#/definitions/simpleTypes" },
136
+ "minItems": 1,
137
+ "uniqueItems": true
138
+ }
139
+ ]
140
+ },
141
+ "format": { "type": "string" },
142
+ "allOf": { "$ref": "#/definitions/schemaArray" },
143
+ "anyOf": { "$ref": "#/definitions/schemaArray" },
144
+ "oneOf": { "$ref": "#/definitions/schemaArray" },
145
+ "not": { "$ref": "#" }
146
+ },
147
+ "default": {}
148
+ }
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LazyGraph
4
+ # Represents a value that is missing or undefined, allowing for graceful handling
5
+ # of method calls and JSON serialization even when the value is not present.
6
+ class MissingValue
7
+ attr_reader :details
8
+
9
+ def initialize(details) = @details = details
10
+ def to_s = "MISSING[#{@details}]"
11
+
12
+ def inspect = to_s
13
+ def coerce(other) = [self, other]
14
+ def as_json = nil
15
+ def respond_to_missing?(_method_name, _include_private = false) = true
16
+
17
+ def method_missing(method, *args, &block)
18
+ return super if method == :to_ary
19
+ return self if self == BLANK
20
+
21
+ MissingValue.new(:"#{details}##{method}#{args.any? ? :"(#{args.inspect[1...-1]})" : :''}")
22
+ end
23
+
24
+ BLANK = new('')
25
+ end
26
+ end
@@ -0,0 +1,67 @@
1
+ module LazyGraph
2
+ class ArrayNode < Node
3
+ # An Array supports the following types of path resolutions.
4
+ # 1. Forward property (assuming child items are objects): arr.property
5
+ # 2. Absolute Index: arr[0], arr[1], arr[2], ...
6
+ # 3. Range: arr[0..2], arr[1...3]
7
+ # 4. All:arr [*]
8
+ # 5. Set of indexes: arr[0, 2, 4]
9
+ #
10
+ # Parts between square brackets are represented as path groups.
11
+ def resolve(
12
+ path,
13
+ stack_memory,
14
+ should_recycle = stack_memory,
15
+ **
16
+ )
17
+ input = stack_memory.frame
18
+ @visited[input.object_id ^ (path.object_id << 8)] ||= begin
19
+ if (path_segment = path.segment).is_a?(PathParser::PathGroup)
20
+ unless path_segment.options.all?(&:index?)
21
+ return input.length.times.map do |index|
22
+ item = children.fetch_item(input, index, stack_memory)
23
+ children.resolve(path, stack_memory.push(item, index))
24
+ end
25
+ end
26
+
27
+ return resolve(path_segment.options.first.merge(path.next), stack_memory, nil) if path_segment.options.one?
28
+
29
+ return path_segment.options.map { |part| resolve(part.merge(path.next), stack_memory, nil) }
30
+ end
31
+
32
+ segment = path_segment&.part
33
+ case segment
34
+ when nil
35
+ input.length.times do |index|
36
+ item = children.fetch_item(input, index, stack_memory)
37
+ children.resolve(path, stack_memory.push(item, index))
38
+ end
39
+ input
40
+ when DIGIT_REGEXP
41
+ item = @children.fetch_item(input, segment.to_s.to_i, stack_memory)
42
+ children.resolve(path.next, stack_memory.push(item, segment))
43
+ when :*
44
+ input.length.times.map do |index|
45
+ item = children.fetch_item(input, index, stack_memory)
46
+ @children.resolve(path.next, stack_memory.push(item, index))
47
+ end
48
+ else
49
+ if @children.is_object && @children.children[:properties].keys.include?(segment) || input&.first&.key?(segment)
50
+ input.length.times.map do |index|
51
+ item = children.fetch_item(input, index, stack_memory)
52
+ @children.resolve(path, stack_memory.push(item, index))
53
+ end
54
+ else
55
+ MissingValue()
56
+ end
57
+ end
58
+ end
59
+ ensure
60
+ should_recycle&.recycle!
61
+ end
62
+
63
+ def cast(value)
64
+ value.dup
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+ require 'securerandom'
5
+
6
+ module LazyGraph
7
+ class Node
8
+ module DerivedRules
9
+ PLACEHOLDER_VAR_REGEX = /\$\{[^}]+\}/
10
+ # Derived input rules can be provided in a wide variety of formats,
11
+ # this function handles them all.
12
+ #
13
+ # 1. A simple string or symbol: 'a.b.c'. The value at the nodes is simply set to the resolved value
14
+ #
15
+ # 2. Alternatively, you must split the inputs and the rule.
16
+ # derived[:inputs]
17
+ # a. Inputs as strings or symbols, e.g. inputs: ['position', 'velocity'].
18
+ # These paths are resolved and made available within the rule by the same name
19
+ # b. Inputs as a map of key-value pairs, e.g. inputs: { position: 'a.b.c', velocity: 'd.e.f' },
20
+ # These are resolved and made available within the rule by the mapped name
21
+ #
22
+ # 3. derived[:calc]
23
+ # The rule can be a simple string of Ruby code OR (this way we can encode entire lazy graphs as pure JSON)
24
+ # A ruby block.
25
+ def build_derived_inputs(derived, helpers)
26
+ @resolvers = {}.compare_by_identity
27
+ @path_cache = {}.compare_by_identity
28
+
29
+ derived = interpret_derived_proc(derived) if derived.is_a?(Proc)
30
+ derived = { inputs: derived.to_s } if derived.is_a?(String) || derived.is_a?(Symbol)
31
+ derived[:inputs] = parse_derived_inputs(derived)
32
+ @copy_input = true if !derived[:calc] && derived[:inputs].size == 1
33
+ extract_derived_src(derived) if @debug
34
+
35
+ @inputs_optional = derived[:calc].is_a?(Proc)
36
+ derived[:calc] = parse_rule_string(derived) if derived[:calc].is_a?(String) || derived[:calc].is_a?(Symbol)
37
+
38
+ @node_context = create_derived_input_context(derived, helpers)
39
+ @inputs = map_derived_inputs_to_paths(derived[:inputs])
40
+ @conditions = derived[:conditions]
41
+ @derived = true
42
+ end
43
+
44
+ def interpret_derived_proc(derived)
45
+ src, requireds, optionals, keywords, = DerivedRules.extract_expr_from_source_location(derived.source_location)
46
+ src = src.body&.slice || ''
47
+ @src = src.lines.map(&:strip)
48
+ inputs, conditions = parse_args_with_conditions(requireds, optionals, keywords)
49
+
50
+ {
51
+ inputs: inputs,
52
+ mtime: File.mtime(derived.source_location.first),
53
+ conditions: conditions,
54
+ calc: instance_eval(
55
+ "->(#{inputs.keys.map { |k| "#{k}=self.#{k}" }.join(', ')}){ #{src}}",
56
+ # rubocop:disable:next-line
57
+ derived.source_location.first,
58
+ # rubocop:enable
59
+ derived.source_location.last.succ
60
+ )
61
+ }
62
+ end
63
+
64
+ def parse_args_with_conditions(requireds, optionals_with_conditions, keywords_with_conditions)
65
+ keywords = requireds.map { |r| [r, r] }.to_h
66
+ conditions = {}
67
+ keywords_with_conditions.map do |k, v|
68
+ path, condition = v.split('=')
69
+ keywords[k] = path
70
+ conditions[k] = eval(condition) if condition
71
+ end
72
+ optionals_with_conditions.each do |optional_with_conditions|
73
+ keywords[optional_with_conditions.name] = optional_with_conditions.name
74
+ conditions[optional_with_conditions.name] = eval(optional_with_conditions.value.slice)
75
+ end
76
+ [keywords, conditions.any? ? conditions : nil]
77
+ end
78
+
79
+ def self.extract_expr_from_source_location(source_location)
80
+ @derived_proc_cache ||= {}
81
+ mtime = File.mtime(source_location.first).to_i
82
+
83
+ if @derived_proc_cache[source_location]&.last.to_i.< mtime
84
+ @derived_proc_cache[source_location] = begin
85
+ source_lines = IO.readlines(source_location.first)
86
+ proc_line = source_location.last - 1
87
+ first_line = source_lines[proc_line]
88
+ until first_line =~ /(?:lambda|proc|->)/ || proc_line.zero?
89
+ proc_line -= 1
90
+ first_line = source_lines[proc_line]
91
+ end
92
+ lines = source_lines[proc_line..]
93
+ lines[0] = lines[0][/(?:lambda|proc|->).*/]
94
+ src_str = ''.dup
95
+ intermediate = nil
96
+ lines.each do |line|
97
+ token_count = 0
98
+ line.split(/(?=\s|;|\)|\})/).each do |token|
99
+ src_str << token
100
+ token_count += 1
101
+ intermediate = Prism.parse(src_str)
102
+ next unless intermediate.success? && token_count > 1
103
+
104
+ break
105
+ end
106
+ break if intermediate.success?
107
+ end
108
+
109
+ raise 'Source Extraction Failed' unless intermediate.success?
110
+
111
+ src = intermediate.value.statements.body.first.yield_self do |s|
112
+ s.type == :call_node ? s.block : s
113
+ end
114
+ requireds = (src.parameters&.parameters&.requireds || []).map(&:name)
115
+ optionals = src.parameters&.parameters&.optionals || []
116
+ keywords = (src.parameters&.parameters&.keywords || []).map do |kw|
117
+ [kw.name, kw.value.slice.gsub(/^_\./, '$.')]
118
+ end.to_h
119
+ [src, requireds, optionals, keywords, mtime]
120
+ end
121
+ end
122
+
123
+ @derived_proc_cache[source_location]
124
+ rescue StandardError => e
125
+ LazyGraph.logger.error(e.message)
126
+ LazyGraph.logger.error(e.backtrace)
127
+ 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"
128
+ end
129
+
130
+ def parse_derived_inputs(derived)
131
+ inputs = derived[:inputs]
132
+ case inputs
133
+ when Symbol, String
134
+ if inputs =~ PLACEHOLDER_VAR_REGEX && !derived[:calc]
135
+ input_hash = {}
136
+ derived[:calc] = inputs.gsub(PLACEHOLDER_VAR_REGEX) do |match|
137
+ input_hash[match[2...-1]] ||= "a#{::SecureRandom.hex(8)}"
138
+ end
139
+ input_hash.invert
140
+ else
141
+ { inputs.to_str.gsub(/[^(?:[A-Za-z][A-Za-z0-9_])]/, '__') => inputs.to_str.freeze }
142
+ end
143
+ when Array
144
+ inputs.map { |v| { v.to_s.gsub(/[^(?:[A-Za-z][A-Za-z0-9_])]/, '__') => v } }.reduce({}, :merge)
145
+ when Hash
146
+ inputs
147
+ else
148
+ {}
149
+ end.transform_values { |v| PathParser.parse(v) }
150
+ end
151
+
152
+ def extract_derived_src(derived)
153
+ return @src = derived[:calc].to_s.lines unless derived[:calc].is_a?(Proc)
154
+
155
+ @src ||= begin
156
+ extract_expr_from_source_location(derived[:calc].source_location).body.slice.lines.map(&:strip)
157
+ rescue StandardError
158
+ ["Failed to extract source from proc #{derived}"]
159
+ end
160
+ end
161
+
162
+ def parse_rule_string(derived)
163
+ instance_eval("->{ #{derived[:calc]} }", __FILE__, __LINE__)
164
+ rescue SyntaxError
165
+ missing_value = MissingValue { "Syntax error in #{derived[:src]}" }
166
+ -> { missing_value }
167
+ end
168
+
169
+ def create_derived_input_context(derived, helpers)
170
+ return if @copy_input
171
+
172
+ Struct.new(*(derived[:inputs].keys.map(&:to_sym) + %i[itself stack_ptr])) do
173
+ def missing?(value) = value.is_a?(LazyGraph::MissingValue) || value.nil?
174
+ helpers&.each { |h| include h }
175
+ define_method(:process!, &derived[:calc])
176
+ def method_missing(name, *args, &block)
177
+ stack_ptr.send(name, *args, &block)
178
+ end
179
+
180
+ def respond_to_missing?(name, include_private = false)
181
+ stack_ptr.respond_to?(name, include_private)
182
+ end
183
+ end.new
184
+ end
185
+
186
+ def map_derived_inputs_to_paths(inputs)
187
+ inputs.values.map.with_index do |path, idx|
188
+ segment_indexes = path.parts.map.with_index do |segment, i|
189
+ segment.is_a?(PathParser::PathGroup) && segment.options.length == 1 ? i : nil
190
+ end.compact
191
+ [path, idx, segment_indexes.any? ? segment_indexes : nil]
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,64 @@
1
+ module LazyGraph
2
+ module NodeProperties
3
+ # Builds an Anonymous Struct with the given members
4
+ # Invisible members are ignored when the struct is serialized
5
+ def self.build(members:, invisible:)
6
+ Struct.new(*members, keyword_init: true) do
7
+ define_method(:initialize) do |kws|
8
+ members.each { |k| self[k] = kws[k] || MissingValue::BLANK }
9
+ end
10
+
11
+ define_method(:key?) do |x|
12
+ !self[x].equal?(MissingValue::BLANK)
13
+ rescue StandardError
14
+ nil
15
+ end
16
+
17
+ define_method(:[]=) do |key, val|
18
+ super(key, val)
19
+ end
20
+
21
+ define_method(:members) do
22
+ members
23
+ end
24
+
25
+ define_method(:invisible) do
26
+ invisible
27
+ end
28
+
29
+ define_method(:each_key, &members.method(:each))
30
+
31
+ def dup
32
+ self.class.new(members.map do |k|
33
+ [k, self[k].dup]
34
+ end.to_h)
35
+ end
36
+
37
+ def get_first_of(*props)
38
+ key = props.find do |prop|
39
+ !self[prop].is_a?(MissingValue)
40
+ end
41
+ key ? self[key] : MissingValue::BLANK
42
+ end
43
+
44
+ def pretty_print(q)
45
+ # Start the custom pretty print
46
+ q.group(1, '<Props ', '>') do
47
+ q.seplist(members.zip(values).reject do |m, v|
48
+ m == :DEBUG && (v.nil? || v.is_a?(MissingValue))
49
+ end) do |member, value|
50
+ q.group do
51
+ q.text "#{member}="
52
+ if value.respond_to?(:pretty_print)
53
+ q.pp(value) # Delegate to the nested value's pretty_print
54
+ else
55
+ q.text value.inspect
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end