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,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Runtime
5
+ class InputValidator
6
+ SUPPORTED_TYPES = {
7
+ integer: Integer,
8
+ float: Float,
9
+ numeric: Numeric,
10
+ string: String,
11
+ boolean: :boolean,
12
+ array: Array,
13
+ hash: Hash,
14
+ symbol: Symbol
15
+ }.freeze
16
+
17
+ def initialize(compiled_graph)
18
+ @compiled_graph = compiled_graph
19
+ end
20
+
21
+ def normalize_initial_inputs(raw_inputs)
22
+ inputs = symbolize_keys(raw_inputs)
23
+
24
+ validate_unknown_inputs!(inputs)
25
+ apply_defaults(inputs)
26
+ validate_known_inputs!(inputs)
27
+
28
+ inputs
29
+ end
30
+
31
+ def validate_update!(name, value)
32
+ input_node = fetch_input_node(name)
33
+ validate_required!(input_node, value)
34
+ validate_type!(input_node, value)
35
+ end
36
+
37
+ def fetch_value!(name, inputs)
38
+ input_node = fetch_input_node(name)
39
+ value = inputs.fetch(name.to_sym) { missing_value!(input_node) }
40
+
41
+ validate_required!(input_node, value)
42
+ validate_type!(input_node, value)
43
+ value
44
+ end
45
+
46
+ private
47
+
48
+ def input_nodes
49
+ @input_nodes ||= @compiled_graph.nodes.select { |node| node.kind == :input }
50
+ end
51
+
52
+ def input_nodes_by_name
53
+ @input_nodes_by_name ||= input_nodes.each_with_object({}) { |node, memo| memo[node.name] = node }
54
+ end
55
+
56
+ def fetch_input_node(name)
57
+ input_nodes_by_name.fetch(name.to_sym)
58
+ rescue KeyError
59
+ raise InputError.new("Unknown input: #{name}", context: { graph: @compiled_graph.name, node_name: name.to_sym })
60
+ end
61
+
62
+ def validate_unknown_inputs!(inputs)
63
+ unknown = inputs.keys - input_nodes_by_name.keys
64
+ return if unknown.empty?
65
+
66
+ raise InputError.new(
67
+ "Unknown inputs: #{unknown.sort.join(', ')}",
68
+ context: { graph: @compiled_graph.name }
69
+ )
70
+ end
71
+
72
+ def apply_defaults(inputs)
73
+ input_nodes.each do |node|
74
+ next if inputs.key?(node.name)
75
+ next unless node.default?
76
+
77
+ inputs[node.name] = node.default
78
+ end
79
+ end
80
+
81
+ def validate_known_inputs!(inputs)
82
+ inputs.each do |name, value|
83
+ input_node = fetch_input_node(name)
84
+ validate_required!(input_node, value)
85
+ validate_type!(input_node, value)
86
+ end
87
+ end
88
+
89
+ def missing_value!(input_node)
90
+ return input_node.default if input_node.default?
91
+ return nil unless input_node.required?
92
+
93
+ raise input_error(input_node, "Missing required input: #{input_node.name}")
94
+ end
95
+
96
+ def validate_required!(input_node, value)
97
+ return unless input_node.required?
98
+ return unless value.nil?
99
+
100
+ raise input_error(input_node, "Input '#{input_node.name}' is required")
101
+ end
102
+
103
+ def validate_type!(input_node, value)
104
+ return if value.nil?
105
+ return unless input_node.type
106
+
107
+ unless supported_type?(input_node.type)
108
+ raise input_error(input_node, "Unsupported input type '#{input_node.type}' for '#{input_node.name}'")
109
+ end
110
+
111
+ return if type_match?(input_node.type, value)
112
+
113
+ raise input_error(input_node, "Input '#{input_node.name}' must be of type #{input_node.type}, got #{value.class}")
114
+ end
115
+
116
+ def supported_type?(type)
117
+ SUPPORTED_TYPES.key?(type.to_sym)
118
+ end
119
+
120
+ def type_match?(type, value)
121
+ matcher = SUPPORTED_TYPES.fetch(type.to_sym)
122
+ return value == true || value == false if matcher == :boolean
123
+
124
+ value.is_a?(matcher)
125
+ end
126
+
127
+ def symbolize_keys(hash)
128
+ hash.each_with_object({}) { |(key, value), memo| memo[key.to_sym] = value }
129
+ end
130
+
131
+ def input_error(input_node, message)
132
+ InputError.new(
133
+ message,
134
+ context: {
135
+ graph: @compiled_graph.name,
136
+ node_id: input_node.id,
137
+ node_name: input_node.name,
138
+ node_path: input_node.path,
139
+ source_location: input_node.source_location
140
+ }
141
+ )
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Runtime
5
+ class Invalidator
6
+ def initialize(execution)
7
+ @execution = execution
8
+ end
9
+
10
+ def invalidate_from(node_name)
11
+ queue = @execution.compiled_graph.dependents.fetch(node_name.to_sym, []).dup
12
+ seen = {}
13
+
14
+ until queue.empty?
15
+ dependent_name = queue.shift
16
+ next if seen[dependent_name]
17
+
18
+ seen[dependent_name] = true
19
+ dependent_node = @execution.compiled_graph.fetch_node(dependent_name)
20
+ stale_state = @execution.cache.stale!(dependent_node, invalidated_by: node_name.to_sym)
21
+
22
+ if stale_state
23
+ @execution.events.emit(
24
+ :node_invalidated,
25
+ node: dependent_node,
26
+ status: :stale,
27
+ payload: { cause: node_name.to_sym }
28
+ )
29
+ emit_output_invalidations_for(dependent_node.name, node_name)
30
+ end
31
+
32
+ queue.concat(@execution.compiled_graph.dependents.fetch(dependent_name, []))
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def emit_output_invalidations_for(source_name, cause_name)
39
+ @execution.compiled_graph.outputs.each do |output_node|
40
+ next unless output_node.source == source_name.to_sym
41
+
42
+ @execution.events.emit(
43
+ :node_invalidated,
44
+ node: output_node,
45
+ status: :stale,
46
+ payload: { cause: cause_name.to_sym }
47
+ )
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Runtime
5
+ class NodeState
6
+ attr_reader :node, :status, :value, :error, :version, :resolved_at, :invalidated_by
7
+
8
+ def initialize(node:, status:, value: nil, error: nil, version: nil, resolved_at: Time.now.utc, invalidated_by: nil)
9
+ @node = node
10
+ @status = status
11
+ @value = value
12
+ @error = error
13
+ @version = version
14
+ @resolved_at = resolved_at
15
+ @invalidated_by = invalidated_by
16
+ end
17
+
18
+ def stale?
19
+ status == :stale
20
+ end
21
+
22
+ def succeeded?
23
+ status == :succeeded
24
+ end
25
+
26
+ def failed?
27
+ status == :failed
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Runtime
5
+ class Resolver
6
+ def initialize(execution)
7
+ @execution = execution
8
+ end
9
+
10
+ def resolve(node_name)
11
+ node = @execution.compiled_graph.fetch_node(node_name)
12
+ cached = @execution.cache.fetch(node.name)
13
+ return cached if cached && !cached.stale?
14
+
15
+ @execution.events.emit(:node_started, node: node, status: :running)
16
+
17
+ state = case node.kind
18
+ when :input
19
+ resolve_input(node)
20
+ when :compute
21
+ resolve_compute(node)
22
+ when :composition
23
+ resolve_composition(node)
24
+ else
25
+ raise ResolutionError, "Unsupported node kind: #{node.kind}"
26
+ end
27
+
28
+ @execution.cache.write(state)
29
+ @execution.events.emit(
30
+ state.failed? ? :node_failed : :node_succeeded,
31
+ node: node,
32
+ status: state.status,
33
+ payload: success_payload(node, state)
34
+ )
35
+ state
36
+ rescue StandardError => e
37
+ state = NodeState.new(node: node, status: :failed, error: normalize_error(e, node))
38
+ @execution.cache.write(state)
39
+ @execution.events.emit(:node_failed, node: node, status: :failed, payload: { error: state.error.message })
40
+ state
41
+ end
42
+
43
+ private
44
+
45
+ def resolve_input(node)
46
+ NodeState.new(node: node, status: :succeeded, value: @execution.fetch_input!(node.name))
47
+ end
48
+
49
+ def resolve_compute(node)
50
+ dependencies = node.dependencies.each_with_object({}) do |dependency_name, memo|
51
+ dependency_state = resolve(dependency_name)
52
+ raise dependency_state.error if dependency_state.failed?
53
+
54
+ memo[dependency_name] = dependency_state.value
55
+ end
56
+
57
+ value = call_compute(node.callable, dependencies)
58
+ NodeState.new(node: node, status: :succeeded, value: value)
59
+ end
60
+
61
+ def call_compute(callable, dependencies)
62
+ case callable
63
+ when Proc
64
+ callable.call(**dependencies)
65
+ when Symbol, String
66
+ @execution.contract_instance.public_send(callable.to_sym, **dependencies)
67
+ else
68
+ raise ResolutionError, "Unsupported callable: #{callable.class}"
69
+ end
70
+ end
71
+
72
+ def resolve_composition(node)
73
+ child_inputs = node.input_mapping.each_with_object({}) do |(child_input_name, dependency_name), memo|
74
+ dependency_state = resolve(dependency_name)
75
+ raise dependency_state.error if dependency_state.failed?
76
+
77
+ memo[child_input_name] = dependency_state.value
78
+ end
79
+
80
+ child_contract = node.contract_class.new(child_inputs)
81
+ child_contract.resolve_all
82
+ child_error = child_contract.result.errors.values.first
83
+ raise child_error if child_error
84
+
85
+ NodeState.new(node: node, status: :succeeded, value: child_contract.result)
86
+ end
87
+
88
+ def success_payload(node, state)
89
+ return {} unless node.kind == :composition
90
+ return {} unless state.value.is_a?(Igniter::Runtime::Result)
91
+
92
+ {
93
+ child_execution_id: state.value.execution.events.execution_id,
94
+ child_graph: state.value.execution.compiled_graph.name
95
+ }
96
+ end
97
+
98
+ def normalize_error(error, node)
99
+ return error if error.is_a?(Igniter::Error)
100
+
101
+ ResolutionError.new(
102
+ error.message,
103
+ context: {
104
+ graph: @execution.compiled_graph.name,
105
+ node_id: node.id,
106
+ node_name: node.name,
107
+ node_path: node.path,
108
+ source_location: node.source_location
109
+ }
110
+ )
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Runtime
5
+ class Result
6
+ attr_reader :execution
7
+
8
+ def initialize(execution)
9
+ @execution = execution
10
+ define_output_readers!
11
+ end
12
+
13
+ def to_h
14
+ @execution.compiled_graph.outputs.each_with_object({}) do |output_node, memo|
15
+ memo[output_node.name] = serialize_output_value(public_send(output_node.name))
16
+ end
17
+ end
18
+
19
+ def success?
20
+ @execution.resolve_all
21
+ !failed?
22
+ end
23
+
24
+ def failed?
25
+ @execution.resolve_all
26
+ @execution.cache.values.any?(&:failed?)
27
+ end
28
+
29
+ def errors
30
+ @execution.resolve_all
31
+ @execution.cache.values.each_with_object({}) do |state, memo|
32
+ next unless state.failed?
33
+
34
+ memo[state.node.name] = state.error
35
+ end
36
+ end
37
+
38
+ def states
39
+ @execution.resolve_all
40
+ @execution.states
41
+ end
42
+
43
+ def explain(output_name)
44
+ @execution.resolve_output(output_name)
45
+ @execution.explain_output(output_name)
46
+ end
47
+
48
+ def as_json(*)
49
+ @execution.resolve_all
50
+
51
+ {
52
+ graph: @execution.compiled_graph.name,
53
+ execution_id: @execution.events.execution_id,
54
+ outputs: to_h,
55
+ success: !failed?,
56
+ failed: failed?,
57
+ errors: serialize_errors(errors),
58
+ states: states
59
+ }
60
+ end
61
+
62
+ private
63
+
64
+ def define_output_readers!
65
+ @execution.compiled_graph.outputs.each do |output_node|
66
+ define_singleton_method(output_node.name) do
67
+ @execution.resolve_output(output_node.name)
68
+ end
69
+ end
70
+ end
71
+
72
+ def serialize_value(value)
73
+ case value
74
+ when Result
75
+ value.as_json
76
+ when Array
77
+ value.map { |item| serialize_value(item) }
78
+ else
79
+ value
80
+ end
81
+ end
82
+
83
+ def serialize_output_value(value)
84
+ case value
85
+ when Result
86
+ value.to_h
87
+ when Array
88
+ value.map { |item| serialize_output_value(item) }
89
+ else
90
+ value
91
+ end
92
+ end
93
+
94
+ def serialize_errors(error_hash)
95
+ error_hash.each_with_object({}) do |(node_name, error), memo|
96
+ memo[node_name] = {
97
+ type: error.class.name,
98
+ message: error.message,
99
+ context: error.respond_to?(:context) ? error.context : {}
100
+ }
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "runtime/node_state"
4
+ require_relative "runtime/cache"
5
+ require_relative "runtime/input_validator"
6
+ require_relative "runtime/resolver"
7
+ require_relative "runtime/invalidator"
8
+ require_relative "runtime/result"
9
+ require_relative "runtime/execution"
10
+
11
+ module Igniter
12
+ module Runtime
13
+ end
14
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ VERSION = "0.2.0"
5
+ end
data/lib/igniter.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "igniter/version"
4
+ require_relative "igniter/errors"
5
+ require_relative "igniter/model"
6
+ require_relative "igniter/compiler"
7
+ require_relative "igniter/events"
8
+ require_relative "igniter/runtime"
9
+ require_relative "igniter/dsl"
10
+ require_relative "igniter/extensions"
11
+ require_relative "igniter/diagnostics"
12
+ require_relative "igniter/contract"
13
+
14
+ module Igniter
15
+ class << self
16
+ def compile(&block)
17
+ DSL::ContractBuilder.compile(&block)
18
+ end
19
+ end
20
+ end
data/sig/igniter.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Igniter
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,126 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: igniter
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Alexander
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rake
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '13.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '13.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rspec
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.0'
40
+ description: Igniter provides a contract DSL, graph compiler, runtime execution engine,
41
+ auditing, reactivity, and introspection for business logic expressed as dependency
42
+ graphs.
43
+ email:
44
+ - alexander.s.fokin@gmail.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - CHANGELOG.md
50
+ - LICENSE.txt
51
+ - README.md
52
+ - docs/API_V2.md
53
+ - docs/ARCHITECTURE_V2.md
54
+ - docs/EXECUTION_MODEL_V2.md
55
+ - docs/IGNITER_CONCEPTS.md
56
+ - examples/README.md
57
+ - examples/basic_pricing.rb
58
+ - examples/composition.rb
59
+ - examples/diagnostics.rb
60
+ - lib/igniter.rb
61
+ - lib/igniter/compiler.rb
62
+ - lib/igniter/compiler/compiled_graph.rb
63
+ - lib/igniter/compiler/graph_compiler.rb
64
+ - lib/igniter/compiler/validator.rb
65
+ - lib/igniter/contract.rb
66
+ - lib/igniter/diagnostics.rb
67
+ - lib/igniter/diagnostics/report.rb
68
+ - lib/igniter/dsl.rb
69
+ - lib/igniter/dsl/contract_builder.rb
70
+ - lib/igniter/errors.rb
71
+ - lib/igniter/events.rb
72
+ - lib/igniter/events/bus.rb
73
+ - lib/igniter/events/event.rb
74
+ - lib/igniter/extensions.rb
75
+ - lib/igniter/extensions/auditing.rb
76
+ - lib/igniter/extensions/auditing/timeline.rb
77
+ - lib/igniter/extensions/introspection.rb
78
+ - lib/igniter/extensions/introspection/graph_formatter.rb
79
+ - lib/igniter/extensions/introspection/runtime_formatter.rb
80
+ - lib/igniter/extensions/reactive.rb
81
+ - lib/igniter/extensions/reactive/engine.rb
82
+ - lib/igniter/extensions/reactive/matcher.rb
83
+ - lib/igniter/extensions/reactive/reaction.rb
84
+ - lib/igniter/model.rb
85
+ - lib/igniter/model/composition_node.rb
86
+ - lib/igniter/model/compute_node.rb
87
+ - lib/igniter/model/graph.rb
88
+ - lib/igniter/model/input_node.rb
89
+ - lib/igniter/model/node.rb
90
+ - lib/igniter/model/output_node.rb
91
+ - lib/igniter/runtime.rb
92
+ - lib/igniter/runtime/cache.rb
93
+ - lib/igniter/runtime/execution.rb
94
+ - lib/igniter/runtime/input_validator.rb
95
+ - lib/igniter/runtime/invalidator.rb
96
+ - lib/igniter/runtime/node_state.rb
97
+ - lib/igniter/runtime/resolver.rb
98
+ - lib/igniter/runtime/result.rb
99
+ - lib/igniter/version.rb
100
+ - sig/igniter.rbs
101
+ homepage: https://github.com/alexander-s-f/igniter
102
+ licenses:
103
+ - MIT
104
+ metadata:
105
+ rubygems_mfa_required: 'true'
106
+ homepage_uri: https://github.com/alexander-s-f/igniter
107
+ source_code_uri: https://github.com/alexander-s-f/igniter
108
+ changelog_uri: https://github.com/alexander-s-f/igniter/blob/main/CHANGELOG.md
109
+ rdoc_options: []
110
+ require_paths:
111
+ - lib
112
+ required_ruby_version: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: 3.1.0
117
+ required_rubygems_version: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: '0'
122
+ requirements: []
123
+ rubygems_version: 3.6.9
124
+ specification_version: 4
125
+ summary: Declarative dependency-graph runtime for business logic
126
+ test_files: []