igniter 0.4.3 → 0.4.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5634161e393aef6bad7be0b3584d7376e933c70b9ba81e6a2055adede34a05b1
4
- data.tar.gz: 5d136cf57c9c115d5d76ea4f84d011f4d6f383989907b60475aadcef8be439d9
3
+ metadata.gz: 71035dd880de5a8a81bcc14cd0bf1097dbbb03ec9c2a915e36ae821bb3e25d70
4
+ data.tar.gz: a6bb66b9f865230e09c21e5db140ecf3b6705a934b2a5172f3dd2d6198dd5371
5
5
  SHA512:
6
- metadata.gz: fb93db3831bceffc0d6e9fc83a3e1ecff6e105356c168b9428a694049a8a4bd857aa9f3378580885ec9a8007c55d0896d8d8cb5ea00c149829262a9eec8c1244
7
- data.tar.gz: bbfbd87106e660d958ce5b3b92b82e3394a9e7771686f62926cedc397b4ffb73fbabb9fbab3ea491a51fa0984d330b61d71c1042c3c874ef0738519e2fd89cc1
6
+ metadata.gz: 0d59ee3a5cab0e01e5f0bdae733eb991296be4ff0ef107b305e1795da1c0de2629a629dbeef18326ca606e142376542eb919470cd66ac1baee7db4210eeccd4c
7
+ data.tar.gz: 79d4cabcebcfa3b93cb9fd409c1f4173001125f59dde18bb79a21a8021f45877613de5023de0caf6a0400418bfe71541a61ec8a42125d9aab526ddc23be3c211
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ # examples/incremental.rb
4
+ #
5
+ # Demonstrates Igniter's incremental computation: only the minimal set of
6
+ # nodes is re-executed when inputs change.
7
+ #
8
+ # Inspired by Salsa (Rust, rust-analyzer) and Adapton.
9
+ #
10
+ # Run with: bundle exec ruby examples/incremental.rb
11
+
12
+ require_relative "../lib/igniter"
13
+ require_relative "../lib/igniter/extensions/incremental"
14
+
15
+ # ─── Contract ──────────────────────────────────────────────────────────────
16
+ #
17
+ # base_price ──┐
18
+ # ├→ adjusted_price ──┐
19
+ # user_tier ──→ tier_discount ──┘ ├→ converted_price
20
+ # │
21
+ # exchange_rate ────────────────────┘
22
+ #
23
+ # When only exchange_rate changes:
24
+ # ✓ tier_discount → SKIPPED (doesn't depend on exchange_rate)
25
+ # ✓ adjusted_price → SKIPPED (doesn't depend on exchange_rate)
26
+ # ✓ converted_price → RECOMPUTED (directly depends on exchange_rate)
27
+ #
28
+ class PricingContract < Igniter::Contract
29
+ define do
30
+ input :base_price
31
+ input :user_tier
32
+ input :exchange_rate
33
+
34
+ compute :tier_discount, depends_on: :user_tier, call: lambda { |user_tier:|
35
+ case user_tier
36
+ when "gold" then 0.20
37
+ when "silver" then 0.10
38
+ else 0.0
39
+ end
40
+ }
41
+
42
+ compute :adjusted_price, depends_on: %i[base_price tier_discount], call: lambda { |base_price:, tier_discount:|
43
+ base_price * (1.0 - tier_discount)
44
+ }
45
+
46
+ compute :converted_price, depends_on: %i[adjusted_price exchange_rate], call: lambda { |adjusted_price:, exchange_rate:|
47
+ (adjusted_price * exchange_rate).round(2)
48
+ }
49
+
50
+ output :tier_discount
51
+ output :adjusted_price
52
+ output :converted_price
53
+ end
54
+ end
55
+
56
+ # ─── Helper to show node value_versions ────────────────────────────────────
57
+ def show_versions(contract, label)
58
+ puts "\n#{label}"
59
+ puts "-" * 50
60
+ %i[base_price user_tier exchange_rate tier_discount adjusted_price converted_price].each do |name|
61
+ node_name = name
62
+ state = contract.execution.cache.fetch(node_name)
63
+ next unless state
64
+
65
+ status = state.status
66
+ vv = state.value_version
67
+ val = state.value
68
+ mark = case status
69
+ when :stale then "~"
70
+ when :succeeded then "✓"
71
+ else " "
72
+ end
73
+ puts " #{mark} :#{name.to_s.ljust(20)} val=#{val.inspect.ljust(10)} vv=#{vv}"
74
+ end
75
+ end
76
+
77
+ # ─── Run ───────────────────────────────────────────────────────────────────
78
+ puts "=" * 60
79
+ puts "Igniter Incremental Computation Demo"
80
+ puts "=" * 60
81
+
82
+ contract = PricingContract.new(
83
+ base_price: 100.0,
84
+ user_tier: "gold",
85
+ exchange_rate: 1.0
86
+ )
87
+
88
+ contract.resolve_all
89
+ show_versions(contract, "After initial resolve_all")
90
+
91
+ puts "\n" + "=" * 60
92
+ puts "Scenario 1: exchange_rate changes 1.0 → 1.12"
93
+ puts "(tier_discount and adjusted_price should be SKIPPED)"
94
+ puts "=" * 60
95
+
96
+ result = contract.resolve_incrementally(exchange_rate: 1.12)
97
+
98
+ show_versions(contract, "After resolve_incrementally(exchange_rate: 1.12)")
99
+ puts "\nIncrementalResult:"
100
+ puts result.explain
101
+
102
+ puts "\nKey assertions:"
103
+ puts " Skipped nodes: #{result.skipped_nodes.inspect}"
104
+ puts " Backdated: #{result.backdated_nodes.inspect}"
105
+ puts " Changed: #{result.changed_nodes.inspect}"
106
+ puts " Recomputed: #{result.recomputed_count}"
107
+ puts " Changed output: converted_price #{result.changed_outputs[:converted_price]&.values_at(:from, :to)&.join(" → ")}"
108
+
109
+ puts "\n" + "=" * 60
110
+ puts "Scenario 2: user_tier changes gold → silver"
111
+ puts "(tier_discount, adjusted_price, converted_price all recompute)"
112
+ puts "=" * 60
113
+
114
+ result2 = contract.resolve_incrementally(user_tier: "silver")
115
+ show_versions(contract, "After resolve_incrementally(user_tier: 'silver')")
116
+ puts "\nIncrementalResult:"
117
+ puts result2.explain
118
+
119
+ puts "\n" + "=" * 60
120
+ puts "Scenario 3: same exchange_rate again (no change)"
121
+ puts "(fully memoized — nothing recomputes)"
122
+ puts "=" * 60
123
+
124
+ result3 = contract.resolve_incrementally(exchange_rate: 1.12)
125
+ puts "\nIncrementalResult:"
126
+ puts result3.explain
127
+ puts "\nfully_memoized? #{result3.fully_memoized?}"
128
+ puts "outputs_changed? #{result3.outputs_changed?}"
129
+
130
+ puts "\n" + "=" * 60
131
+ puts "Scenario 4: base_price changes 100 → 100 (same value)"
132
+ puts "(demonstrates value-equality backdating in adjusted_price)")
133
+ puts "=" * 60
134
+
135
+ contract2 = PricingContract.new(base_price: 100.0, user_tier: "gold", exchange_rate: 1.0)
136
+ contract2.resolve_all
137
+
138
+ # Now change base_price to the same value — cache.write sees same value → value_version stays
139
+ result4 = contract2.resolve_incrementally(base_price: 100.0)
140
+ puts "\nIncrementalResult:"
141
+ puts result4.explain
142
+ puts "outputs_changed? #{result4.outputs_changed?}"
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "igniter"
4
+ require "igniter/incremental"
5
+
6
+ module Igniter
7
+ module Extensions
8
+ # Patches Igniter::Contract with:
9
+ # - Instance method: resolve_incrementally(new_inputs = {}) → Incremental::Result
10
+ #
11
+ # The method updates inputs, re-resolves, and returns a structured report
12
+ # of which nodes changed, were memoized, or were backdated.
13
+ #
14
+ # Applied globally via:
15
+ # Igniter::Contract.include(Igniter::Extensions::Incremental)
16
+ #
17
+ module Incremental
18
+ def self.included(base)
19
+ base.include(InstanceMethods)
20
+ end
21
+
22
+ module InstanceMethods
23
+ # Update inputs and re-resolve, returning a detailed incremental result.
24
+ #
25
+ # If called without arguments on an already-resolved contract, it
26
+ # re-resolves with current inputs (useful for observing memoization).
27
+ #
28
+ # @param new_inputs [Hash] input values to update before re-resolving
29
+ # @return [Igniter::Incremental::Result]
30
+ def resolve_incrementally(new_inputs = {})
31
+ unless execution.cache.values.any?
32
+ raise Igniter::Incremental::IncrementalError,
33
+ "Contract has not been executed yet — call resolve_all first, " \
34
+ "then resolve_incrementally to get incremental results"
35
+ end
36
+
37
+ tracker = Igniter::Incremental::Tracker.new(execution)
38
+ tracker.start!
39
+
40
+ update_inputs(new_inputs) if new_inputs.any?
41
+ resolve_all
42
+
43
+ tracker.build_result
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ Igniter::Contract.include(Igniter::Extensions::Incremental)
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Incremental
5
+ # Renders an Incremental::Result as a human-readable text block.
6
+ #
7
+ # Example output:
8
+ #
9
+ # Incremental Execution Report
10
+ # ─────────────────────────────────────────
11
+ # Recomputed: 1 node(s)
12
+ # Skipped: 2 node(s) (deps unchanged)
13
+ # Backdated: 0 node(s) (value unchanged)
14
+ #
15
+ # CHANGED OUTPUTS (1):
16
+ # :converted_price 1.05 → 1.12
17
+ #
18
+ # SKIPPED NODES (memoized):
19
+ # :tier_discount :adjusted_price
20
+ #
21
+ module Formatter
22
+ VALUE_MAX = 60
23
+ LINE = "─" * 42
24
+
25
+ class << self
26
+ def format(result) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
27
+ lines = []
28
+ lines << "Incremental Execution Report"
29
+ lines << LINE
30
+ lines << "Recomputed: #{result.recomputed_count} node(s)"
31
+ lines << "Skipped: #{result.skipped_nodes.size} node(s) (deps unchanged)"
32
+ lines << "Backdated: #{result.backdated_nodes.size} node(s) (value unchanged)"
33
+ lines << ""
34
+
35
+ if result.changed_outputs.any?
36
+ lines << "CHANGED OUTPUTS (#{result.changed_outputs.size}):"
37
+ result.changed_outputs.each do |name, diff|
38
+ lines << " :#{name} #{fmt(diff[:from])} → #{fmt(diff[:to])}"
39
+ end
40
+ else
41
+ lines << "No output values changed."
42
+ end
43
+ lines << ""
44
+
45
+ if result.skipped_nodes.any?
46
+ lines << "SKIPPED (memoized, #{result.skipped_nodes.size}):"
47
+ lines << " #{result.skipped_nodes.map { |n| ":#{n}" }.join(" ")}"
48
+ lines << ""
49
+ end
50
+
51
+ if result.backdated_nodes.any?
52
+ lines << "BACKDATED (recomputed → same value, #{result.backdated_nodes.size}):"
53
+ lines << " #{result.backdated_nodes.map { |n| ":#{n}" }.join(" ")}"
54
+ lines << ""
55
+ end
56
+
57
+ if result.changed_nodes.any?
58
+ lines << "CHANGED (#{result.changed_nodes.size}):"
59
+ lines << " #{result.changed_nodes.map { |n| ":#{n}" }.join(" ")}"
60
+ end
61
+
62
+ lines.join("\n")
63
+ end
64
+
65
+ private
66
+
67
+ def fmt(value) # rubocop:disable Metrics/CyclomaticComplexity
68
+ str = case value
69
+ when nil then "nil"
70
+ when String then value.inspect
71
+ when Symbol then value.inspect
72
+ when Hash then "{#{value.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")}}"
73
+ when Array then "[#{value.map(&:inspect).join(", ")}]"
74
+ else value.inspect
75
+ end
76
+ str.length > VALUE_MAX ? "#{str[0, VALUE_MAX - 3]}..." : str
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Incremental
5
+ # Structured result of a resolve_incrementally call.
6
+ #
7
+ # Attributes:
8
+ # changed_nodes — node names whose value_version increased (value actually changed)
9
+ # skipped_nodes — node names that were stale but memoized (deps unchanged, compute skipped)
10
+ # backdated_nodes — node names that recomputed but produced the same value
11
+ # changed_outputs — Hash{ Symbol => { from: old_value, to: new_value } }
12
+ # recomputed_count — total number of compute calls actually executed
13
+ class Result
14
+ attr_reader :changed_nodes, :skipped_nodes, :backdated_nodes,
15
+ :changed_outputs, :recomputed_count
16
+
17
+ def initialize(changed_nodes:, skipped_nodes:, backdated_nodes:,
18
+ changed_outputs:, recomputed_count:)
19
+ @changed_nodes = changed_nodes.freeze
20
+ @skipped_nodes = skipped_nodes.freeze
21
+ @backdated_nodes = backdated_nodes.freeze
22
+ @changed_outputs = changed_outputs.freeze
23
+ @recomputed_count = recomputed_count
24
+ freeze
25
+ end
26
+
27
+ # True when at least one output value changed.
28
+ def outputs_changed?
29
+ changed_outputs.any?
30
+ end
31
+
32
+ # True when every stale node was memoized — nothing actually ran.
33
+ def fully_memoized?
34
+ recomputed_count.zero?
35
+ end
36
+
37
+ # One-line summary for logging.
38
+ def summary # rubocop:disable Metrics/AbcSize
39
+ parts = []
40
+ parts << "#{changed_nodes.size} node(s) changed" if changed_nodes.any?
41
+ parts << "#{skipped_nodes.size} skipped (memoized)" if skipped_nodes.any?
42
+ parts << "#{backdated_nodes.size} backdated (same value)" if backdated_nodes.any?
43
+ parts << "#{recomputed_count} recomputed"
44
+ parts.join(", ")
45
+ end
46
+
47
+ # Human-readable ASCII report.
48
+ def explain
49
+ Formatter.format(self)
50
+ end
51
+
52
+ alias to_s explain
53
+
54
+ def to_h # rubocop:disable Metrics/MethodLength
55
+ {
56
+ changed_nodes: changed_nodes,
57
+ skipped_nodes: skipped_nodes,
58
+ backdated_nodes: backdated_nodes,
59
+ changed_outputs: changed_outputs.transform_values do |diff|
60
+ { from: diff[:from], to: diff[:to] }
61
+ end,
62
+ recomputed_count: recomputed_count,
63
+ outputs_changed: outputs_changed?,
64
+ fully_memoized: fully_memoized?
65
+ }
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Incremental
5
+ # Subscribes to execution events and records which nodes were changed,
6
+ # skipped (memoized), or backdated during an incremental resolve pass.
7
+ #
8
+ # Takes a pre-snapshot of value_versions before the resolve pass and compares
9
+ # after to detect what changed, rather than relying on event payloads alone.
10
+ class Tracker
11
+ def initialize(execution)
12
+ @execution = execution
13
+ @skipped_nodes = []
14
+ @backdated_nodes = []
15
+ @recomputed_nodes = []
16
+ @pre_node_vv = {}
17
+ @pre_output_values = {}
18
+ end
19
+
20
+ def start!
21
+ snapshot_pre_state!
22
+ @execution.events.subscribe(self)
23
+ end
24
+
25
+ # Called by Events::Bus for every event.
26
+ def call(event)
27
+ case event.type
28
+ when :node_skipped
29
+ @skipped_nodes << event.node_name
30
+ when :node_backdated
31
+ @backdated_nodes << event.node_name
32
+ when :node_succeeded
33
+ kind = fetch_node_kind(event.node_name)
34
+ @recomputed_nodes << event.node_name if %i[compute effect].include?(kind)
35
+ end
36
+ end
37
+
38
+ def build_result # rubocop:disable Metrics/MethodLength
39
+ # Deduplicate (events may fire multiple times across execution passes)
40
+ skipped = @skipped_nodes.uniq
41
+ backdated = @backdated_nodes.uniq
42
+ # recomputed = node_succeeded minus skipped (skipped also fires node_succeeded)
43
+ recomputed = @recomputed_nodes.uniq - skipped
44
+
45
+ changed = detect_changed_nodes
46
+ changed_outputs = detect_changed_outputs
47
+
48
+ Result.new(
49
+ changed_nodes: changed,
50
+ skipped_nodes: skipped,
51
+ backdated_nodes: backdated,
52
+ changed_outputs: changed_outputs,
53
+ recomputed_count: recomputed.size
54
+ )
55
+ end
56
+
57
+ private
58
+
59
+ def snapshot_pre_state! # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
60
+ @execution.compiled_graph.nodes.each do |node|
61
+ state = @execution.cache.fetch(node.name)
62
+ @pre_node_vv[node.name] = state&.value_version
63
+ end
64
+
65
+ @execution.compiled_graph.outputs.each do |output_node|
66
+ src_state = @execution.cache.fetch(output_node.source_root)
67
+ @pre_output_values[output_node.name] = {
68
+ value: src_state&.value,
69
+ value_version: src_state&.value_version
70
+ }
71
+ end
72
+ end
73
+
74
+ def detect_changed_nodes
75
+ @execution.compiled_graph.nodes.each_with_object([]) do |node, memo|
76
+ pre_vv = @pre_node_vv[node.name]
77
+ current_vv = @execution.cache.fetch(node.name)&.value_version
78
+ # Changed = value_version advanced AND it's a compute/effect node
79
+ next unless %i[compute effect].include?(node.kind)
80
+ next unless current_vv && current_vv != pre_vv
81
+
82
+ memo << node.name
83
+ end
84
+ end
85
+
86
+ def detect_changed_outputs
87
+ @execution.compiled_graph.outputs.each_with_object({}) do |output_node, memo|
88
+ pre = @pre_output_values[output_node.name]
89
+ next unless pre
90
+
91
+ src_state = @execution.cache.fetch(output_node.source_root)
92
+ current_vv = src_state&.value_version
93
+
94
+ next if pre[:value_version] == current_vv
95
+
96
+ memo[output_node.name] = { from: pre[:value], to: src_state&.value }
97
+ end
98
+ end
99
+
100
+ def fetch_node_kind(node_name)
101
+ return nil unless node_name
102
+ return nil unless @execution.compiled_graph.node?(node_name)
103
+
104
+ @execution.compiled_graph.fetch_node(node_name).kind
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "igniter"
4
+ require "igniter/incremental/result"
5
+ require "igniter/incremental/tracker"
6
+ require "igniter/incremental/formatter"
7
+
8
+ module Igniter
9
+ # Incremental computation for Igniter contracts.
10
+ #
11
+ # Implements the Salsa/Adapton incremental model:
12
+ # - Each compute node tracks a dep_snapshot: the value_versions of its
13
+ # dependencies at last compute time.
14
+ # - On re-resolution of a stale node, if all dep value_versions are
15
+ # unchanged, the compute is skipped entirely (memoization).
16
+ # - If a node recomputes but produces the same output value, its own
17
+ # value_version is not incremented (backdating), preventing unnecessary
18
+ # downstream recomputation.
19
+ #
20
+ # These optimizations are built into the core runtime (NodeState, Cache,
21
+ # Resolver) and are always active. This module adds the reporting API:
22
+ # - contract.resolve_incrementally → Incremental::Result
23
+ #
24
+ # Usage:
25
+ # require "igniter/extensions/incremental"
26
+ #
27
+ # class PricingContract < Igniter::Contract
28
+ # define do
29
+ # input :base_price
30
+ # input :user_tier
31
+ # input :exchange_rate
32
+ # compute :tier_discount, depends_on: :user_tier, call: -> (user_tier:) { ... }
33
+ # compute :adjusted_price, depends_on: %i[base_price tier_discount], call: -> (**) { ... }
34
+ # compute :converted_price, depends_on: %i[adjusted_price exchange_rate], call: -> (**) { ... }
35
+ # output :converted_price
36
+ # end
37
+ # end
38
+ #
39
+ # contract = PricingContract.new(base_price: 100, user_tier: "gold", exchange_rate: 1.0)
40
+ # contract.resolve_all
41
+ #
42
+ # result = contract.resolve_incrementally(exchange_rate: 1.12)
43
+ # result.skipped_nodes # => [:tier_discount, :adjusted_price]
44
+ # result.changed_outputs # => { converted_price: { from: 100.0, to: 112.0 } }
45
+ # result.explain
46
+ #
47
+ module Incremental
48
+ class IncrementalError < Igniter::Error; end
49
+ end
50
+ end
@@ -13,24 +13,27 @@ module Igniter
13
13
  @mutex.synchronize { @states[node_name.to_sym] }
14
14
  end
15
15
 
16
- def write(state)
16
+ def write(state) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
17
17
  @mutex.synchronize do
18
18
  current = @states[state.node.name]
19
19
  version = state.version || (current&.running? ? current.version : next_version(current))
20
+ value_version = compute_value_version(state, current)
20
21
  @states[state.node.name] = NodeState.new(
21
22
  node: state.node,
22
23
  status: state.status,
23
24
  value: state.value,
24
25
  error: state.error,
25
26
  version: version,
27
+ value_version: value_version,
26
28
  resolved_at: state.resolved_at,
27
- invalidated_by: state.invalidated_by
29
+ invalidated_by: state.invalidated_by,
30
+ dep_snapshot: state.dep_snapshot
28
31
  )
29
32
  @condition.broadcast
30
33
  end
31
34
  end
32
35
 
33
- def begin_resolution(node)
36
+ def begin_resolution(node) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
34
37
  @mutex.synchronize do
35
38
  loop do
36
39
  current = @states[node.name]
@@ -43,8 +46,10 @@ module Igniter
43
46
  value: current&.value,
44
47
  error: current&.error,
45
48
  version: next_version(current),
49
+ value_version: current&.value_version,
46
50
  resolved_at: current&.resolved_at || Time.now.utc,
47
- invalidated_by: nil
51
+ invalidated_by: nil,
52
+ dep_snapshot: current&.dep_snapshot
48
53
  )
49
54
  return [:started, @states[node.name]]
50
55
  end
@@ -54,7 +59,7 @@ module Igniter
54
59
  end
55
60
  end
56
61
 
57
- def stale!(node, invalidated_by:)
62
+ def stale!(node, invalidated_by:) # rubocop:disable Metrics/MethodLength
58
63
  @mutex.synchronize do
59
64
  current = @states[node.name]
60
65
  return unless current
@@ -65,8 +70,10 @@ module Igniter
65
70
  value: current.value,
66
71
  error: current.error,
67
72
  version: current.version + 1,
73
+ value_version: current.value_version,
68
74
  resolved_at: current.resolved_at,
69
- invalidated_by: invalidated_by
75
+ invalidated_by: invalidated_by,
76
+ dep_snapshot: current.dep_snapshot
70
77
  )
71
78
  @condition.broadcast
72
79
  end
@@ -92,6 +99,28 @@ module Igniter
92
99
  def next_version(current)
93
100
  current ? current.version + 1 : 1
94
101
  end
102
+
103
+ # value_version only increments when the actual value changes.
104
+ # When state.value_version is set explicitly (backdating from resolver), use it.
105
+ # For :succeeded states, compare the new value against the old value:
106
+ # - same value → preserve value_version
107
+ # - different (or first time) → increment
108
+ # For all other statuses (failed, pending, etc.) → no value_version.
109
+ def compute_value_version(state, current)
110
+ return state.value_version if state.value_version
111
+
112
+ return nil unless state.status == :succeeded
113
+
114
+ base_vv = current&.value_version || 0
115
+ # When current is :running, current.value holds the pre-stale value for comparison.
116
+ old_value = current&.value
117
+
118
+ if base_vv.positive? && old_value == state.value
119
+ base_vv
120
+ else
121
+ base_vv + 1
122
+ end
123
+ end
95
124
  end
96
125
  end
97
126
  end
@@ -251,10 +251,12 @@ module Igniter
251
251
  memo[node_name] = {
252
252
  status: state.status,
253
253
  version: state.version,
254
+ value_version: state.value_version,
254
255
  resolved_at: state.resolved_at&.iso8601,
255
256
  invalidated_by: state.invalidated_by,
256
257
  value: serialize_state_value(state.value),
257
- error: serialize_state_error(state.error)
258
+ error: serialize_state_error(state.error),
259
+ dep_snapshot: state.dep_snapshot
258
260
  }
259
261
  end
260
262
  end
@@ -262,14 +264,18 @@ module Igniter
262
264
  def deserialize_states(snapshot_states)
263
265
  snapshot_states.each_with_object({}) do |(node_name, state_data), memo|
264
266
  node = compiled_graph.fetch_node(node_name)
267
+ raw_dep_snapshot = state_data[:dep_snapshot] || state_data["dep_snapshot"]
268
+ dep_snapshot = raw_dep_snapshot&.transform_keys(&:to_sym)
265
269
  memo[node.name] = NodeState.new(
266
270
  node: node,
267
271
  status: (state_data[:status] || state_data["status"]).to_sym,
268
272
  value: deserialize_state_value(node, state_data[:value] || state_data["value"]),
269
273
  error: deserialize_state_error(state_data[:error] || state_data["error"]),
270
274
  version: state_data[:version] || state_data["version"],
275
+ value_version: state_data[:value_version] || state_data["value_version"],
271
276
  resolved_at: deserialize_time(state_data[:resolved_at] || state_data["resolved_at"]),
272
- invalidated_by: (state_data[:invalidated_by] || state_data["invalidated_by"])&.to_sym
277
+ invalidated_by: (state_data[:invalidated_by] || state_data["invalidated_by"])&.to_sym,
278
+ dep_snapshot: dep_snapshot
273
279
  )
274
280
  end
275
281
  end
@@ -3,16 +3,20 @@
3
3
  module Igniter
4
4
  module Runtime
5
5
  class NodeState
6
- attr_reader :node, :status, :value, :error, :version, :resolved_at, :invalidated_by
6
+ attr_reader :node, :status, :value, :error, :version, :value_version,
7
+ :resolved_at, :invalidated_by, :dep_snapshot
7
8
 
8
- def initialize(node:, status:, value: nil, error: nil, version: nil, resolved_at: Time.now.utc, invalidated_by: nil)
9
+ def initialize(node:, status:, value: nil, error: nil, version: nil, value_version: nil, # rubocop:disable Metrics/ParameterLists
10
+ resolved_at: Time.now.utc, invalidated_by: nil, dep_snapshot: nil)
9
11
  @node = node
10
12
  @status = status
11
13
  @value = value
12
14
  @error = error
13
15
  @version = version
16
+ @value_version = value_version
14
17
  @resolved_at = resolved_at
15
18
  @invalidated_by = invalidated_by
19
+ @dep_snapshot = dep_snapshot
16
20
  end
17
21
 
18
22
  def stale?
@@ -40,6 +44,7 @@ module Igniter
40
44
  node_name: node.name,
41
45
  status: status,
42
46
  version: version,
47
+ value_version: value_version,
43
48
  resolved_at: resolved_at,
44
49
  invalidated_by: invalidated_by,
45
50
  value: value,
@@ -115,16 +115,52 @@ module Igniter
115
115
  raise ResolutionError, "Cannot reach #{node.node_url}: #{e.message}"
116
116
  end
117
117
 
118
- def resolve_compute(node)
118
+ def resolve_compute(node) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
119
+ # Running state preserves dep_snapshot + value_version from the stale state.
120
+ # These are used for memoization (skip recompute) and value backdating.
121
+ running_state = @execution.cache.fetch(node.name)
122
+ old_dep_snapshot = running_state&.dep_snapshot
123
+ old_value = running_state&.value
124
+ old_value_version = running_state&.value_version || 0
125
+
126
+ # Resolve all dependencies (may recursively recompute upstream nodes).
119
127
  dependencies = node.dependencies.each_with_object({}) do |dependency_name, memo|
120
128
  memo[dependency_name] = resolve_dependency_value(dependency_name)
121
129
  end
122
130
 
131
+ # Build snapshot of current dep value_versions (only regular nodes, not outputs).
132
+ current_dep_snapshot = build_dep_snapshot(node)
133
+
134
+ # Memoization: if all dep value_versions are unchanged, skip the compute entirely.
135
+ if old_dep_snapshot && old_value && old_value_version.positive? &&
136
+ dep_snapshot_match?(current_dep_snapshot, old_dep_snapshot)
137
+ @execution.events.emit(:node_skipped, node: node, status: :succeeded,
138
+ payload: { reason: :deps_unchanged })
139
+ return NodeState.new(node: node, status: :succeeded, value: old_value,
140
+ value_version: old_value_version,
141
+ dep_snapshot: current_dep_snapshot)
142
+ end
143
+
123
144
  value = call_compute(node.callable, dependencies)
124
- return NodeState.new(node: node, status: :pending, value: normalize_deferred_result(value, node)) if deferred_result?(value)
145
+ if deferred_result?(value)
146
+ return NodeState.new(node: node, status: :pending,
147
+ value: normalize_deferred_result(value, node))
148
+ end
149
+
125
150
  value = normalize_guard_value(node, value)
126
151
 
127
- NodeState.new(node: node, status: :succeeded, value: value)
152
+ # Value backdating: if the output is unchanged, preserve value_version so that
153
+ # downstream nodes whose dep_snapshots reference this node won't see it as changed.
154
+ if old_value && old_value_version.positive? && value == old_value
155
+ @execution.events.emit(:node_backdated, node: node, status: :succeeded,
156
+ payload: { reason: :value_unchanged })
157
+ return NodeState.new(node: node, status: :succeeded, value: value,
158
+ value_version: old_value_version,
159
+ dep_snapshot: current_dep_snapshot)
160
+ end
161
+
162
+ NodeState.new(node: node, status: :succeeded, value: value,
163
+ dep_snapshot: current_dep_snapshot)
128
164
  end
129
165
 
130
166
  def call_compute(callable, dependencies)
@@ -151,11 +187,9 @@ module Igniter
151
187
  end
152
188
 
153
189
  def call_compute_object(callable, dependencies)
154
- if callable.respond_to?(:call)
155
- callable.call(**dependencies)
156
- else
157
- raise ResolutionError, "Unsupported callable: #{callable.class}"
158
- end
190
+ raise ResolutionError, "Unsupported callable: #{callable.class}" unless callable.respond_to?(:call)
191
+
192
+ callable.call(**dependencies)
159
193
  end
160
194
 
161
195
  def resolve_composition(node)
@@ -281,13 +315,17 @@ module Igniter
281
315
  if @execution.compiled_graph.node?(dependency_name)
282
316
  dependency_state = resolve(dependency_name)
283
317
  raise dependency_state.error if dependency_state.failed?
284
- raise PendingDependencyError.new(dependency_state.value, context: pending_context(dependency_state.node)) if dependency_state.pending?
318
+
319
+ if dependency_state.pending?
320
+ raise PendingDependencyError.new(dependency_state.value,
321
+ context: pending_context(dependency_state.node))
322
+ end
285
323
 
286
324
  dependency_state.value
287
325
  elsif @execution.compiled_graph.outputs_by_name.key?(dependency_name.to_sym)
288
326
  output = @execution.compiled_graph.fetch_output(dependency_name)
289
327
  value = @execution.send(:resolve_exported_output, output)
290
- raise PendingDependencyError.new(value) if deferred_result?(value)
328
+ raise PendingDependencyError, value if deferred_result?(value)
291
329
 
292
330
  value
293
331
  else
@@ -372,6 +410,21 @@ module Igniter
372
410
  )
373
411
  end
374
412
 
413
+ def build_dep_snapshot(node)
414
+ node.dependencies.each_with_object({}) do |dep_name, memo|
415
+ next unless @execution.compiled_graph.node?(dep_name)
416
+
417
+ dep_state = @execution.cache.fetch(dep_name.to_sym)
418
+ memo[dep_name] = dep_state&.value_version
419
+ end
420
+ end
421
+
422
+ def dep_snapshot_match?(current, old)
423
+ return false if current.size != old.size
424
+
425
+ current.all? { |name, vv| old[name] == vv }
426
+ end
427
+
375
428
  def normalize_guard_value(node, value)
376
429
  return value unless node.respond_to?(:guard?) && node.guard?
377
430
  return true if value
@@ -389,9 +442,7 @@ module Igniter
389
442
  end
390
443
 
391
444
  def normalize_collection_items(node, items, context_values = {})
392
- if node.input_mapper? && items.is_a?(Hash)
393
- items = items.to_a
394
- end
445
+ items = items.to_a if node.input_mapper? && items.is_a?(Hash)
395
446
 
396
447
  unless items.is_a?(Array)
397
448
  raise CollectionInputError.new(
@@ -440,14 +491,17 @@ module Igniter
440
491
 
441
492
  def ensure_unique_collection_keys!(node, items)
442
493
  keys = items.map do |item|
443
- item.fetch(node.key_name) { raise CollectionKeyError.new("Collection '#{node.name}' item is missing key '#{node.key_name}'", context: collection_context(node)) }
494
+ item.fetch(node.key_name) do
495
+ raise CollectionKeyError.new("Collection '#{node.name}' item is missing key '#{node.key_name}'",
496
+ context: collection_context(node))
497
+ end
444
498
  end
445
499
 
446
500
  duplicates = keys.group_by(&:itself).select { |_key, entries| entries.size > 1 }.keys
447
501
  return if duplicates.empty?
448
502
 
449
503
  raise CollectionKeyError.new(
450
- "Collection '#{node.name}' has duplicate keys: #{duplicates.join(', ')}",
504
+ "Collection '#{node.name}' has duplicate keys: #{duplicates.join(", ")}",
451
505
  context: collection_context(node)
452
506
  )
453
507
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Igniter
4
- VERSION = "0.4.3"
4
+ VERSION = "0.4.5"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: igniter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.3
4
+ version: 0.4.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexander
@@ -73,6 +73,7 @@ files:
73
73
  - examples/distributed_server.rb
74
74
  - examples/distributed_workflow.rb
75
75
  - examples/effects.rb
76
+ - examples/incremental.rb
76
77
  - examples/invariants.rb
77
78
  - examples/marketing_ergonomics.rb
78
79
  - examples/order_pipeline.rb
@@ -133,6 +134,7 @@ files:
133
134
  - lib/igniter/extensions/auditing/timeline.rb
134
135
  - lib/igniter/extensions/differential.rb
135
136
  - lib/igniter/extensions/execution_report.rb
137
+ - lib/igniter/extensions/incremental.rb
136
138
  - lib/igniter/extensions/introspection.rb
137
139
  - lib/igniter/extensions/introspection/graph_formatter.rb
138
140
  - lib/igniter/extensions/introspection/plan_formatter.rb
@@ -144,6 +146,10 @@ files:
144
146
  - lib/igniter/extensions/reactive/matcher.rb
145
147
  - lib/igniter/extensions/reactive/reaction.rb
146
148
  - lib/igniter/extensions/saga.rb
149
+ - lib/igniter/incremental.rb
150
+ - lib/igniter/incremental/formatter.rb
151
+ - lib/igniter/incremental/result.rb
152
+ - lib/igniter/incremental/tracker.rb
147
153
  - lib/igniter/integrations/agents.rb
148
154
  - lib/igniter/integrations/llm.rb
149
155
  - lib/igniter/integrations/llm/config.rb