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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +238 -218
- data/docs/LLM_V1.md +335 -0
- data/docs/PATTERNS.md +189 -0
- data/docs/SERVER_V1.md +313 -0
- data/examples/README.md +129 -0
- data/examples/agents.rb +150 -0
- data/examples/differential.rb +161 -0
- data/examples/distributed_server.rb +94 -0
- data/examples/effects.rb +184 -0
- data/examples/invariants.rb +179 -0
- data/examples/order_pipeline.rb +163 -0
- data/examples/provenance.rb +122 -0
- data/examples/saga.rb +110 -0
- data/lib/igniter/agent/mailbox.rb +96 -0
- data/lib/igniter/agent/message.rb +21 -0
- data/lib/igniter/agent/ref.rb +86 -0
- data/lib/igniter/agent/runner.rb +129 -0
- data/lib/igniter/agent/state_holder.rb +23 -0
- data/lib/igniter/agent.rb +155 -0
- data/lib/igniter/compiler/validators/callable_validator.rb +21 -3
- data/lib/igniter/differential/divergence.rb +29 -0
- data/lib/igniter/differential/formatter.rb +96 -0
- data/lib/igniter/differential/report.rb +86 -0
- data/lib/igniter/differential/runner.rb +130 -0
- data/lib/igniter/differential.rb +51 -0
- data/lib/igniter/dsl/contract_builder.rb +32 -0
- data/lib/igniter/effect.rb +91 -0
- data/lib/igniter/effect_registry.rb +78 -0
- data/lib/igniter/errors.rb +11 -1
- data/lib/igniter/execution_report/builder.rb +54 -0
- data/lib/igniter/execution_report/formatter.rb +50 -0
- data/lib/igniter/execution_report/node_entry.rb +24 -0
- data/lib/igniter/execution_report/report.rb +65 -0
- data/lib/igniter/execution_report.rb +32 -0
- data/lib/igniter/extensions/differential.rb +114 -0
- data/lib/igniter/extensions/execution_report.rb +27 -0
- data/lib/igniter/extensions/invariants.rb +116 -0
- data/lib/igniter/extensions/provenance.rb +45 -0
- data/lib/igniter/extensions/saga.rb +74 -0
- data/lib/igniter/integrations/agents.rb +18 -0
- data/lib/igniter/invariant.rb +50 -0
- data/lib/igniter/model/effect_node.rb +37 -0
- data/lib/igniter/model.rb +1 -0
- data/lib/igniter/property_testing/formatter.rb +66 -0
- data/lib/igniter/property_testing/generators.rb +115 -0
- data/lib/igniter/property_testing/result.rb +45 -0
- data/lib/igniter/property_testing/run.rb +43 -0
- data/lib/igniter/property_testing/runner.rb +47 -0
- data/lib/igniter/property_testing.rb +64 -0
- data/lib/igniter/provenance/builder.rb +97 -0
- data/lib/igniter/provenance/lineage.rb +82 -0
- data/lib/igniter/provenance/node_trace.rb +65 -0
- data/lib/igniter/provenance/text_formatter.rb +70 -0
- data/lib/igniter/provenance.rb +29 -0
- data/lib/igniter/registry.rb +67 -0
- data/lib/igniter/runtime/resolver.rb +15 -0
- data/lib/igniter/saga/compensation.rb +31 -0
- data/lib/igniter/saga/compensation_record.rb +20 -0
- data/lib/igniter/saga/executor.rb +85 -0
- data/lib/igniter/saga/formatter.rb +49 -0
- data/lib/igniter/saga/result.rb +47 -0
- data/lib/igniter/saga.rb +56 -0
- data/lib/igniter/stream_loop.rb +80 -0
- data/lib/igniter/supervisor.rb +167 -0
- data/lib/igniter/version.rb +1 -1
- data/lib/igniter.rb +10 -0
- 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
|
@@ -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
|