igniter 0.4.0 → 0.4.3

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +238 -218
  4. data/docs/LLM_V1.md +335 -0
  5. data/docs/PATTERNS.md +189 -0
  6. data/docs/SERVER_V1.md +313 -0
  7. data/examples/README.md +129 -0
  8. data/examples/agents.rb +150 -0
  9. data/examples/differential.rb +161 -0
  10. data/examples/distributed_server.rb +94 -0
  11. data/examples/effects.rb +184 -0
  12. data/examples/invariants.rb +179 -0
  13. data/examples/order_pipeline.rb +163 -0
  14. data/examples/provenance.rb +122 -0
  15. data/examples/saga.rb +110 -0
  16. data/lib/igniter/agent/mailbox.rb +96 -0
  17. data/lib/igniter/agent/message.rb +21 -0
  18. data/lib/igniter/agent/ref.rb +86 -0
  19. data/lib/igniter/agent/runner.rb +129 -0
  20. data/lib/igniter/agent/state_holder.rb +23 -0
  21. data/lib/igniter/agent.rb +155 -0
  22. data/lib/igniter/compiler/validators/callable_validator.rb +21 -3
  23. data/lib/igniter/differential/divergence.rb +29 -0
  24. data/lib/igniter/differential/formatter.rb +96 -0
  25. data/lib/igniter/differential/report.rb +86 -0
  26. data/lib/igniter/differential/runner.rb +130 -0
  27. data/lib/igniter/differential.rb +51 -0
  28. data/lib/igniter/dsl/contract_builder.rb +32 -0
  29. data/lib/igniter/effect.rb +91 -0
  30. data/lib/igniter/effect_registry.rb +78 -0
  31. data/lib/igniter/errors.rb +11 -1
  32. data/lib/igniter/execution_report/builder.rb +54 -0
  33. data/lib/igniter/execution_report/formatter.rb +50 -0
  34. data/lib/igniter/execution_report/node_entry.rb +24 -0
  35. data/lib/igniter/execution_report/report.rb +65 -0
  36. data/lib/igniter/execution_report.rb +32 -0
  37. data/lib/igniter/extensions/differential.rb +114 -0
  38. data/lib/igniter/extensions/execution_report.rb +27 -0
  39. data/lib/igniter/extensions/invariants.rb +116 -0
  40. data/lib/igniter/extensions/provenance.rb +45 -0
  41. data/lib/igniter/extensions/saga.rb +74 -0
  42. data/lib/igniter/integrations/agents.rb +18 -0
  43. data/lib/igniter/invariant.rb +50 -0
  44. data/lib/igniter/model/effect_node.rb +37 -0
  45. data/lib/igniter/model.rb +1 -0
  46. data/lib/igniter/property_testing/formatter.rb +66 -0
  47. data/lib/igniter/property_testing/generators.rb +115 -0
  48. data/lib/igniter/property_testing/result.rb +45 -0
  49. data/lib/igniter/property_testing/run.rb +43 -0
  50. data/lib/igniter/property_testing/runner.rb +47 -0
  51. data/lib/igniter/property_testing.rb +64 -0
  52. data/lib/igniter/provenance/builder.rb +97 -0
  53. data/lib/igniter/provenance/lineage.rb +82 -0
  54. data/lib/igniter/provenance/node_trace.rb +65 -0
  55. data/lib/igniter/provenance/text_formatter.rb +70 -0
  56. data/lib/igniter/provenance.rb +29 -0
  57. data/lib/igniter/registry.rb +67 -0
  58. data/lib/igniter/runtime/resolver.rb +15 -0
  59. data/lib/igniter/saga/compensation.rb +31 -0
  60. data/lib/igniter/saga/compensation_record.rb +20 -0
  61. data/lib/igniter/saga/executor.rb +85 -0
  62. data/lib/igniter/saga/formatter.rb +49 -0
  63. data/lib/igniter/saga/result.rb +47 -0
  64. data/lib/igniter/saga.rb +56 -0
  65. data/lib/igniter/stream_loop.rb +80 -0
  66. data/lib/igniter/supervisor.rb +167 -0
  67. data/lib/igniter/version.rb +1 -1
  68. data/lib/igniter.rb +10 -0
  69. metadata +57 -1
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "igniter"
4
+ require "igniter/invariant"
5
+
6
+ module Igniter
7
+ module Extensions
8
+ # Patches Igniter::Contract with:
9
+ # - Class method: invariant(name) { |output:, **| condition }
10
+ # - Instance method: check_invariants → Array<InvariantViolation>
11
+ # - Automatic post-execution check in resolve_all (raises InvariantError)
12
+ #
13
+ # Invariant blocks receive ONLY the contract's declared output values as
14
+ # keyword args — the stable public interface, independent of internal nodes.
15
+ # Use ** to absorb outputs you don't need.
16
+ #
17
+ # @example
18
+ # class PricingContract < Igniter::Contract
19
+ # define do
20
+ # input :price
21
+ # input :quantity
22
+ # compute :total, depends_on: %i[price quantity] do |price:, quantity:|
23
+ # price * quantity
24
+ # end
25
+ # output :total
26
+ # end
27
+ #
28
+ # invariant(:total_non_negative) { |total:, **| total >= 0 }
29
+ # end
30
+ #
31
+ # This module is applied globally via:
32
+ # Igniter::Contract.include(Igniter::Extensions::Invariants)
33
+ #
34
+ module Invariants
35
+ def self.included(base)
36
+ base.extend(ClassMethods)
37
+ base.prepend(InstanceMethods)
38
+ end
39
+
40
+ # ── Class-level DSL ──────────────────────────────────────────────────────
41
+
42
+ module ClassMethods
43
+ # Declare a condition that must always hold after successful execution.
44
+ #
45
+ # The block receives the contract's declared output values as keyword
46
+ # arguments; use ** to absorb outputs you don't care about. Return a
47
+ # truthy value to indicate the invariant holds, falsy to indicate
48
+ # a violation.
49
+ #
50
+ # @example
51
+ # invariant(:positive_total) { |total:, **| total >= 0 }
52
+ #
53
+ # @param name [Symbol]
54
+ def invariant(name, &block)
55
+ @_invariants ||= {}
56
+ @_invariants[name.to_sym] = Igniter::Invariant.new(name, &block)
57
+ end
58
+
59
+ # @return [Hash{Symbol => Igniter::Invariant}]
60
+ def invariants
61
+ @_invariants || {}
62
+ end
63
+ end
64
+
65
+ # ── Instance override + new public method ───────────────────────────────
66
+
67
+ module InstanceMethods
68
+ # Intercepts resolve_all to run invariant checks after execution.
69
+ # Uses a thread-local flag so that property testing can disable the
70
+ # automatic raise and collect violations as data instead.
71
+ def resolve_all(...)
72
+ result = super
73
+ validate_invariants! unless Thread.current[:igniter_skip_invariants]
74
+ result
75
+ end
76
+
77
+ # Run all invariants without raising. Returns violations as data.
78
+ # Safe to call at any time after execution.
79
+ #
80
+ # @return [Array<Igniter::InvariantViolation>]
81
+ def check_invariants
82
+ return [] if self.class.invariants.empty?
83
+
84
+ resolved = collect_output_values
85
+ self.class.invariants.values.filter_map { |inv| inv.check(resolved) }
86
+ end
87
+
88
+ private
89
+
90
+ def validate_invariants!
91
+ violations = check_invariants
92
+ return if violations.empty?
93
+
94
+ names = violations.map { |v| ":#{v.name}" }.join(", ")
95
+ raise Igniter::InvariantError.new(
96
+ "#{violations.size} invariant(s) violated: #{names}",
97
+ violations: violations,
98
+ context: { contract: self.class.name }
99
+ )
100
+ end
101
+
102
+ # Collect all declared output values from the resolved cache.
103
+ # Keyed by output name (not source node name).
104
+ def collect_output_values
105
+ cache = execution.cache
106
+ execution.compiled_graph.outputs.each_with_object({}) do |output_node, acc|
107
+ state = cache.fetch(output_node.source_root)
108
+ acc[output_node.name] = state.value if state&.succeeded?
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+
116
+ Igniter::Contract.include(Igniter::Extensions::Invariants)
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../provenance"
4
+
5
+ module Igniter
6
+ module Extensions
7
+ # Adds runtime provenance (data lineage) methods to Igniter::Contract.
8
+ #
9
+ # After requiring this file, every resolved contract gains two new methods:
10
+ #
11
+ # contract.resolve_all
12
+ #
13
+ # # Full Lineage object — query API
14
+ # lin = contract.lineage(:grand_total)
15
+ # lin.contributing_inputs # => { base_price: 100.0, quantity: 2 }
16
+ # lin.sensitive_to?(:base_price) # => true
17
+ # lin.path_to(:base_price) # => [:grand_total, :subtotal, :base_price]
18
+ # lin.to_h # serialisable Hash
19
+ #
20
+ # # Shorthand: human-readable ASCII tree printed to stdout
21
+ # contract.explain(:grand_total)
22
+ #
23
+ module Provenance
24
+ # Return a Lineage object for the named output.
25
+ # Raises ProvenanceError if the output is unknown or the contract has not
26
+ # been executed (resolve_all has not been called).
27
+ def lineage(output_name)
28
+ unless execution
29
+ raise Igniter::Provenance::ProvenanceError,
30
+ "Contract has not been executed — call resolve_all first"
31
+ end
32
+
33
+ Igniter::Provenance::Builder.build(output_name, execution)
34
+ end
35
+
36
+ # Return a human-readable ASCII tree explaining how +output_name+ was derived.
37
+ def explain(output_name)
38
+ lineage(output_name).explain
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ # Patch Igniter::Contract so every contract instance gains the methods.
45
+ Igniter::Contract.include(Igniter::Extensions::Provenance)
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "igniter"
4
+ require "igniter/saga"
5
+
6
+ module Igniter
7
+ module Extensions
8
+ # Adds saga/compensating-transaction support to all Igniter contracts.
9
+ #
10
+ # Class-level DSL:
11
+ # compensate :node_name do |inputs:, value:| ... end
12
+ #
13
+ # Instance methods added:
14
+ # resolve_saga → Igniter::Saga::Result
15
+ #
16
+ # Applied globally via:
17
+ # Igniter::Contract.include(Igniter::Extensions::Saga)
18
+ #
19
+ module Saga
20
+ def self.included(base)
21
+ base.extend(ClassMethods)
22
+ end
23
+
24
+ # ── Class-level DSL ────────────────────────────────────────────────────
25
+
26
+ module ClassMethods
27
+ # Declare a compensating action for a compute node.
28
+ #
29
+ # The block is called with:
30
+ # inputs: — Hash of the node's dependency values
31
+ # value: — the value the node produced (now being rolled back)
32
+ #
33
+ # @param node_name [Symbol]
34
+ def compensate(node_name, &block)
35
+ @_compensations ||= {}
36
+ @_compensations[node_name.to_sym] = Igniter::Saga::Compensation.new(node_name, &block)
37
+ end
38
+
39
+ # @return [Hash{ Symbol => Compensation }]
40
+ def compensations
41
+ @_compensations || {}
42
+ end
43
+ end
44
+
45
+ # ── Instance methods ───────────────────────────────────────────────────
46
+
47
+ # Execute the contract and handle compensations on failure.
48
+ #
49
+ # Unlike `resolve_all` (which raises on failure), `resolve_saga`:
50
+ # - Returns a successful Result when execution completes
51
+ # - Catches Igniter::Error, runs compensations, returns a failed Result
52
+ #
53
+ # @return [Igniter::Saga::Result]
54
+ def resolve_saga # rubocop:disable Metrics/MethodLength
55
+ resolve_all
56
+ Igniter::Saga::Result.new(success: true, contract: self)
57
+ rescue Igniter::Error => e
58
+ executor = Igniter::Saga::Executor.new(self)
59
+ compensations_ran = executor.run_compensations
60
+ failed_node = executor.failed_node_name
61
+
62
+ Igniter::Saga::Result.new(
63
+ success: false,
64
+ contract: self,
65
+ error: e,
66
+ failed_node: failed_node,
67
+ compensations: compensations_ran
68
+ )
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ Igniter::Contract.include(Igniter::Extensions::Saga)
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Actor system for Igniter — stateful message-driven agents with supervision.
4
+ #
5
+ # Usage:
6
+ # require "igniter/integrations/agents"
7
+ #
8
+ # Provides:
9
+ # Igniter::Agent — base class for stateful actors
10
+ # Igniter::Supervisor — supervises and restarts child agents
11
+ # Igniter::Registry — thread-safe name → Ref lookup
12
+ # Igniter::StreamLoop — continuous contract-in-a-tick-loop
13
+ #
14
+
15
+ require_relative "../agent"
16
+ require_relative "../supervisor"
17
+ require_relative "../registry"
18
+ require_relative "../stream_loop"
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ # Represents a named condition that must always hold for a contract's outputs.
5
+ #
6
+ # An Invariant wraps a block that receives the contract's declared output values
7
+ # as keyword arguments and returns a truthy value when the condition holds.
8
+ #
9
+ # @example
10
+ # inv = Igniter::Invariant.new(:total_non_negative) { |total:, **| total >= 0 }
11
+ # inv.check(total: 42) # => nil (passed)
12
+ # inv.check(total: -1) # => InvariantViolation
13
+ class Invariant
14
+ attr_reader :name, :block
15
+
16
+ def initialize(name, &block)
17
+ raise ArgumentError, "invariant :#{name} requires a block" unless block
18
+
19
+ @name = name.to_sym
20
+ @block = block
21
+ freeze
22
+ end
23
+
24
+ # Evaluate invariant against the resolved output values.
25
+ #
26
+ # @param resolved_values [Hash] output_name => value for all declared outputs
27
+ # @return [InvariantViolation, nil] nil when the invariant holds
28
+ def check(resolved_values)
29
+ passed = block.call(**resolved_values)
30
+ passed ? nil : InvariantViolation.new(name: name, passed: false)
31
+ rescue StandardError => e
32
+ InvariantViolation.new(name: name, passed: false, error: e)
33
+ end
34
+ end
35
+
36
+ # Records a single invariant check outcome.
37
+ class InvariantViolation
38
+ attr_reader :name, :error
39
+
40
+ def initialize(name:, passed:, error: nil)
41
+ @name = name.to_sym
42
+ @passed = passed
43
+ @error = error
44
+ freeze
45
+ end
46
+
47
+ def passed? = @passed
48
+ def failed? = !@passed
49
+ end
50
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Model
5
+ # Represents a side-effect node in the computation graph.
6
+ #
7
+ # An EffectNode wraps an Igniter::Effect adapter class. It participates
8
+ # in topological ordering, dependency resolution, saga compensations,
9
+ # and execution reporting like any other node — but is explicitly typed
10
+ # as a side effect for visibility and audit purposes.
11
+ class EffectNode < Node
12
+ attr_reader :adapter_class
13
+
14
+ def initialize(id:, name:, dependencies:, adapter_class:, path: nil, metadata: {}) # rubocop:disable Metrics/ParameterLists
15
+ super(
16
+ id: id,
17
+ kind: :effect,
18
+ name: name,
19
+ path: path || name,
20
+ dependencies: dependencies,
21
+ metadata: metadata
22
+ )
23
+ @adapter_class = adapter_class
24
+ end
25
+
26
+ # @return [Symbol] e.g. :database, :http, :cache, :generic
27
+ def effect_type
28
+ adapter_class.effect_type
29
+ end
30
+
31
+ # @return [Boolean]
32
+ def idempotent?
33
+ adapter_class.idempotent?
34
+ end
35
+ end
36
+ end
37
+ end
data/lib/igniter/model.rb CHANGED
@@ -10,6 +10,7 @@ require_relative "model/collection_node"
10
10
  require_relative "model/output_node"
11
11
  require_relative "model/await_node"
12
12
  require_relative "model/remote_node"
13
+ require_relative "model/effect_node"
13
14
 
14
15
  module Igniter
15
16
  module Model
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module PropertyTesting
5
+ # Renders a property-test Result as a human-readable ASCII report.
6
+ class Formatter
7
+ PASS_LABEL = "PASS"
8
+ FAIL_LABEL = "FAIL"
9
+
10
+ def initialize(result)
11
+ @result = result
12
+ end
13
+
14
+ def render # rubocop:disable Metrics/MethodLength
15
+ lines = []
16
+ lines << header
17
+ lines << summary_line
18
+ lines << ""
19
+
20
+ if @result.passed?
21
+ lines << " All #{@result.total_runs} runs passed."
22
+ else
23
+ lines << counterexample_section
24
+ lines << ""
25
+ lines << failed_runs_section
26
+ end
27
+
28
+ lines.join("\n")
29
+ end
30
+
31
+ private
32
+
33
+ def header
34
+ label = @result.passed? ? PASS_LABEL : FAIL_LABEL
35
+ "PropertyTest: #{@result.contract_class.name} [#{label}]"
36
+ end
37
+
38
+ def summary_line
39
+ "Runs: #{@result.total_runs} | " \
40
+ "Passed: #{@result.passed_runs.size} | " \
41
+ "Failed: #{@result.failed_runs.size}"
42
+ end
43
+
44
+ def counterexample_section
45
+ cx = @result.counterexample
46
+ return "" unless cx
47
+
48
+ [
49
+ "COUNTEREXAMPLE (run ##{cx.run_number}):",
50
+ " Inputs: #{cx.inputs.inspect}",
51
+ " Failure: #{cx.failure_message}"
52
+ ].join("\n")
53
+ end
54
+
55
+ def failed_runs_section
56
+ return "" if @result.failed_runs.empty?
57
+
58
+ lines = ["FAILED RUNS:"]
59
+ @result.failed_runs.each do |run|
60
+ lines << " ##{run.run_number.to_s.ljust(5)} #{run.failure_message.ljust(40)} inputs: #{run.inputs.inspect}"
61
+ end
62
+ lines.join("\n")
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module PropertyTesting
5
+ # Built-in convenience generators for property testing.
6
+ #
7
+ # Each method returns a callable (lambda) that produces a random value
8
+ # when invoked. Pass these as values in the `generators:` hash to
9
+ # ContractClass.property_test.
10
+ #
11
+ # @example
12
+ # G = Igniter::PropertyTesting::Generators
13
+ #
14
+ # MyContract.property_test(
15
+ # generators: {
16
+ # price: G.float(0.0..500.0),
17
+ # quantity: G.positive_integer(max: 100),
18
+ # label: G.string(length: 3..10),
19
+ # active: G.boolean
20
+ # },
21
+ # runs: 200
22
+ # )
23
+ module Generators
24
+ # @param min [Integer]
25
+ # @param max [Integer]
26
+ # @return [#call]
27
+ def self.integer(min: -100, max: 100)
28
+ -> { rand(min..max) }
29
+ end
30
+
31
+ # @param max [Integer]
32
+ # @return [#call]
33
+ def self.positive_integer(max: 1000)
34
+ -> { rand(1..max) }
35
+ end
36
+
37
+ # @param range [Range<Float>]
38
+ # @return [#call]
39
+ def self.float(range = 0.0..1.0)
40
+ -> { range.min + rand * (range.max - range.min) }
41
+ end
42
+
43
+ # @param length [Range<Integer>, Integer]
44
+ # @param charset [Symbol] :alpha, :alphanumeric, :hex, :printable
45
+ # @return [#call]
46
+ def self.string(length: 1..20, charset: :alpha) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
47
+ chars = case charset
48
+ when :alpha then ("a".."z").to_a + ("A".."Z").to_a
49
+ when :alphanumeric then ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
50
+ when :hex then ("0".."9").to_a + ("a".."f").to_a
51
+ when :printable then (32..126).map(&:chr)
52
+ else raise ArgumentError, "Unknown charset: #{charset}"
53
+ end
54
+
55
+ lambda do
56
+ len = length.is_a?(Range) ? rand(length) : length
57
+ Array.new(len) { chars.sample }.join
58
+ end
59
+ end
60
+
61
+ # Returns one of the supplied values at random.
62
+ #
63
+ # @param values [Array]
64
+ # @return [#call]
65
+ def self.one_of(*values)
66
+ raise ArgumentError, "one_of requires at least one value" if values.empty?
67
+
68
+ -> { values.sample }
69
+ end
70
+
71
+ # Generates an array of values produced by the given generator.
72
+ #
73
+ # @param generator [#call]
74
+ # @param size [Range<Integer>, Integer]
75
+ # @return [#call]
76
+ def self.array(generator, size: 0..10)
77
+ lambda do
78
+ len = size.is_a?(Range) ? rand(size) : size
79
+ Array.new(len) { generator.call }
80
+ end
81
+ end
82
+
83
+ # @return [#call]
84
+ def self.boolean
85
+ -> { [true, false].sample }
86
+ end
87
+
88
+ # Wraps another generator, occasionally returning nil.
89
+ #
90
+ # @param generator [#call]
91
+ # @param null_rate [Float] probability of nil (0.0..1.0)
92
+ # @return [#call]
93
+ def self.nullable(generator, null_rate: 0.1)
94
+ -> { rand < null_rate ? nil : generator.call }
95
+ end
96
+
97
+ # Generates a Hash where each key maps to a generated value.
98
+ #
99
+ # @param fields [Hash{Symbol => #call}]
100
+ # @return [#call]
101
+ def self.hash_of(**fields)
102
+ -> { fields.transform_values(&:call) }
103
+ end
104
+
105
+ # Always returns the same constant value. Useful for pinning one input
106
+ # while randomising others.
107
+ #
108
+ # @param value [Object]
109
+ # @return [#call]
110
+ def self.constant(value)
111
+ -> { value }
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module PropertyTesting
5
+ # Aggregated result of a full property-test run.
6
+ class Result
7
+ attr_reader :contract_class, :total_runs, :runs
8
+
9
+ def initialize(contract_class:, total_runs:, runs:)
10
+ @contract_class = contract_class
11
+ @total_runs = total_runs
12
+ @runs = runs.freeze
13
+ end
14
+
15
+ def passed? = failed_runs.empty?
16
+ def failed_runs = runs.select(&:failed?)
17
+ def passed_runs = runs.select(&:passed?)
18
+
19
+ # The first failing run — the counterexample to inspect and debug.
20
+ # nil when all runs passed.
21
+ #
22
+ # @return [Run, nil]
23
+ def counterexample = failed_runs.first
24
+
25
+ # @return [String]
26
+ def explain = Formatter.new(self).render
27
+
28
+ # @return [Hash]
29
+ def to_h # rubocop:disable Metrics/MethodLength
30
+ {
31
+ contract: contract_class.name,
32
+ total_runs: total_runs,
33
+ passed: passed_runs.size,
34
+ failed: failed_runs.size,
35
+ passed?: passed?,
36
+ counterexample: counterexample && {
37
+ run_number: counterexample.run_number,
38
+ inputs: counterexample.inputs,
39
+ failure: counterexample.failure_message
40
+ }
41
+ }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module PropertyTesting
5
+ # Result of a single property-test execution.
6
+ #
7
+ # A run can fail in two ways:
8
+ # - :execution_error — the contract raised an error
9
+ # - :invariant_violation — the contract completed but an invariant was violated
10
+ class Run
11
+ attr_reader :run_number, :inputs, :execution_error, :violations
12
+
13
+ def initialize(run_number:, inputs:, execution_error: nil, violations: [])
14
+ @run_number = run_number
15
+ @inputs = inputs.freeze
16
+ @execution_error = execution_error
17
+ @violations = violations.freeze
18
+ freeze
19
+ end
20
+
21
+ def passed? = execution_error.nil? && violations.empty?
22
+ def failed? = !passed?
23
+
24
+ # @return [Symbol, nil] :execution_error, :invariant_violation, or nil
25
+ def failure_type
26
+ if execution_error
27
+ :execution_error
28
+ elsif violations.any?
29
+ :invariant_violation
30
+ end
31
+ end
32
+
33
+ # @return [String, nil]
34
+ def failure_message
35
+ if execution_error
36
+ execution_error.message
37
+ elsif violations.any?
38
+ violations.map { |v| ":#{v.name} violated" }.join(", ")
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module PropertyTesting
5
+ # Executes a contract repeatedly with random inputs and collects run results.
6
+ #
7
+ # Invariant violations are captured as data (not exceptions) using the
8
+ # Thread.current[:igniter_skip_invariants] flag, which prevents the
9
+ # automatic raise in resolve_all.
10
+ class Runner
11
+ def initialize(contract_class, generators:, runs: 100, seed: nil)
12
+ @contract_class = contract_class
13
+ @generators = generators
14
+ @total_runs = runs
15
+ @seed = seed
16
+ end
17
+
18
+ # @return [Result]
19
+ def run
20
+ Random.srand(@seed) if @seed
21
+
22
+ runs = (1..@total_runs).map { |n| execute_run(n) }
23
+ Result.new(contract_class: @contract_class, total_runs: @total_runs, runs: runs)
24
+ end
25
+
26
+ private
27
+
28
+ def execute_run(run_number)
29
+ inputs = generate_inputs
30
+ contract = @contract_class.new(**inputs)
31
+
32
+ Thread.current[:igniter_skip_invariants] = true
33
+ contract.resolve_all
34
+ violations = contract.check_invariants
35
+ Run.new(run_number: run_number, inputs: inputs, violations: violations)
36
+ rescue StandardError => e
37
+ Run.new(run_number: run_number, inputs: inputs, execution_error: e)
38
+ ensure
39
+ Thread.current[:igniter_skip_invariants] = false
40
+ end
41
+
42
+ def generate_inputs
43
+ @generators.transform_values(&:call)
44
+ end
45
+ end
46
+ end
47
+ end