igniter 0.3.1 → 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/DISTRIBUTED_CONTRACTS_V1.md +493 -0
- 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/distributed_workflow.rb +52 -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/compiled_graph.rb +12 -0
- data/lib/igniter/compiler/validation_pipeline.rb +3 -1
- data/lib/igniter/compiler/validators/await_validator.rb +53 -0
- data/lib/igniter/compiler/validators/callable_validator.rb +21 -3
- data/lib/igniter/compiler/validators/dependencies_validator.rb +41 -1
- data/lib/igniter/compiler/validators/remote_validator.rb +58 -0
- data/lib/igniter/compiler.rb +2 -0
- data/lib/igniter/contract.rb +59 -8
- 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 +74 -4
- data/lib/igniter/effect.rb +91 -0
- data/lib/igniter/effect_registry.rb +78 -0
- data/lib/igniter/errors.rb +17 -2
- 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/integrations/llm/config.rb +69 -0
- data/lib/igniter/integrations/llm/context.rb +74 -0
- data/lib/igniter/integrations/llm/executor.rb +159 -0
- data/lib/igniter/integrations/llm/providers/anthropic.rb +148 -0
- data/lib/igniter/integrations/llm/providers/base.rb +33 -0
- data/lib/igniter/integrations/llm/providers/ollama.rb +137 -0
- data/lib/igniter/integrations/llm/providers/openai.rb +153 -0
- data/lib/igniter/integrations/llm.rb +59 -0
- data/lib/igniter/integrations/rails/cable_adapter.rb +49 -0
- data/lib/igniter/integrations/rails/contract_job.rb +76 -0
- data/lib/igniter/integrations/rails/generators/contract/contract_generator.rb +22 -0
- data/lib/igniter/integrations/rails/generators/install/install_generator.rb +33 -0
- data/lib/igniter/integrations/rails/railtie.rb +25 -0
- data/lib/igniter/integrations/rails/webhook_concern.rb +49 -0
- data/lib/igniter/integrations/rails.rb +12 -0
- data/lib/igniter/invariant.rb +50 -0
- data/lib/igniter/model/await_node.rb +21 -0
- data/lib/igniter/model/effect_node.rb +37 -0
- data/lib/igniter/model/remote_node.rb +26 -0
- data/lib/igniter/model.rb +3 -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/execution.rb +2 -2
- data/lib/igniter/runtime/input_validator.rb +5 -3
- data/lib/igniter/runtime/resolver.rb +58 -1
- data/lib/igniter/runtime/stores/active_record_store.rb +13 -1
- data/lib/igniter/runtime/stores/file_store.rb +50 -2
- data/lib/igniter/runtime/stores/memory_store.rb +55 -2
- data/lib/igniter/runtime/stores/redis_store.rb +13 -1
- 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/server/client.rb +123 -0
- data/lib/igniter/server/config.rb +27 -0
- data/lib/igniter/server/handlers/base.rb +105 -0
- data/lib/igniter/server/handlers/contracts_handler.rb +15 -0
- data/lib/igniter/server/handlers/event_handler.rb +28 -0
- data/lib/igniter/server/handlers/execute_handler.rb +37 -0
- data/lib/igniter/server/handlers/health_handler.rb +32 -0
- data/lib/igniter/server/handlers/status_handler.rb +27 -0
- data/lib/igniter/server/http_server.rb +109 -0
- data/lib/igniter/server/rack_app.rb +35 -0
- data/lib/igniter/server/registry.rb +56 -0
- data/lib/igniter/server/router.rb +75 -0
- data/lib/igniter/server.rb +67 -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 +14 -0
- metadata +92 -2
|
@@ -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
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "igniter"
|
|
4
|
+
require "igniter/extensions/invariants"
|
|
5
|
+
require "igniter/property_testing/generators"
|
|
6
|
+
require "igniter/property_testing/run"
|
|
7
|
+
require "igniter/property_testing/result"
|
|
8
|
+
require "igniter/property_testing/formatter"
|
|
9
|
+
require "igniter/property_testing/runner"
|
|
10
|
+
|
|
11
|
+
module Igniter
|
|
12
|
+
# Property-based testing for Igniter contracts.
|
|
13
|
+
#
|
|
14
|
+
# Generates hundreds of random inputs, runs the contract with each set,
|
|
15
|
+
# and verifies that all declared invariants hold. Violations are collected
|
|
16
|
+
# as data so you can inspect the first counterexample.
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
19
|
+
# require "igniter/property_testing"
|
|
20
|
+
#
|
|
21
|
+
# G = Igniter::PropertyTesting::Generators
|
|
22
|
+
#
|
|
23
|
+
# class PricingContract < Igniter::Contract
|
|
24
|
+
# define do
|
|
25
|
+
# input :price
|
|
26
|
+
# input :quantity
|
|
27
|
+
# compute :total, depends_on: %i[price quantity] do |price:, quantity:|
|
|
28
|
+
# price * quantity
|
|
29
|
+
# end
|
|
30
|
+
# output :total
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# invariant(:total_non_negative) { |total:, **| total >= 0 }
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# result = PricingContract.property_test(
|
|
37
|
+
# generators: { price: G.float(0.0..500.0), quantity: G.positive_integer(max: 100) },
|
|
38
|
+
# runs: 200,
|
|
39
|
+
# seed: 42
|
|
40
|
+
# )
|
|
41
|
+
#
|
|
42
|
+
# puts result.explain
|
|
43
|
+
# puts result.counterexample&.inputs
|
|
44
|
+
#
|
|
45
|
+
module PropertyTesting
|
|
46
|
+
# Class methods added to every Igniter::Contract subclass.
|
|
47
|
+
module ClassMethods
|
|
48
|
+
# Run the contract against randomly generated inputs and verify invariants.
|
|
49
|
+
#
|
|
50
|
+
# Requires at least one `invariant` to be declared on the contract class,
|
|
51
|
+
# though it will still execute and collect execution errors without any.
|
|
52
|
+
#
|
|
53
|
+
# @param generators [Hash{Symbol => #call}] input name → generator callable
|
|
54
|
+
# @param runs [Integer] number of test runs (default: 100)
|
|
55
|
+
# @param seed [Integer, nil] optional RNG seed for reproducibility
|
|
56
|
+
# @return [Igniter::PropertyTesting::Result]
|
|
57
|
+
def property_test(generators:, runs: 100, seed: nil)
|
|
58
|
+
Runner.new(self, generators: generators, runs: runs, seed: seed).run
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
Igniter::Contract.extend(Igniter::PropertyTesting::ClassMethods)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Provenance
|
|
5
|
+
# Builds a Lineage object for a named output by traversing the compiled
|
|
6
|
+
# graph and reading resolved values from the execution cache.
|
|
7
|
+
#
|
|
8
|
+
# The builder memoises each NodeTrace so that shared dependencies
|
|
9
|
+
# (diamond patterns) point to the same object rather than being duplicated.
|
|
10
|
+
class Builder
|
|
11
|
+
class << self
|
|
12
|
+
# Build lineage for +output_name+ from a resolved +execution+.
|
|
13
|
+
def build(output_name, execution)
|
|
14
|
+
new(execution).build(output_name)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(execution)
|
|
19
|
+
@graph = execution.compiled_graph
|
|
20
|
+
@cache = execution.cache
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def build(output_name) # rubocop:disable Metrics/MethodLength
|
|
24
|
+
sym = output_name.to_sym
|
|
25
|
+
|
|
26
|
+
raise ProvenanceError, "No output named '#{sym}' in #{@graph.name}" unless @graph.output?(sym)
|
|
27
|
+
|
|
28
|
+
output_node = @graph.fetch_output(sym)
|
|
29
|
+
source_name = output_node.source_root
|
|
30
|
+
|
|
31
|
+
source_node = begin
|
|
32
|
+
@graph.fetch_node(source_name)
|
|
33
|
+
rescue KeyError
|
|
34
|
+
raise ProvenanceError, "Source node '#{source_name}' for output '#{sym}' not found in graph"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
trace = build_trace(source_node, {})
|
|
38
|
+
Lineage.new(trace)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# Recursively build a NodeTrace for +node+.
|
|
44
|
+
# +memo+ prevents re-processing the same node in diamond-dependency graphs.
|
|
45
|
+
def build_trace(node, memo) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
46
|
+
return memo[node.name] if memo.key?(node.name)
|
|
47
|
+
|
|
48
|
+
# Reserve the slot to handle (unlikely) circular edge during traversal
|
|
49
|
+
memo[node.name] = nil
|
|
50
|
+
|
|
51
|
+
state = @cache.fetch(node.name)
|
|
52
|
+
value = extract_value(state)
|
|
53
|
+
|
|
54
|
+
contributing = {}
|
|
55
|
+
node.dependencies.each do |dep_name|
|
|
56
|
+
dep_node = safe_fetch_node(dep_name)
|
|
57
|
+
next unless dep_node
|
|
58
|
+
|
|
59
|
+
contributing[dep_name] = build_trace(dep_node, memo)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
trace = NodeTrace.new(
|
|
63
|
+
name: node.name,
|
|
64
|
+
kind: node.kind,
|
|
65
|
+
value: value,
|
|
66
|
+
contributing: contributing
|
|
67
|
+
)
|
|
68
|
+
memo[node.name] = trace
|
|
69
|
+
trace
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def safe_fetch_node(name)
|
|
73
|
+
@graph.fetch_node(name)
|
|
74
|
+
rescue KeyError
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Extract a display-friendly value from a NodeState.
|
|
79
|
+
# Composition/Collection results are summarised as hashes.
|
|
80
|
+
def extract_value(state) # rubocop:disable Metrics/MethodLength
|
|
81
|
+
return nil unless state
|
|
82
|
+
|
|
83
|
+
val = state.value
|
|
84
|
+
case val
|
|
85
|
+
when Runtime::Result
|
|
86
|
+
val.to_h
|
|
87
|
+
when Runtime::CollectionResult
|
|
88
|
+
val.summary
|
|
89
|
+
when Runtime::DeferredResult
|
|
90
|
+
{ pending: true, event: val.waiting_on }
|
|
91
|
+
else
|
|
92
|
+
val
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Provenance
|
|
5
|
+
# Lineage captures the full provenance of a single contract output.
|
|
6
|
+
#
|
|
7
|
+
# It wraps the NodeTrace tree rooted at the node that produces the output
|
|
8
|
+
# and exposes query methods for understanding what inputs shaped the result.
|
|
9
|
+
#
|
|
10
|
+
# Usage (after `require "igniter/extensions/provenance"`):
|
|
11
|
+
#
|
|
12
|
+
# contract.resolve_all
|
|
13
|
+
# lin = contract.lineage(:grand_total)
|
|
14
|
+
#
|
|
15
|
+
# lin.value # => 229.95
|
|
16
|
+
# lin.contributing_inputs # => { base_price: 100.0, quantity: 2, ... }
|
|
17
|
+
# lin.sensitive_to?(:base_price) # => true
|
|
18
|
+
# lin.sensitive_to?(:user_name) # => false
|
|
19
|
+
# lin.path_to(:base_price) # => [:grand_total, :subtotal, :unit_price, :base_price]
|
|
20
|
+
# puts lin # prints ASCII tree
|
|
21
|
+
#
|
|
22
|
+
class Lineage
|
|
23
|
+
# The NodeTrace rooted at the output's source computation node.
|
|
24
|
+
attr_reader :trace
|
|
25
|
+
|
|
26
|
+
def initialize(trace)
|
|
27
|
+
@trace = trace
|
|
28
|
+
freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The output name (same as the root trace node name).
|
|
32
|
+
def output_name
|
|
33
|
+
trace.name
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The resolved output value.
|
|
37
|
+
def value
|
|
38
|
+
trace.value
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# All :input nodes that transitively contributed to this output.
|
|
42
|
+
# Returns Hash{ Symbol => value }.
|
|
43
|
+
def contributing_inputs
|
|
44
|
+
trace.contributing_inputs
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Does this output's value depend (transitively) on the given input?
|
|
48
|
+
def sensitive_to?(input_name)
|
|
49
|
+
trace.sensitive_to?(input_name)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Ordered path of node names from the output down to the given input.
|
|
53
|
+
# Returns nil if the input does not contribute to this output.
|
|
54
|
+
def path_to(input_name)
|
|
55
|
+
trace.path_to(input_name)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Human-readable ASCII tree explaining how this output was derived.
|
|
59
|
+
def explain
|
|
60
|
+
TextFormatter.format(trace)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
alias to_s explain
|
|
64
|
+
|
|
65
|
+
# Structured (serialisable) representation of the full trace tree.
|
|
66
|
+
def to_h
|
|
67
|
+
serialize_trace(trace)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def serialize_trace(trc)
|
|
73
|
+
{
|
|
74
|
+
node: trc.name,
|
|
75
|
+
kind: trc.kind,
|
|
76
|
+
value: trc.value,
|
|
77
|
+
contributing: trc.contributing.transform_values { |dep| serialize_trace(dep) }
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Provenance
|
|
5
|
+
# Immutable snapshot of a single resolved node and its full dependency chain.
|
|
6
|
+
#
|
|
7
|
+
# The tree structure mirrors the contract's dependency graph: each NodeTrace
|
|
8
|
+
# holds the traced values of all the nodes that contributed to its result.
|
|
9
|
+
# Input nodes are leaves (no contributing dependencies).
|
|
10
|
+
#
|
|
11
|
+
# The tree may share nodes (diamond dependencies in the original graph) but
|
|
12
|
+
# each shared node is memoised once by the Builder, so the same NodeTrace
|
|
13
|
+
# object is referenced from multiple parents — it is NOT duplicated.
|
|
14
|
+
class NodeTrace
|
|
15
|
+
attr_reader :name, :kind, :value, :contributing
|
|
16
|
+
|
|
17
|
+
# contributing: Hash{ Symbol => NodeTrace } — may be empty for leaf inputs
|
|
18
|
+
def initialize(name:, kind:, value:, contributing: {})
|
|
19
|
+
@name = name.to_sym
|
|
20
|
+
@kind = kind.to_sym
|
|
21
|
+
@value = value
|
|
22
|
+
@contributing = contributing.freeze
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def input?
|
|
27
|
+
kind == :input
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# True when this node has no dependencies that contributed to its value.
|
|
31
|
+
def leaf?
|
|
32
|
+
contributing.empty?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Recursively collect all :input nodes that influenced this trace.
|
|
36
|
+
# Returns Hash{ Symbol => value }.
|
|
37
|
+
def contributing_inputs
|
|
38
|
+
return { name => value } if input?
|
|
39
|
+
|
|
40
|
+
contributing.each_value.with_object({}) do |dep, acc|
|
|
41
|
+
acc.merge!(dep.contributing_inputs)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Does this trace transitively depend on the named input?
|
|
46
|
+
def sensitive_to?(input_name)
|
|
47
|
+
contributing_inputs.key?(input_name.to_sym)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Return the ordered path of node names from this node down to the given
|
|
51
|
+
# input, or nil if the input does not contribute to this node.
|
|
52
|
+
def path_to(input_name)
|
|
53
|
+
target = input_name.to_sym
|
|
54
|
+
return [name] if name == target
|
|
55
|
+
|
|
56
|
+
contributing.each_value do |dep|
|
|
57
|
+
sub_path = dep.path_to(target)
|
|
58
|
+
return [name] + sub_path if sub_path
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Provenance
|
|
5
|
+
# Renders a NodeTrace tree as a human-readable ASCII tree.
|
|
6
|
+
#
|
|
7
|
+
# Example output:
|
|
8
|
+
#
|
|
9
|
+
# grand_total = 229.95 [compute]
|
|
10
|
+
# ├─ subtotal = 199.96 [compute]
|
|
11
|
+
# │ ├─ unit_price = 99.98 [compute]
|
|
12
|
+
# │ │ └─ base_price = 100.0 [input]
|
|
13
|
+
# │ └─ quantity = 2 [input]
|
|
14
|
+
# └─ shipping_cost = 29.99 [compute]
|
|
15
|
+
# └─ destination = "US" [input]
|
|
16
|
+
#
|
|
17
|
+
module TextFormatter
|
|
18
|
+
VALUE_MAX_LENGTH = 60
|
|
19
|
+
|
|
20
|
+
def self.format(trace)
|
|
21
|
+
lines = []
|
|
22
|
+
render(trace, lines, prefix: "", is_root: true, is_last: true)
|
|
23
|
+
lines.join("\n")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# ── private helpers ──────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
def self.render(trace, lines, prefix:, is_root:, is_last:) # rubocop:disable Metrics/MethodLength
|
|
29
|
+
if is_root
|
|
30
|
+
connector = ""
|
|
31
|
+
child_pad = ""
|
|
32
|
+
elsif is_last
|
|
33
|
+
connector = "└─ "
|
|
34
|
+
child_pad = " "
|
|
35
|
+
else
|
|
36
|
+
connector = "├─ "
|
|
37
|
+
child_pad = "│ "
|
|
38
|
+
end
|
|
39
|
+
child_prefix = prefix + child_pad
|
|
40
|
+
|
|
41
|
+
lines << "#{prefix}#{connector}#{trace.name} = #{format_value(trace.value)} [#{trace.kind}]"
|
|
42
|
+
|
|
43
|
+
deps = trace.contributing.values
|
|
44
|
+
deps.each_with_index do |dep, idx|
|
|
45
|
+
render(dep, lines,
|
|
46
|
+
prefix: child_prefix,
|
|
47
|
+
is_root: false,
|
|
48
|
+
is_last: idx == deps.size - 1)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
private_class_method :render
|
|
52
|
+
|
|
53
|
+
def self.format_value(value) # rubocop:disable Metrics/CyclomaticComplexity
|
|
54
|
+
str = case value
|
|
55
|
+
when nil then "nil"
|
|
56
|
+
when String then value.inspect
|
|
57
|
+
when Symbol then value.inspect
|
|
58
|
+
when Hash then "{#{value.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")}}"
|
|
59
|
+
when Array then "[#{value.map(&:inspect).join(", ")}]"
|
|
60
|
+
else value.inspect
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
return str if str.length <= VALUE_MAX_LENGTH
|
|
64
|
+
|
|
65
|
+
"#{str[0, VALUE_MAX_LENGTH - 3]}..."
|
|
66
|
+
end
|
|
67
|
+
private_class_method :format_value
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|