igniter 0.4.0 → 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/CHANGELOG.md +25 -0
- data/README.md +238 -218
- data/docs/LLM_V1.md +335 -0
- data/docs/PATTERNS.md +189 -0
- data/docs/SERVER_V1.md +313 -0
- data/examples/README.md +129 -0
- data/examples/agents.rb +150 -0
- data/examples/differential.rb +161 -0
- data/examples/distributed_server.rb +94 -0
- data/examples/effects.rb +184 -0
- data/examples/incremental.rb +142 -0
- data/examples/invariants.rb +179 -0
- data/examples/order_pipeline.rb +163 -0
- data/examples/provenance.rb +122 -0
- data/examples/saga.rb +110 -0
- data/lib/igniter/agent/mailbox.rb +96 -0
- data/lib/igniter/agent/message.rb +21 -0
- data/lib/igniter/agent/ref.rb +86 -0
- data/lib/igniter/agent/runner.rb +129 -0
- data/lib/igniter/agent/state_holder.rb +23 -0
- data/lib/igniter/agent.rb +155 -0
- data/lib/igniter/compiler/validators/callable_validator.rb +21 -3
- data/lib/igniter/differential/divergence.rb +29 -0
- data/lib/igniter/differential/formatter.rb +96 -0
- data/lib/igniter/differential/report.rb +86 -0
- data/lib/igniter/differential/runner.rb +130 -0
- data/lib/igniter/differential.rb +51 -0
- data/lib/igniter/dsl/contract_builder.rb +32 -0
- data/lib/igniter/effect.rb +91 -0
- data/lib/igniter/effect_registry.rb +78 -0
- data/lib/igniter/errors.rb +11 -1
- data/lib/igniter/execution_report/builder.rb +54 -0
- data/lib/igniter/execution_report/formatter.rb +50 -0
- data/lib/igniter/execution_report/node_entry.rb +24 -0
- data/lib/igniter/execution_report/report.rb +65 -0
- data/lib/igniter/execution_report.rb +32 -0
- data/lib/igniter/extensions/differential.rb +114 -0
- data/lib/igniter/extensions/execution_report.rb +27 -0
- data/lib/igniter/extensions/incremental.rb +50 -0
- data/lib/igniter/extensions/invariants.rb +116 -0
- data/lib/igniter/extensions/provenance.rb +45 -0
- data/lib/igniter/extensions/saga.rb +74 -0
- data/lib/igniter/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/integrations/agents.rb +18 -0
- data/lib/igniter/invariant.rb +50 -0
- data/lib/igniter/model/effect_node.rb +37 -0
- data/lib/igniter/model.rb +1 -0
- data/lib/igniter/property_testing/formatter.rb +66 -0
- data/lib/igniter/property_testing/generators.rb +115 -0
- data/lib/igniter/property_testing/result.rb +45 -0
- data/lib/igniter/property_testing/run.rb +43 -0
- data/lib/igniter/property_testing/runner.rb +47 -0
- data/lib/igniter/property_testing.rb +64 -0
- data/lib/igniter/provenance/builder.rb +97 -0
- data/lib/igniter/provenance/lineage.rb +82 -0
- data/lib/igniter/provenance/node_trace.rb +65 -0
- data/lib/igniter/provenance/text_formatter.rb +70 -0
- data/lib/igniter/provenance.rb +29 -0
- data/lib/igniter/registry.rb +67 -0
- data/lib/igniter/runtime/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 +84 -15
- data/lib/igniter/saga/compensation.rb +31 -0
- data/lib/igniter/saga/compensation_record.rb +20 -0
- data/lib/igniter/saga/executor.rb +85 -0
- data/lib/igniter/saga/formatter.rb +49 -0
- data/lib/igniter/saga/result.rb +47 -0
- data/lib/igniter/saga.rb +56 -0
- data/lib/igniter/stream_loop.rb +80 -0
- data/lib/igniter/supervisor.rb +167 -0
- data/lib/igniter/version.rb +1 -1
- data/lib/igniter.rb +10 -0
- metadata +63 -1
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Differential Execution — compare two contract implementations output-by-output.
|
|
4
|
+
#
|
|
5
|
+
# Use cases:
|
|
6
|
+
# * Validate a refactored contract produces the same results
|
|
7
|
+
# * Run a shadow/canary contract alongside production, catching divergences
|
|
8
|
+
# * A/B test alternative business-rule implementations
|
|
9
|
+
#
|
|
10
|
+
# Run: ruby examples/differential.rb
|
|
11
|
+
|
|
12
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
13
|
+
require "igniter"
|
|
14
|
+
require "igniter/extensions/differential"
|
|
15
|
+
|
|
16
|
+
# ── Contracts ─────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
# V1 — production contract (10% tax, no discount)
|
|
19
|
+
class PricingV1 < Igniter::Contract
|
|
20
|
+
define do
|
|
21
|
+
input :price, type: :numeric
|
|
22
|
+
input :quantity, type: :numeric
|
|
23
|
+
|
|
24
|
+
compute :subtotal, depends_on: %i[price quantity] do |price:, quantity:|
|
|
25
|
+
(price * quantity).round(2)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
compute :tax, depends_on: :subtotal do |subtotal:|
|
|
29
|
+
(subtotal * 0.10).round(2)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
compute :total, depends_on: %i[subtotal tax] do |subtotal:, tax:|
|
|
33
|
+
subtotal + tax
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
output :subtotal
|
|
37
|
+
output :tax
|
|
38
|
+
output :total
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# V2 — candidate contract (15% tax + volume discount)
|
|
43
|
+
class PricingV2 < Igniter::Contract
|
|
44
|
+
define do
|
|
45
|
+
input :price, type: :numeric
|
|
46
|
+
input :quantity, type: :numeric
|
|
47
|
+
|
|
48
|
+
compute :subtotal, depends_on: %i[price quantity] do |price:, quantity:|
|
|
49
|
+
(price * quantity).round(2)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
compute :tax, depends_on: :subtotal do |subtotal:|
|
|
53
|
+
(subtotal * 0.15).round(2) # higher rate
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
compute :discount, depends_on: :subtotal do |subtotal:|
|
|
57
|
+
subtotal > 100 ? 10.0 : 0.0
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
compute :total, depends_on: %i[subtotal tax discount] do |subtotal:, tax:, discount:|
|
|
61
|
+
(subtotal + tax - discount).round(2)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
output :subtotal
|
|
65
|
+
output :tax
|
|
66
|
+
output :discount # new output — absent in V1
|
|
67
|
+
output :total
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
puts "=== Differential Execution Demo ==="
|
|
72
|
+
puts
|
|
73
|
+
|
|
74
|
+
# ── 1. Standalone comparison ───────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
puts "--- Igniter::Differential.compare ---"
|
|
77
|
+
report = Igniter::Differential.compare(
|
|
78
|
+
primary: PricingV1,
|
|
79
|
+
candidate: PricingV2,
|
|
80
|
+
inputs: { price: 50.0, quantity: 3 }
|
|
81
|
+
)
|
|
82
|
+
puts report.explain
|
|
83
|
+
puts
|
|
84
|
+
|
|
85
|
+
# ── 2. Numeric tolerance ───────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
puts "--- compare with tolerance: 10.0 ---"
|
|
88
|
+
report_tol = Igniter::Differential.compare(
|
|
89
|
+
primary: PricingV1,
|
|
90
|
+
candidate: PricingV2,
|
|
91
|
+
inputs: { price: 50.0, quantity: 3 },
|
|
92
|
+
tolerance: 10.0
|
|
93
|
+
)
|
|
94
|
+
puts "tax within tolerance? #{report_tol.divergences.none? { |d| d.output_name == :tax }}"
|
|
95
|
+
puts
|
|
96
|
+
|
|
97
|
+
# ── 3. Instance diff_against ───────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
puts "--- contract.diff_against(PricingV2) ---"
|
|
100
|
+
contract = PricingV1.new(price: 50.0, quantity: 3)
|
|
101
|
+
contract.resolve_all
|
|
102
|
+
report2 = contract.diff_against(PricingV2)
|
|
103
|
+
puts "match? = #{report2.match?}"
|
|
104
|
+
puts "diverged: #{report2.divergences.map(&:output_name).inspect}"
|
|
105
|
+
puts "cand. only: #{report2.candidate_only.keys.inspect}"
|
|
106
|
+
puts
|
|
107
|
+
|
|
108
|
+
# ── 4. Query API on Report ─────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
puts "--- Report query API ---"
|
|
111
|
+
report.divergences.each do |div|
|
|
112
|
+
puts " #{div.output_name}: #{div.primary_value} → #{div.candidate_value} (delta: #{div.delta})"
|
|
113
|
+
end
|
|
114
|
+
puts "summary: #{report.summary}"
|
|
115
|
+
puts
|
|
116
|
+
|
|
117
|
+
# ── 5. Shadow mode ─────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
puts "--- shadow_with (sync) ---"
|
|
120
|
+
|
|
121
|
+
class PricingWithShadow < Igniter::Contract
|
|
122
|
+
shadow_with PricingV2, on_divergence: ->(r) { puts " [shadow] #{r.summary}" }
|
|
123
|
+
define do
|
|
124
|
+
input :price, type: :numeric
|
|
125
|
+
input :quantity, type: :numeric
|
|
126
|
+
|
|
127
|
+
compute :subtotal, depends_on: %i[price quantity] do |price:, quantity:|
|
|
128
|
+
(price * quantity).round(2)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
compute :tax, depends_on: :subtotal do |subtotal:|
|
|
132
|
+
(subtotal * 0.10).round(2)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
compute :total, depends_on: %i[subtotal tax] do |subtotal:, tax:|
|
|
136
|
+
subtotal + tax
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
output :subtotal
|
|
140
|
+
output :tax
|
|
141
|
+
output :total
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
shadow_contract = PricingWithShadow.new(price: 50.0, quantity: 3)
|
|
146
|
+
shadow_contract.resolve_all # shadow runs automatically
|
|
147
|
+
puts "primary total = #{shadow_contract.result.total}"
|
|
148
|
+
puts
|
|
149
|
+
|
|
150
|
+
# ── 6. Matching contracts ──────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
puts "--- identical contracts match ---"
|
|
153
|
+
matching = Igniter::Differential.compare(
|
|
154
|
+
primary: PricingV1,
|
|
155
|
+
candidate: PricingV1,
|
|
156
|
+
inputs: { price: 50.0, quantity: 3 }
|
|
157
|
+
)
|
|
158
|
+
puts "match? = #{matching.match?}"
|
|
159
|
+
puts
|
|
160
|
+
|
|
161
|
+
puts "done=true"
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Distributed Contract — job application review workflow
|
|
4
|
+
#
|
|
5
|
+
# Shows the full distributed lifecycle:
|
|
6
|
+
# 1. Application submitted → execution starts, suspends at both await nodes
|
|
7
|
+
# 2. Background screening → first event delivered, execution still pending
|
|
8
|
+
# 3. Manager review → second event delivered, execution completes
|
|
9
|
+
# 4. on_success callback → fires when the decision node resolves
|
|
10
|
+
#
|
|
11
|
+
# Uses an in-process MemoryStore — no real server needed to run this example.
|
|
12
|
+
#
|
|
13
|
+
# Run: ruby examples/distributed_server.rb
|
|
14
|
+
|
|
15
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
16
|
+
require "igniter"
|
|
17
|
+
|
|
18
|
+
# ── Workflow Contract ─────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
class ApplicationReviewWorkflow < Igniter::Contract
|
|
21
|
+
correlate_by :application_id
|
|
22
|
+
|
|
23
|
+
define do
|
|
24
|
+
input :application_id
|
|
25
|
+
input :applicant_name
|
|
26
|
+
input :position
|
|
27
|
+
|
|
28
|
+
# Execution suspends at each await until the named event is delivered
|
|
29
|
+
await :screening_result, event: :screening_completed
|
|
30
|
+
await :manager_review, event: :manager_reviewed
|
|
31
|
+
|
|
32
|
+
compute :decision, depends_on: %i[screening_result manager_review] do |screening_result:, manager_review:|
|
|
33
|
+
passed = screening_result[:passed]
|
|
34
|
+
approved = manager_review[:approved]
|
|
35
|
+
|
|
36
|
+
if passed && approved
|
|
37
|
+
{ status: :hired, note: manager_review[:note] }
|
|
38
|
+
elsif !passed
|
|
39
|
+
{ status: :rejected, note: "Did not pass background screening" }
|
|
40
|
+
else
|
|
41
|
+
{ status: :rejected, note: manager_review[:note] }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
output :decision
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
on_success :decision do |value:, **|
|
|
49
|
+
puts "[callback] Decision reached: #{value[:status].upcase}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# ── Run ───────────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
store = Igniter::Runtime::Stores::MemoryStore.new
|
|
56
|
+
|
|
57
|
+
puts "=== Step 1: Application submitted ==="
|
|
58
|
+
execution = ApplicationReviewWorkflow.start(
|
|
59
|
+
{ application_id: "app-42", applicant_name: "Alice Chen", position: "Senior Engineer" },
|
|
60
|
+
store: store
|
|
61
|
+
)
|
|
62
|
+
puts "pending=#{execution.pending?}"
|
|
63
|
+
waiting = execution.execution.cache.values
|
|
64
|
+
.select { |s| s.pending? && s.node.kind == :await }
|
|
65
|
+
.map { |s| s.value.payload[:event] }
|
|
66
|
+
puts "waiting_for=#{waiting.inspect}"
|
|
67
|
+
|
|
68
|
+
puts "\n=== Step 2: Background screening completed ==="
|
|
69
|
+
ApplicationReviewWorkflow.deliver_event(
|
|
70
|
+
:screening_completed,
|
|
71
|
+
correlation: { application_id: "app-42" },
|
|
72
|
+
payload: { passed: true, score: 92 },
|
|
73
|
+
store: store
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Restore from store to inspect mid-flight state
|
|
77
|
+
exec_id = store.find_by_correlation(
|
|
78
|
+
graph: "ApplicationReviewWorkflow",
|
|
79
|
+
correlation: { application_id: "app-42" }
|
|
80
|
+
)
|
|
81
|
+
mid_state = ApplicationReviewWorkflow.restore_from_store(exec_id, store: store)
|
|
82
|
+
puts "still_pending=#{mid_state.execution.cache.values.any?(&:pending?)}"
|
|
83
|
+
|
|
84
|
+
puts "\n=== Step 3: Manager review completed ==="
|
|
85
|
+
final = ApplicationReviewWorkflow.deliver_event(
|
|
86
|
+
:manager_reviewed,
|
|
87
|
+
correlation: { application_id: "app-42" },
|
|
88
|
+
payload: { approved: true, note: "Excellent system design skills" },
|
|
89
|
+
store: store
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
puts "\n=== Final result ==="
|
|
93
|
+
puts "success=#{final.success?}"
|
|
94
|
+
puts "decision=#{final.result.decision.inspect}"
|
data/examples/effects.rb
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# examples/effects.rb
|
|
4
|
+
#
|
|
5
|
+
# Demonstrates the Igniter Effect system — first-class side-effect nodes
|
|
6
|
+
# that participate fully in the computation graph.
|
|
7
|
+
#
|
|
8
|
+
# Run with: bundle exec ruby examples/effects.rb
|
|
9
|
+
|
|
10
|
+
require_relative "../lib/igniter"
|
|
11
|
+
require_relative "../lib/igniter/extensions/saga"
|
|
12
|
+
require_relative "../lib/igniter/extensions/execution_report"
|
|
13
|
+
|
|
14
|
+
# ── 1. Define effect adapters ──────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
class UserRepository < Igniter::Effect
|
|
17
|
+
effect_type :database
|
|
18
|
+
idempotent false
|
|
19
|
+
|
|
20
|
+
# Simulate a DB lookup
|
|
21
|
+
def call(user_id:)
|
|
22
|
+
raise "User #{user_id} not found" if user_id == "missing"
|
|
23
|
+
|
|
24
|
+
{ id: user_id, name: "Alice", tier: "premium" }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Built-in rollback: undo any changes made by this effect
|
|
28
|
+
compensate do |**|
|
|
29
|
+
puts " [compensate] Rolling back user lookup (no-op for reads)"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class AuditLogger < Igniter::Effect
|
|
34
|
+
effect_type :audit
|
|
35
|
+
idempotent true
|
|
36
|
+
|
|
37
|
+
def call(user_id:, action:)
|
|
38
|
+
puts " [audit] #{action} performed for user #{user_id}"
|
|
39
|
+
{ logged_at: Time.now.iso8601, action: action }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class EmailNotifier < Igniter::Effect
|
|
44
|
+
effect_type :notification
|
|
45
|
+
|
|
46
|
+
def call(user:, message:)
|
|
47
|
+
raise "Email delivery failed" if user[:tier] == "suspended"
|
|
48
|
+
|
|
49
|
+
puts " [email] Sending '#{message}' to #{user[:name]}"
|
|
50
|
+
{ delivered: true, recipient: user[:name] }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
compensate do |inputs:, **|
|
|
54
|
+
puts " [compensate] Cancelling email to #{inputs[:user]&.dig(:name)}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# ── 2. Define a contract using effects ────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
class WelcomeWorkflow < Igniter::Contract
|
|
61
|
+
define do
|
|
62
|
+
input :user_id
|
|
63
|
+
input :action, default: "welcome"
|
|
64
|
+
|
|
65
|
+
effect :user, uses: UserRepository, depends_on: :user_id
|
|
66
|
+
effect :log, uses: AuditLogger, depends_on: %i[user_id action]
|
|
67
|
+
effect :email, uses: EmailNotifier, depends_on: %i[user action]
|
|
68
|
+
|
|
69
|
+
compute :summary, depends_on: %i[user email] do |user:, email:|
|
|
70
|
+
"Welcomed #{user[:name]}: email=#{email[:delivered]}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
output :summary
|
|
74
|
+
output :user
|
|
75
|
+
output :log
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# ── 3. Register an effect in the registry (optional, enables symbol lookup) ───
|
|
80
|
+
|
|
81
|
+
Igniter.register_effect(:user_repo, UserRepository)
|
|
82
|
+
Igniter.register_effect(:audit_log, AuditLogger)
|
|
83
|
+
|
|
84
|
+
class RegistryWorkflow < Igniter::Contract
|
|
85
|
+
define do
|
|
86
|
+
input :user_id
|
|
87
|
+
|
|
88
|
+
# Uses registered names instead of class references
|
|
89
|
+
effect :user_data, uses: :user_repo, depends_on: :user_id
|
|
90
|
+
effect :log_entry, uses: :audit_log, depends_on: [:user_id]
|
|
91
|
+
|
|
92
|
+
output :user_data
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# ── 4. Run the happy path ──────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
puts "=" * 60
|
|
99
|
+
puts "HAPPY PATH"
|
|
100
|
+
puts "=" * 60
|
|
101
|
+
|
|
102
|
+
contract = WelcomeWorkflow.new(user_id: "u42", action: "onboarding")
|
|
103
|
+
contract.resolve_all
|
|
104
|
+
|
|
105
|
+
puts
|
|
106
|
+
puts " summary: #{contract.result.summary.inspect}"
|
|
107
|
+
puts " user: #{contract.result.user.inspect}"
|
|
108
|
+
puts " log: #{contract.result.log.inspect}"
|
|
109
|
+
puts
|
|
110
|
+
|
|
111
|
+
# Execution report
|
|
112
|
+
report = contract.execution_report
|
|
113
|
+
puts report.explain
|
|
114
|
+
puts
|
|
115
|
+
|
|
116
|
+
# ── 5. Run via registry ────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
puts "=" * 60
|
|
119
|
+
puts "REGISTRY LOOKUP"
|
|
120
|
+
puts "=" * 60
|
|
121
|
+
puts
|
|
122
|
+
|
|
123
|
+
rw = RegistryWorkflow.new(user_id: "u99")
|
|
124
|
+
rw.resolve_all
|
|
125
|
+
puts " user_data: #{rw.result.user_data.inspect}"
|
|
126
|
+
puts
|
|
127
|
+
|
|
128
|
+
# ── 6. Saga with built-in compensation ────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
puts "=" * 60
|
|
131
|
+
puts "SAGA — effect failure triggers built-in compensation"
|
|
132
|
+
puts "=" * 60
|
|
133
|
+
puts
|
|
134
|
+
|
|
135
|
+
class FailingWorkflow < Igniter::Contract
|
|
136
|
+
define do
|
|
137
|
+
input :user_id
|
|
138
|
+
|
|
139
|
+
effect :user, uses: UserRepository, depends_on: :user_id
|
|
140
|
+
effect :notify, uses: EmailNotifier, depends_on: :user do
|
|
141
|
+
# notify depends on user
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
output :user
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Build a user that has "suspended" tier to trigger EmailNotifier failure
|
|
149
|
+
class SuspendedUserRepo < Igniter::Effect
|
|
150
|
+
effect_type :database
|
|
151
|
+
|
|
152
|
+
def call(user_id:)
|
|
153
|
+
{ id: user_id, name: "Bob", tier: "suspended" }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
compensate do |value:, **|
|
|
157
|
+
puts " [compensate] Undoing user fetch for #{value[:name]}"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
class FailingWorkflow2 < Igniter::Contract
|
|
162
|
+
define do
|
|
163
|
+
input :user_id
|
|
164
|
+
|
|
165
|
+
effect :user, uses: SuspendedUserRepo, depends_on: :user_id
|
|
166
|
+
effect :notify, uses: EmailNotifier, depends_on: :user
|
|
167
|
+
|
|
168
|
+
output :user
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
saga_result = FailingWorkflow2.new(user_id: "u_suspended").resolve_saga
|
|
173
|
+
puts
|
|
174
|
+
puts "Success: #{saga_result.success?}"
|
|
175
|
+
puts "Failed node: #{saga_result.failed_node}"
|
|
176
|
+
puts "Error: #{saga_result.error.message}"
|
|
177
|
+
puts
|
|
178
|
+
puts "Compensations:"
|
|
179
|
+
saga_result.compensations.each do |record|
|
|
180
|
+
status = record.success? ? "ok" : "FAILED: #{record.error.message}"
|
|
181
|
+
puts " :#{record.node_name} → #{status}"
|
|
182
|
+
end
|
|
183
|
+
puts
|
|
184
|
+
puts saga_result.explain
|
|
@@ -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?}"
|