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 +4 -4
- data/examples/incremental.rb +142 -0
- data/lib/igniter/extensions/incremental.rb +50 -0
- data/lib/igniter/incremental/formatter.rb +81 -0
- data/lib/igniter/incremental/result.rb +69 -0
- data/lib/igniter/incremental/tracker.rb +108 -0
- data/lib/igniter/incremental.rb +50 -0
- data/lib/igniter/runtime/cache.rb +35 -6
- data/lib/igniter/runtime/execution.rb +8 -2
- data/lib/igniter/runtime/node_state.rb +7 -2
- data/lib/igniter/runtime/resolver.rb +69 -15
- data/lib/igniter/version.rb +1 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 71035dd880de5a8a81bcc14cd0bf1097dbbb03ec9c2a915e36ae821bb3e25d70
|
|
4
|
+
data.tar.gz: a6bb66b9f865230e09c21e5db140ecf3b6705a934b2a5172f3dd2d6198dd5371
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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, :
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
data/lib/igniter/version.rb
CHANGED
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.
|
|
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
|