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.
Files changed (114) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +238 -218
  4. data/docs/DISTRIBUTED_CONTRACTS_V1.md +493 -0
  5. data/docs/LLM_V1.md +335 -0
  6. data/docs/PATTERNS.md +189 -0
  7. data/docs/SERVER_V1.md +313 -0
  8. data/examples/README.md +129 -0
  9. data/examples/agents.rb +150 -0
  10. data/examples/differential.rb +161 -0
  11. data/examples/distributed_server.rb +94 -0
  12. data/examples/distributed_workflow.rb +52 -0
  13. data/examples/effects.rb +184 -0
  14. data/examples/invariants.rb +179 -0
  15. data/examples/order_pipeline.rb +163 -0
  16. data/examples/provenance.rb +122 -0
  17. data/examples/saga.rb +110 -0
  18. data/lib/igniter/agent/mailbox.rb +96 -0
  19. data/lib/igniter/agent/message.rb +21 -0
  20. data/lib/igniter/agent/ref.rb +86 -0
  21. data/lib/igniter/agent/runner.rb +129 -0
  22. data/lib/igniter/agent/state_holder.rb +23 -0
  23. data/lib/igniter/agent.rb +155 -0
  24. data/lib/igniter/compiler/compiled_graph.rb +12 -0
  25. data/lib/igniter/compiler/validation_pipeline.rb +3 -1
  26. data/lib/igniter/compiler/validators/await_validator.rb +53 -0
  27. data/lib/igniter/compiler/validators/callable_validator.rb +21 -3
  28. data/lib/igniter/compiler/validators/dependencies_validator.rb +41 -1
  29. data/lib/igniter/compiler/validators/remote_validator.rb +58 -0
  30. data/lib/igniter/compiler.rb +2 -0
  31. data/lib/igniter/contract.rb +59 -8
  32. data/lib/igniter/differential/divergence.rb +29 -0
  33. data/lib/igniter/differential/formatter.rb +96 -0
  34. data/lib/igniter/differential/report.rb +86 -0
  35. data/lib/igniter/differential/runner.rb +130 -0
  36. data/lib/igniter/differential.rb +51 -0
  37. data/lib/igniter/dsl/contract_builder.rb +74 -4
  38. data/lib/igniter/effect.rb +91 -0
  39. data/lib/igniter/effect_registry.rb +78 -0
  40. data/lib/igniter/errors.rb +17 -2
  41. data/lib/igniter/execution_report/builder.rb +54 -0
  42. data/lib/igniter/execution_report/formatter.rb +50 -0
  43. data/lib/igniter/execution_report/node_entry.rb +24 -0
  44. data/lib/igniter/execution_report/report.rb +65 -0
  45. data/lib/igniter/execution_report.rb +32 -0
  46. data/lib/igniter/extensions/differential.rb +114 -0
  47. data/lib/igniter/extensions/execution_report.rb +27 -0
  48. data/lib/igniter/extensions/invariants.rb +116 -0
  49. data/lib/igniter/extensions/provenance.rb +45 -0
  50. data/lib/igniter/extensions/saga.rb +74 -0
  51. data/lib/igniter/integrations/agents.rb +18 -0
  52. data/lib/igniter/integrations/llm/config.rb +69 -0
  53. data/lib/igniter/integrations/llm/context.rb +74 -0
  54. data/lib/igniter/integrations/llm/executor.rb +159 -0
  55. data/lib/igniter/integrations/llm/providers/anthropic.rb +148 -0
  56. data/lib/igniter/integrations/llm/providers/base.rb +33 -0
  57. data/lib/igniter/integrations/llm/providers/ollama.rb +137 -0
  58. data/lib/igniter/integrations/llm/providers/openai.rb +153 -0
  59. data/lib/igniter/integrations/llm.rb +59 -0
  60. data/lib/igniter/integrations/rails/cable_adapter.rb +49 -0
  61. data/lib/igniter/integrations/rails/contract_job.rb +76 -0
  62. data/lib/igniter/integrations/rails/generators/contract/contract_generator.rb +22 -0
  63. data/lib/igniter/integrations/rails/generators/install/install_generator.rb +33 -0
  64. data/lib/igniter/integrations/rails/railtie.rb +25 -0
  65. data/lib/igniter/integrations/rails/webhook_concern.rb +49 -0
  66. data/lib/igniter/integrations/rails.rb +12 -0
  67. data/lib/igniter/invariant.rb +50 -0
  68. data/lib/igniter/model/await_node.rb +21 -0
  69. data/lib/igniter/model/effect_node.rb +37 -0
  70. data/lib/igniter/model/remote_node.rb +26 -0
  71. data/lib/igniter/model.rb +3 -0
  72. data/lib/igniter/property_testing/formatter.rb +66 -0
  73. data/lib/igniter/property_testing/generators.rb +115 -0
  74. data/lib/igniter/property_testing/result.rb +45 -0
  75. data/lib/igniter/property_testing/run.rb +43 -0
  76. data/lib/igniter/property_testing/runner.rb +47 -0
  77. data/lib/igniter/property_testing.rb +64 -0
  78. data/lib/igniter/provenance/builder.rb +97 -0
  79. data/lib/igniter/provenance/lineage.rb +82 -0
  80. data/lib/igniter/provenance/node_trace.rb +65 -0
  81. data/lib/igniter/provenance/text_formatter.rb +70 -0
  82. data/lib/igniter/provenance.rb +29 -0
  83. data/lib/igniter/registry.rb +67 -0
  84. data/lib/igniter/runtime/execution.rb +2 -2
  85. data/lib/igniter/runtime/input_validator.rb +5 -3
  86. data/lib/igniter/runtime/resolver.rb +58 -1
  87. data/lib/igniter/runtime/stores/active_record_store.rb +13 -1
  88. data/lib/igniter/runtime/stores/file_store.rb +50 -2
  89. data/lib/igniter/runtime/stores/memory_store.rb +55 -2
  90. data/lib/igniter/runtime/stores/redis_store.rb +13 -1
  91. data/lib/igniter/saga/compensation.rb +31 -0
  92. data/lib/igniter/saga/compensation_record.rb +20 -0
  93. data/lib/igniter/saga/executor.rb +85 -0
  94. data/lib/igniter/saga/formatter.rb +49 -0
  95. data/lib/igniter/saga/result.rb +47 -0
  96. data/lib/igniter/saga.rb +56 -0
  97. data/lib/igniter/server/client.rb +123 -0
  98. data/lib/igniter/server/config.rb +27 -0
  99. data/lib/igniter/server/handlers/base.rb +105 -0
  100. data/lib/igniter/server/handlers/contracts_handler.rb +15 -0
  101. data/lib/igniter/server/handlers/event_handler.rb +28 -0
  102. data/lib/igniter/server/handlers/execute_handler.rb +37 -0
  103. data/lib/igniter/server/handlers/health_handler.rb +32 -0
  104. data/lib/igniter/server/handlers/status_handler.rb +27 -0
  105. data/lib/igniter/server/http_server.rb +109 -0
  106. data/lib/igniter/server/rack_app.rb +35 -0
  107. data/lib/igniter/server/registry.rb +56 -0
  108. data/lib/igniter/server/router.rb +75 -0
  109. data/lib/igniter/server.rb +67 -0
  110. data/lib/igniter/stream_loop.rb +80 -0
  111. data/lib/igniter/supervisor.rb +167 -0
  112. data/lib/igniter/version.rb +1 -1
  113. data/lib/igniter.rb +14 -0
  114. 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