igniter 0.2.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.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +20 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +264 -0
  5. data/docs/API_V2.md +242 -0
  6. data/docs/ARCHITECTURE_V2.md +317 -0
  7. data/docs/EXECUTION_MODEL_V2.md +245 -0
  8. data/docs/IGNITER_CONCEPTS.md +81 -0
  9. data/examples/README.md +77 -0
  10. data/examples/basic_pricing.rb +27 -0
  11. data/examples/composition.rb +39 -0
  12. data/examples/diagnostics.rb +28 -0
  13. data/lib/igniter/compiler/compiled_graph.rb +78 -0
  14. data/lib/igniter/compiler/graph_compiler.rb +60 -0
  15. data/lib/igniter/compiler/validator.rb +205 -0
  16. data/lib/igniter/compiler.rb +10 -0
  17. data/lib/igniter/contract.rb +117 -0
  18. data/lib/igniter/diagnostics/report.rb +174 -0
  19. data/lib/igniter/diagnostics.rb +8 -0
  20. data/lib/igniter/dsl/contract_builder.rb +95 -0
  21. data/lib/igniter/dsl.rb +8 -0
  22. data/lib/igniter/errors.rb +53 -0
  23. data/lib/igniter/events/bus.rb +39 -0
  24. data/lib/igniter/events/event.rb +53 -0
  25. data/lib/igniter/events.rb +9 -0
  26. data/lib/igniter/extensions/auditing/timeline.rb +99 -0
  27. data/lib/igniter/extensions/auditing.rb +10 -0
  28. data/lib/igniter/extensions/introspection/graph_formatter.rb +73 -0
  29. data/lib/igniter/extensions/introspection/runtime_formatter.rb +102 -0
  30. data/lib/igniter/extensions/introspection.rb +11 -0
  31. data/lib/igniter/extensions/reactive/engine.rb +36 -0
  32. data/lib/igniter/extensions/reactive/matcher.rb +21 -0
  33. data/lib/igniter/extensions/reactive/reaction.rb +17 -0
  34. data/lib/igniter/extensions/reactive.rb +12 -0
  35. data/lib/igniter/extensions.rb +10 -0
  36. data/lib/igniter/model/composition_node.rb +22 -0
  37. data/lib/igniter/model/compute_node.rb +21 -0
  38. data/lib/igniter/model/graph.rb +15 -0
  39. data/lib/igniter/model/input_node.rb +27 -0
  40. data/lib/igniter/model/node.rb +22 -0
  41. data/lib/igniter/model/output_node.rb +21 -0
  42. data/lib/igniter/model.rb +13 -0
  43. data/lib/igniter/runtime/cache.rb +58 -0
  44. data/lib/igniter/runtime/execution.rb +142 -0
  45. data/lib/igniter/runtime/input_validator.rb +145 -0
  46. data/lib/igniter/runtime/invalidator.rb +52 -0
  47. data/lib/igniter/runtime/node_state.rb +31 -0
  48. data/lib/igniter/runtime/resolver.rb +114 -0
  49. data/lib/igniter/runtime/result.rb +105 -0
  50. data/lib/igniter/runtime.rb +14 -0
  51. data/lib/igniter/version.rb +5 -0
  52. data/lib/igniter.rb +20 -0
  53. data/sig/igniter.rbs +4 -0
  54. metadata +126 -0
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
4
+ require "igniter"
5
+
6
+ class PriceContract < Igniter::Contract
7
+ define do
8
+ input :order_total, type: :numeric
9
+ input :country, type: :string
10
+
11
+ compute :vat_rate, depends_on: [:country] do |country:|
12
+ country == "UA" ? 0.2 : 0.0
13
+ end
14
+
15
+ compute :gross_total, depends_on: %i[order_total vat_rate] do |order_total:, vat_rate:|
16
+ order_total * (1 + vat_rate)
17
+ end
18
+
19
+ output :gross_total
20
+ end
21
+ end
22
+
23
+ contract = PriceContract.new(order_total: 100, country: "UA")
24
+
25
+ puts "gross_total=#{contract.result.gross_total}"
26
+ contract.update_inputs(order_total: 150)
27
+ puts "updated_gross_total=#{contract.result.gross_total}"
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
4
+ require "igniter"
5
+
6
+ class PriceContract < Igniter::Contract
7
+ define do
8
+ input :order_total, type: :numeric
9
+ input :country, type: :string
10
+
11
+ compute :vat_rate, depends_on: [:country] do |country:|
12
+ country == "UA" ? 0.2 : 0.0
13
+ end
14
+
15
+ compute :gross_total, depends_on: %i[order_total vat_rate] do |order_total:, vat_rate:|
16
+ order_total * (1 + vat_rate)
17
+ end
18
+
19
+ output :gross_total
20
+ end
21
+ end
22
+
23
+ class CheckoutContract < Igniter::Contract
24
+ define do
25
+ input :order_total, type: :numeric
26
+ input :country, type: :string
27
+
28
+ compose :pricing, contract: PriceContract, inputs: {
29
+ order_total: :order_total,
30
+ country: :country
31
+ }
32
+
33
+ output :pricing
34
+ end
35
+ end
36
+
37
+ contract = CheckoutContract.new(order_total: 100, country: "UA")
38
+
39
+ puts "pricing=#{contract.result.to_h.inspect}"
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
4
+ require "igniter"
5
+
6
+ class PriceContract < Igniter::Contract
7
+ define do
8
+ input :order_total, type: :numeric
9
+ input :country, type: :string
10
+
11
+ compute :vat_rate, depends_on: [:country] do |country:|
12
+ country == "UA" ? 0.2 : 0.0
13
+ end
14
+
15
+ compute :gross_total, depends_on: %i[order_total vat_rate] do |order_total:, vat_rate:|
16
+ order_total * (1 + vat_rate)
17
+ end
18
+
19
+ output :gross_total
20
+ end
21
+ end
22
+
23
+ contract = PriceContract.new(order_total: 100, country: "UA")
24
+ contract.result.gross_total
25
+
26
+ puts contract.diagnostics_text
27
+ puts "---"
28
+ puts contract.result.as_json.inspect
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Compiler
5
+ class CompiledGraph
6
+ attr_reader :name, :nodes, :nodes_by_id, :nodes_by_name, :nodes_by_path, :outputs, :outputs_by_name, :resolution_order, :dependents
7
+
8
+ def initialize(name:, nodes:, outputs:, resolution_order:, dependents:)
9
+ @name = name
10
+ @nodes = nodes.freeze
11
+ @nodes_by_id = nodes.each_with_object({}) { |node, memo| memo[node.id] = node }.freeze
12
+ @nodes_by_name = nodes.each_with_object({}) { |node, memo| memo[node.name] = node }.freeze
13
+ @nodes_by_path = nodes.each_with_object({}) { |node, memo| memo[node.path] = node }.freeze
14
+ @outputs = outputs.freeze
15
+ @outputs_by_name = outputs.each_with_object({}) { |node, memo| memo[node.name] = node }.freeze
16
+ @resolution_order = resolution_order.freeze
17
+ @dependents = dependents.transform_values(&:freeze).freeze
18
+ freeze
19
+ end
20
+
21
+ def fetch_node_by_id(id)
22
+ @nodes_by_id.fetch(id)
23
+ end
24
+
25
+ def fetch_node(name)
26
+ @nodes_by_name.fetch(name.to_sym)
27
+ end
28
+
29
+ def node?(name)
30
+ @nodes_by_name.key?(name.to_sym)
31
+ end
32
+
33
+ def fetch_node_by_path(path)
34
+ @nodes_by_path.fetch(path.to_s)
35
+ end
36
+
37
+ def fetch_output(name)
38
+ @outputs_by_name.fetch(name.to_sym)
39
+ end
40
+
41
+ def to_h
42
+ {
43
+ name: name,
44
+ nodes: nodes.map do |node|
45
+ base = {
46
+ id: node.id,
47
+ kind: node.kind,
48
+ name: node.name,
49
+ path: node.path,
50
+ dependencies: node.dependencies
51
+ }
52
+ if node.kind == :composition
53
+ base[:contract] = node.contract_class.name
54
+ base[:inputs] = node.input_mapping
55
+ end
56
+ base
57
+ end,
58
+ outputs: outputs.map do |output|
59
+ {
60
+ name: output.name,
61
+ path: output.path,
62
+ source: output.source
63
+ }
64
+ end,
65
+ resolution_order: resolution_order.map(&:name)
66
+ }
67
+ end
68
+
69
+ def to_text
70
+ Extensions::Introspection::GraphFormatter.to_text(self)
71
+ end
72
+
73
+ def to_mermaid
74
+ Extensions::Introspection::GraphFormatter.to_mermaid(self)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tsort"
4
+
5
+ module Igniter
6
+ module Compiler
7
+ class GraphCompiler
8
+ include TSort
9
+
10
+ def self.call(graph)
11
+ new(graph).call
12
+ end
13
+
14
+ def initialize(graph)
15
+ @graph = graph
16
+ end
17
+
18
+ def call
19
+ validator = Validator.call(@graph)
20
+ @nodes_by_name = validator.runtime_nodes_by_name
21
+
22
+ CompiledGraph.new(
23
+ name: @graph.name,
24
+ nodes: validator.runtime_nodes,
25
+ outputs: validator.outputs,
26
+ resolution_order: tsort,
27
+ dependents: build_dependents
28
+ )
29
+ end
30
+
31
+ private
32
+
33
+ def runtime_nodes
34
+ @runtime_nodes ||= @graph.nodes.reject { |node| node.kind == :output }
35
+ end
36
+
37
+ def build_dependents
38
+ runtime_nodes.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |node, memo|
39
+ node.dependencies.each { |dependency_name| memo[dependency_name] << node.name }
40
+ end
41
+ end
42
+
43
+ def tsort_each_node(&block)
44
+ runtime_nodes.each(&block)
45
+ end
46
+
47
+ def tsort_each_child(node, &block)
48
+ node.dependencies.each do |dependency_name|
49
+ block.call(@nodes_by_name.fetch(dependency_name))
50
+ end
51
+ end
52
+
53
+ def tsort
54
+ super
55
+ rescue TSort::Cyclic => e
56
+ raise CycleError, e.message
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Compiler
5
+ class Validator
6
+ def self.call(graph)
7
+ new(graph).call
8
+ end
9
+
10
+ def initialize(graph)
11
+ @graph = graph
12
+ @runtime_nodes_by_name = {}
13
+ end
14
+
15
+ def call
16
+ validate_unique_ids!
17
+ index_runtime_nodes!
18
+ validate_outputs!
19
+ validate_unique_paths!
20
+ validate_dependencies!
21
+ validate_callable_signatures!
22
+
23
+ self
24
+ end
25
+
26
+ def runtime_nodes
27
+ @runtime_nodes ||= @graph.nodes.reject { |node| node.kind == :output }
28
+ end
29
+
30
+ def outputs
31
+ @outputs ||= @graph.nodes.select { |node| node.kind == :output }
32
+ end
33
+
34
+ def runtime_nodes_by_name
35
+ @runtime_nodes_by_name
36
+ end
37
+
38
+ private
39
+
40
+ def validate_unique_ids!
41
+ seen = {}
42
+
43
+ @graph.nodes.each do |node|
44
+ if seen.key?(node.id)
45
+ raise validation_error(node, "Duplicate node id: #{node.id}")
46
+ end
47
+
48
+ seen[node.id] = true
49
+ end
50
+ end
51
+
52
+ def validate_unique_paths!
53
+ seen = {}
54
+
55
+ runtime_nodes.each do |node|
56
+ if seen.key?(node.path)
57
+ raise validation_error(node, "Duplicate node path: #{node.path}")
58
+ end
59
+
60
+ seen[node.path] = true
61
+ end
62
+
63
+ outputs.each do |output|
64
+ if seen.key?(output.path)
65
+ raise validation_error(output, "Duplicate node path: #{output.path}")
66
+ end
67
+
68
+ seen[output.path] = true
69
+ end
70
+ end
71
+
72
+ def index_runtime_nodes!
73
+ runtime_nodes.each do |node|
74
+ raise validation_error(node, "Duplicate node name: #{node.name}") if @runtime_nodes_by_name.key?(node.name)
75
+
76
+ @runtime_nodes_by_name[node.name] = node
77
+ end
78
+ end
79
+
80
+ def validate_outputs!
81
+ raise ValidationError, "Graph must define at least one output" if outputs.empty?
82
+
83
+ seen = {}
84
+ outputs.each do |output|
85
+ raise validation_error(output, "Duplicate output name: #{output.name}") if seen.key?(output.name)
86
+ raise validation_error(output, "Unknown output source '#{output.source}' for output '#{output.name}'") unless @runtime_nodes_by_name.key?(output.source)
87
+
88
+ seen[output.name] = true
89
+ end
90
+ end
91
+
92
+ def validate_dependencies!
93
+ runtime_nodes.each do |node|
94
+ validate_composition_node!(node) if node.kind == :composition
95
+
96
+ node.dependencies.each do |dependency_name|
97
+ next if @runtime_nodes_by_name.key?(dependency_name)
98
+
99
+ raise validation_error(node, "Unknown dependency '#{dependency_name}' for node '#{node.name}'")
100
+ end
101
+ end
102
+ end
103
+
104
+ def validate_composition_node!(node)
105
+ contract_class = node.contract_class
106
+
107
+ unless contract_class.is_a?(Class) && contract_class <= Igniter::Contract
108
+ raise validation_error(node, "Composition '#{node.name}' must reference an Igniter::Contract subclass")
109
+ end
110
+
111
+ unless contract_class.compiled_graph
112
+ raise validation_error(node, "Composition '#{node.name}' references an uncompiled contract")
113
+ end
114
+
115
+ validate_composition_input_mapping!(node, contract_class.compiled_graph)
116
+ end
117
+
118
+ def validate_composition_input_mapping!(node, child_graph)
119
+ child_input_nodes = child_graph.nodes.select { |child_node| child_node.kind == :input }
120
+ child_input_names = child_input_nodes.map(&:name)
121
+
122
+ unknown_inputs = node.input_mapping.keys - child_input_names
123
+ unless unknown_inputs.empty?
124
+ raise validation_error(
125
+ node,
126
+ "Composition '#{node.name}' maps unknown child inputs: #{unknown_inputs.sort.join(', ')}"
127
+ )
128
+ end
129
+
130
+ missing_required_inputs = child_input_nodes
131
+ .select(&:required?)
132
+ .reject { |child_input| node.input_mapping.key?(child_input.name) }
133
+ .map(&:name)
134
+
135
+ return if missing_required_inputs.empty?
136
+
137
+ raise validation_error(
138
+ node,
139
+ "Composition '#{node.name}' is missing mappings for required child inputs: #{missing_required_inputs.sort.join(', ')}"
140
+ )
141
+ end
142
+
143
+ def validate_callable_signatures!
144
+ runtime_nodes.each do |node|
145
+ next unless node.kind == :compute
146
+ next unless node.callable.is_a?(Proc)
147
+
148
+ validate_proc_signature!(node)
149
+ end
150
+ end
151
+
152
+ def validate_proc_signature!(node)
153
+ parameters = node.callable.parameters
154
+ positional_kinds = %i[req opt rest]
155
+ positional = parameters.select { |kind, _name| positional_kinds.include?(kind) }
156
+
157
+ unless positional.empty?
158
+ raise validation_error(
159
+ node,
160
+ "Compute '#{node.name}' proc must use keyword arguments for dependencies, got positional parameters"
161
+ )
162
+ end
163
+
164
+ accepts_any_keywords = parameters.any? { |kind, _name| kind == :keyrest }
165
+ accepted_keywords = parameters
166
+ .select { |kind, _name| %i[key keyreq].include?(kind) }
167
+ .map(&:last)
168
+ required_keywords = parameters
169
+ .select { |kind, _name| kind == :keyreq }
170
+ .map(&:last)
171
+
172
+ missing_dependencies = required_keywords - node.dependencies
173
+ unless missing_dependencies.empty?
174
+ raise validation_error(
175
+ node,
176
+ "Compute '#{node.name}' requires undeclared dependencies: #{missing_dependencies.sort.join(', ')}"
177
+ )
178
+ end
179
+
180
+ return if accepts_any_keywords
181
+
182
+ unknown_dependencies = node.dependencies - accepted_keywords
183
+ return if unknown_dependencies.empty?
184
+
185
+ raise validation_error(
186
+ node,
187
+ "Compute '#{node.name}' declares unsupported dependencies for its proc: #{unknown_dependencies.sort.join(', ')}"
188
+ )
189
+ end
190
+
191
+ def validation_error(node, message)
192
+ ValidationError.new(
193
+ message,
194
+ context: {
195
+ graph: @graph.name,
196
+ node_id: node.id,
197
+ node_name: node.name,
198
+ node_path: node.path,
199
+ source_location: node.source_location
200
+ }
201
+ )
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "compiler/compiled_graph"
4
+ require_relative "compiler/validator"
5
+ require_relative "compiler/graph_compiler"
6
+
7
+ module Igniter
8
+ module Compiler
9
+ end
10
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ class Contract
5
+ class << self
6
+ def define(&block)
7
+ @compiled_graph = DSL::ContractBuilder.compile(name: contract_name, &block)
8
+ end
9
+
10
+ def react_to(event_type, path: nil, &block)
11
+ raise CompileError, "react_to requires a block" unless block
12
+
13
+ reactions << Extensions::Reactive::Reaction.new(
14
+ event_type: event_type,
15
+ path: path,
16
+ action: block
17
+ )
18
+ end
19
+
20
+ def compiled_graph
21
+ @compiled_graph || superclass_compiled_graph
22
+ end
23
+ alias graph compiled_graph
24
+
25
+ def reactions
26
+ @reactions ||= []
27
+ end
28
+
29
+ private
30
+
31
+ def contract_name
32
+ name || "AnonymousContract"
33
+ end
34
+
35
+ def superclass_compiled_graph
36
+ return unless superclass.respond_to?(:compiled_graph)
37
+
38
+ superclass.compiled_graph
39
+ end
40
+ end
41
+
42
+ attr_reader :execution, :result
43
+
44
+ def initialize(inputs = {})
45
+ graph = self.class.compiled_graph
46
+ raise CompileError, "#{self.class.name} has no compiled graph. Use `define`." unless graph
47
+
48
+ @execution = Runtime::Execution.new(
49
+ compiled_graph: graph,
50
+ contract_instance: self,
51
+ inputs: inputs
52
+ )
53
+ @reactive = Extensions::Reactive::Engine.new(
54
+ execution: @execution,
55
+ contract: self,
56
+ reactions: self.class.reactions
57
+ )
58
+ @execution.events.subscribe(@reactive)
59
+ @result = Runtime::Result.new(@execution)
60
+ end
61
+
62
+ def resolve
63
+ execution.resolve_all
64
+ self
65
+ end
66
+
67
+ def resolve_all
68
+ resolve
69
+ end
70
+
71
+ def update_inputs(inputs)
72
+ execution.update_inputs(inputs)
73
+ self
74
+ end
75
+
76
+ def events
77
+ execution.events.events
78
+ end
79
+
80
+ def audit
81
+ execution.audit
82
+ end
83
+
84
+ def audit_snapshot
85
+ execution.audit.snapshot
86
+ end
87
+
88
+ def reactive
89
+ @reactive
90
+ end
91
+
92
+ def subscribe(subscriber = nil, &block)
93
+ execution.events.subscribe(subscriber, &block)
94
+ self
95
+ end
96
+
97
+ def diagnostics
98
+ Diagnostics::Report.new(execution)
99
+ end
100
+
101
+ def diagnostics_text
102
+ diagnostics.to_text
103
+ end
104
+
105
+ def diagnostics_markdown
106
+ diagnostics.to_markdown
107
+ end
108
+
109
+ def success?
110
+ execution.success?
111
+ end
112
+
113
+ def failed?
114
+ execution.failed?
115
+ end
116
+ end
117
+ end