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,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Diagnostics
5
+ class Report
6
+ def initialize(execution)
7
+ @execution = execution
8
+ end
9
+
10
+ def to_h
11
+ safely_resolve_execution
12
+
13
+ {
14
+ graph: execution.compiled_graph.name,
15
+ execution_id: execution.events.execution_id,
16
+ status: status,
17
+ outputs: serialize_outputs,
18
+ errors: serialize_errors,
19
+ nodes: summarize_nodes,
20
+ events: summarize_events
21
+ }
22
+ end
23
+
24
+ def as_json(*)
25
+ to_h
26
+ end
27
+
28
+ def to_text
29
+ report = to_h
30
+ lines = []
31
+ lines << "Diagnostics #{report[:graph]}"
32
+ lines << "Execution #{report[:execution_id]}"
33
+ lines << "Status: #{report[:status]}"
34
+ lines << format_outputs(report[:outputs])
35
+ lines << format_nodes(report[:nodes])
36
+ lines << format_errors(report[:errors])
37
+ lines << format_events(report[:events])
38
+ lines.compact.join("\n")
39
+ end
40
+
41
+ def to_markdown
42
+ report = to_h
43
+ lines = []
44
+ lines << "# Diagnostics #{report[:graph]}"
45
+ lines << ""
46
+ lines << "- Execution: `#{report[:execution_id]}`"
47
+ lines << "- Status: `#{report[:status]}`"
48
+ lines << "- Outputs: #{inline_hash(report[:outputs])}"
49
+ lines << "- Nodes: total=#{report[:nodes][:total]}, succeeded=#{report[:nodes][:succeeded]}, failed=#{report[:nodes][:failed]}, stale=#{report[:nodes][:stale]}"
50
+ lines << "- Events: total=#{report[:events][:total]}, latest=#{report[:events][:latest_type] || 'none'}"
51
+
52
+ unless report[:errors].empty?
53
+ lines << ""
54
+ lines << "## Errors"
55
+ report[:errors].each do |error|
56
+ lines << "- `#{error[:node_name]}`: #{error[:message]}"
57
+ end
58
+ end
59
+
60
+ lines.join("\n")
61
+ end
62
+
63
+ private
64
+
65
+ attr_reader :execution
66
+
67
+ def execution_result
68
+ @execution_result ||= Runtime::Result.new(execution)
69
+ end
70
+
71
+ def safely_resolve_execution
72
+ execution.resolve_all
73
+ rescue Igniter::Error
74
+ nil
75
+ end
76
+
77
+ def status
78
+ return :failed if execution.cache.values.any?(&:failed?)
79
+ return :stale if execution.cache.values.any?(&:stale?)
80
+
81
+ :succeeded
82
+ end
83
+
84
+ def serialize_errors
85
+ execution_result.errors.map do |node_name, error|
86
+ {
87
+ node_name: node_name,
88
+ type: error.class.name,
89
+ message: error.message,
90
+ context: error.respond_to?(:context) ? error.context : {}
91
+ }
92
+ end
93
+ end
94
+
95
+ def serialize_outputs
96
+ execution.compiled_graph.outputs.each_with_object({}) do |output_node, memo|
97
+ state = execution.cache.fetch(output_node.source)
98
+ memo[output_node.name] = serialize_output_state(state)
99
+ end
100
+ end
101
+
102
+ def serialize_output_state(state)
103
+ return nil unless state
104
+ return { error: state.error.message, status: state.status } if state.failed?
105
+
106
+ serialize_value(state.value)
107
+ end
108
+
109
+ def summarize_nodes
110
+ states = execution.states
111
+
112
+ {
113
+ total: states.size,
114
+ succeeded: states.values.count { |state| state[:status] == :succeeded },
115
+ failed: states.values.count { |state| state[:status] == :failed },
116
+ stale: states.values.count { |state| state[:status] == :stale },
117
+ failed_nodes: states.filter_map do |node_name, state|
118
+ next unless state[:status] == :failed
119
+
120
+ { node_name: node_name, path: state[:path], error: state[:error] }
121
+ end
122
+ }
123
+ end
124
+
125
+ def summarize_events
126
+ events = execution.events.events
127
+
128
+ {
129
+ total: events.size,
130
+ latest_type: events.last&.type,
131
+ latest_at: events.last&.timestamp,
132
+ by_type: events.each_with_object(Hash.new(0)) { |event, memo| memo[event.type] += 1 }
133
+ }
134
+ end
135
+
136
+ def format_outputs(outputs)
137
+ "Outputs: #{inline_hash(outputs)}"
138
+ end
139
+
140
+ def format_nodes(nodes)
141
+ line = "Nodes: total=#{nodes[:total]}, succeeded=#{nodes[:succeeded]}, failed=#{nodes[:failed]}, stale=#{nodes[:stale]}"
142
+ return line if nodes[:failed_nodes].empty?
143
+
144
+ failures = nodes[:failed_nodes].map { |node| "#{node[:node_name]}(#{node[:error]})" }.join(", ")
145
+ "#{line}\nFailed Nodes: #{failures}"
146
+ end
147
+
148
+ def format_errors(errors)
149
+ return "Errors: none" if errors.empty?
150
+
151
+ "Errors: #{errors.map { |error| "#{error[:node_name]}=#{error[:type]}" }.join(', ')}"
152
+ end
153
+
154
+ def format_events(events)
155
+ "Events: total=#{events[:total]}, latest=#{events[:latest_type] || 'none'}"
156
+ end
157
+
158
+ def inline_hash(hash)
159
+ hash.map { |key, value| "#{key}=#{value.inspect}" }.join(", ")
160
+ end
161
+
162
+ def serialize_value(value)
163
+ case value
164
+ when Runtime::Result
165
+ value.to_h
166
+ when Array
167
+ value.map { |item| serialize_value(item) }
168
+ else
169
+ value
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "diagnostics/report"
4
+
5
+ module Igniter
6
+ module Diagnostics
7
+ end
8
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module DSL
5
+ class ContractBuilder
6
+ def self.compile(name: "AnonymousContract", &block)
7
+ new(name: name).tap { |builder| builder.instance_eval(&block) }.compile
8
+ end
9
+
10
+ def initialize(name:)
11
+ @name = name
12
+ @nodes = []
13
+ @sequence = 0
14
+ end
15
+
16
+ UNDEFINED_INPUT_DEFAULT = :__igniter_undefined__
17
+
18
+ def input(name, type: nil, required: nil, default: UNDEFINED_INPUT_DEFAULT, **metadata)
19
+ input_metadata = with_source_location(metadata)
20
+ input_metadata[:type] = type if type
21
+ input_metadata[:required] = required unless required.nil?
22
+ input_metadata[:default] = default unless default == UNDEFINED_INPUT_DEFAULT
23
+
24
+ add_node(
25
+ Model::InputNode.new(
26
+ id: next_id,
27
+ name: name,
28
+ metadata: input_metadata
29
+ )
30
+ )
31
+ end
32
+
33
+ def compute(name, depends_on:, call: nil, **metadata, &block)
34
+ callable = call || block
35
+ raise CompileError, "compute :#{name} requires a callable" unless callable
36
+ raise CompileError, "compute :#{name} cannot accept both `call:` and a block" if call && block
37
+
38
+ add_node(
39
+ Model::ComputeNode.new(
40
+ id: next_id,
41
+ name: name,
42
+ dependencies: Array(depends_on),
43
+ callable: callable,
44
+ metadata: with_source_location(metadata)
45
+ )
46
+ )
47
+ end
48
+
49
+ def output(name, from: nil, **metadata)
50
+ add_node(
51
+ Model::OutputNode.new(
52
+ id: next_id,
53
+ name: name,
54
+ source: (from || name),
55
+ metadata: with_source_location(metadata)
56
+ )
57
+ )
58
+ end
59
+
60
+ def compose(name, contract:, inputs:, **metadata)
61
+ raise CompileError, "compose :#{name} requires an `inputs:` hash" unless inputs.is_a?(Hash)
62
+
63
+ add_node(
64
+ Model::CompositionNode.new(
65
+ id: next_id,
66
+ name: name,
67
+ contract_class: contract,
68
+ input_mapping: inputs,
69
+ metadata: with_source_location(metadata)
70
+ )
71
+ )
72
+ end
73
+
74
+ def compile
75
+ Compiler::GraphCompiler.call(Model::Graph.new(name: @name, nodes: @nodes))
76
+ end
77
+
78
+ private
79
+
80
+ def add_node(node)
81
+ @nodes << node
82
+ node
83
+ end
84
+
85
+ def next_id
86
+ @sequence += 1
87
+ "#{@name}:#{@sequence}"
88
+ end
89
+
90
+ def with_source_location(metadata)
91
+ metadata.merge(source_location: caller_locations(2, 1).first&.to_s)
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dsl/contract_builder"
4
+
5
+ module Igniter
6
+ module DSL
7
+ end
8
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ class Error < StandardError
5
+ attr_reader :context
6
+
7
+ def initialize(message = nil, context: {})
8
+ @context = context.compact.freeze
9
+ super(format_message(message, @context))
10
+ end
11
+
12
+ def graph
13
+ context[:graph]
14
+ end
15
+
16
+ def node_id
17
+ context[:node_id]
18
+ end
19
+
20
+ def node_name
21
+ context[:node_name]
22
+ end
23
+
24
+ def node_path
25
+ context[:node_path]
26
+ end
27
+
28
+ def source_location
29
+ context[:source_location]
30
+ end
31
+
32
+ private
33
+
34
+ def format_message(message, context)
35
+ details = []
36
+ details << "graph=#{context[:graph]}" if context[:graph]
37
+ details << "node=#{context[:node_name]}" if context[:node_name]
38
+ details << "path=#{context[:node_path]}" if context[:node_path]
39
+ details << "location=#{context[:source_location]}" if context[:source_location]
40
+
41
+ return message if details.empty?
42
+
43
+ "#{message} [#{details.join(', ')}]"
44
+ end
45
+ end
46
+
47
+ class CompileError < Error; end
48
+ class ValidationError < CompileError; end
49
+ class CycleError < ValidationError; end
50
+ class InputError < Error; end
51
+ class ResolutionError < Error; end
52
+ class CompositionError < Error; end
53
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Igniter
6
+ module Events
7
+ class Bus
8
+ attr_reader :execution_id, :events
9
+
10
+ def initialize(execution_id: SecureRandom.uuid)
11
+ @execution_id = execution_id
12
+ @events = []
13
+ @subscribers = []
14
+ end
15
+
16
+ def emit(type, node: nil, status: nil, payload: {})
17
+ event = Event.new(
18
+ event_id: SecureRandom.uuid,
19
+ type: type,
20
+ execution_id: execution_id,
21
+ node_id: node&.id,
22
+ node_name: node&.name,
23
+ path: node&.path,
24
+ status: status,
25
+ payload: payload,
26
+ timestamp: Time.now.utc
27
+ )
28
+
29
+ @events << event
30
+ @subscribers.each { |subscriber| subscriber.call(event) }
31
+ event
32
+ end
33
+
34
+ def subscribe(subscriber = nil, &block)
35
+ @subscribers << (subscriber || block)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Igniter
6
+ module Events
7
+ Event = Struct.new(
8
+ :event_id,
9
+ :type,
10
+ :execution_id,
11
+ :node_id,
12
+ :node_name,
13
+ :path,
14
+ :status,
15
+ :payload,
16
+ :timestamp,
17
+ keyword_init: true
18
+ ) do
19
+ def to_h
20
+ {
21
+ event_id: event_id,
22
+ type: type,
23
+ execution_id: execution_id,
24
+ node_id: node_id,
25
+ node_name: node_name,
26
+ path: path,
27
+ status: status,
28
+ payload: payload,
29
+ timestamp: timestamp
30
+ }
31
+ end
32
+
33
+ def as_json(*)
34
+ to_h.transform_values { |value| serialize_value(value) }
35
+ end
36
+
37
+ private
38
+
39
+ def serialize_value(value)
40
+ case value
41
+ when Time
42
+ value.iso8601
43
+ when Hash
44
+ value.each_with_object({}) { |(key, nested_value), memo| memo[key] = serialize_value(nested_value) }
45
+ when Array
46
+ value.map { |item| serialize_value(item) }
47
+ else
48
+ value
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "events/event"
4
+ require_relative "events/bus"
5
+
6
+ module Igniter
7
+ module Events
8
+ end
9
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Extensions
5
+ module Auditing
6
+ class Timeline
7
+ attr_reader :execution
8
+
9
+ def initialize(execution)
10
+ @execution = execution
11
+ @events = []
12
+ end
13
+
14
+ def call(event)
15
+ @events << event
16
+ end
17
+
18
+ def events
19
+ @events.dup
20
+ end
21
+
22
+ def snapshot
23
+ {
24
+ execution_id: execution.events.execution_id,
25
+ graph: execution.compiled_graph.name,
26
+ event_count: @events.size,
27
+ events: @events.map { |event| serialize_event(event) },
28
+ states: serialize_states,
29
+ children: child_snapshots
30
+ }
31
+ end
32
+
33
+ private
34
+
35
+ def serialize_event(event)
36
+ {
37
+ event_id: event.event_id,
38
+ type: event.type,
39
+ execution_id: event.execution_id,
40
+ node_id: event.node_id,
41
+ node_name: event.node_name,
42
+ path: event.path,
43
+ status: event.status,
44
+ payload: serialize_payload(event.payload),
45
+ timestamp: event.timestamp
46
+ }
47
+ end
48
+
49
+ def serialize_states
50
+ execution.cache.to_h.each_with_object({}) do |(node_name, state), memo|
51
+ memo[node_name] = {
52
+ path: state.node.path,
53
+ kind: state.node.kind,
54
+ status: state.status,
55
+ version: state.version,
56
+ invalidated_by: state.invalidated_by,
57
+ resolved_at: state.resolved_at,
58
+ value: serialize_value(state.value),
59
+ error: state.error&.message
60
+ }
61
+ end
62
+ end
63
+
64
+ def child_snapshots
65
+ execution.cache.values.filter_map do |state|
66
+ next unless state.value.is_a?(Igniter::Runtime::Result)
67
+
68
+ {
69
+ node_name: state.node.name,
70
+ path: state.node.path,
71
+ snapshot: state.value.execution.audit.snapshot
72
+ }
73
+ end
74
+ end
75
+
76
+ def serialize_payload(payload)
77
+ payload.each_with_object({}) do |(key, value), memo|
78
+ memo[key] = serialize_value(value)
79
+ end
80
+ end
81
+
82
+ def serialize_value(value)
83
+ case value
84
+ when Igniter::Runtime::Result
85
+ {
86
+ type: :result,
87
+ execution_id: value.execution.events.execution_id,
88
+ graph: value.execution.compiled_graph.name
89
+ }
90
+ when Array
91
+ value.map { |item| serialize_value(item) }
92
+ else
93
+ value
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "auditing/timeline"
4
+
5
+ module Igniter
6
+ module Extensions
7
+ module Auditing
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Extensions
5
+ module Introspection
6
+ class GraphFormatter
7
+ def self.to_text(graph)
8
+ new(graph).to_text
9
+ end
10
+
11
+ def self.to_mermaid(graph)
12
+ new(graph).to_mermaid
13
+ end
14
+
15
+ def initialize(graph)
16
+ @graph = graph
17
+ end
18
+
19
+ def to_text
20
+ lines = []
21
+ lines << "Graph #{@graph.name}"
22
+ lines << "Nodes:"
23
+ @graph.nodes.each do |node|
24
+ line = "- #{node.kind} #{node.path}"
25
+ line += " depends_on=#{node.dependencies.join(',')}" if node.dependencies.any?
26
+ if node.kind == :composition
27
+ line += " contract=#{node.contract_class.name || 'AnonymousContract'}"
28
+ end
29
+ lines << line
30
+ end
31
+ lines << "Outputs:"
32
+ @graph.outputs.each do |output|
33
+ lines << "- #{output.path} -> #{output.source}"
34
+ end
35
+ lines.join("\n")
36
+ end
37
+
38
+ def to_mermaid
39
+ lines = []
40
+ lines << "graph TD"
41
+ @graph.nodes.each do |node|
42
+ lines << %( #{node_id(node)}["#{node_label(node)}"])
43
+ end
44
+ @graph.outputs.each do |output|
45
+ lines << %( #{output_id(output)}["output: #{output.name}"])
46
+ lines << %( #{node_id(@graph.fetch_node(output.source))} --> #{output_id(output)})
47
+ end
48
+ @graph.nodes.each do |node|
49
+ node.dependencies.each do |dependency_name|
50
+ dependency_node = @graph.fetch_node(dependency_name)
51
+ lines << %( #{node_id(dependency_node)} --> #{node_id(node)})
52
+ end
53
+ end
54
+ lines.join("\n")
55
+ end
56
+
57
+ private
58
+
59
+ def node_id(node)
60
+ "node_#{node.name}"
61
+ end
62
+
63
+ def output_id(output)
64
+ "output_#{output.name}"
65
+ end
66
+
67
+ def node_label(node)
68
+ "#{node.kind}: #{node.name}"
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end