lazy_graph 0.1.0

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,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