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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +238 -218
  4. data/docs/LLM_V1.md +335 -0
  5. data/docs/PATTERNS.md +189 -0
  6. data/docs/SERVER_V1.md +313 -0
  7. data/examples/README.md +129 -0
  8. data/examples/agents.rb +150 -0
  9. data/examples/differential.rb +161 -0
  10. data/examples/distributed_server.rb +94 -0
  11. data/examples/effects.rb +184 -0
  12. data/examples/incremental.rb +142 -0
  13. data/examples/invariants.rb +179 -0
  14. data/examples/order_pipeline.rb +163 -0
  15. data/examples/provenance.rb +122 -0
  16. data/examples/saga.rb +110 -0
  17. data/lib/igniter/agent/mailbox.rb +96 -0
  18. data/lib/igniter/agent/message.rb +21 -0
  19. data/lib/igniter/agent/ref.rb +86 -0
  20. data/lib/igniter/agent/runner.rb +129 -0
  21. data/lib/igniter/agent/state_holder.rb +23 -0
  22. data/lib/igniter/agent.rb +155 -0
  23. data/lib/igniter/compiler/validators/callable_validator.rb +21 -3
  24. data/lib/igniter/differential/divergence.rb +29 -0
  25. data/lib/igniter/differential/formatter.rb +96 -0
  26. data/lib/igniter/differential/report.rb +86 -0
  27. data/lib/igniter/differential/runner.rb +130 -0
  28. data/lib/igniter/differential.rb +51 -0
  29. data/lib/igniter/dsl/contract_builder.rb +32 -0
  30. data/lib/igniter/effect.rb +91 -0
  31. data/lib/igniter/effect_registry.rb +78 -0
  32. data/lib/igniter/errors.rb +11 -1
  33. data/lib/igniter/execution_report/builder.rb +54 -0
  34. data/lib/igniter/execution_report/formatter.rb +50 -0
  35. data/lib/igniter/execution_report/node_entry.rb +24 -0
  36. data/lib/igniter/execution_report/report.rb +65 -0
  37. data/lib/igniter/execution_report.rb +32 -0
  38. data/lib/igniter/extensions/differential.rb +114 -0
  39. data/lib/igniter/extensions/execution_report.rb +27 -0
  40. data/lib/igniter/extensions/incremental.rb +50 -0
  41. data/lib/igniter/extensions/invariants.rb +116 -0
  42. data/lib/igniter/extensions/provenance.rb +45 -0
  43. data/lib/igniter/extensions/saga.rb +74 -0
  44. data/lib/igniter/incremental/formatter.rb +81 -0
  45. data/lib/igniter/incremental/result.rb +69 -0
  46. data/lib/igniter/incremental/tracker.rb +108 -0
  47. data/lib/igniter/incremental.rb +50 -0
  48. data/lib/igniter/integrations/agents.rb +18 -0
  49. data/lib/igniter/invariant.rb +50 -0
  50. data/lib/igniter/model/effect_node.rb +37 -0
  51. data/lib/igniter/model.rb +1 -0
  52. data/lib/igniter/property_testing/formatter.rb +66 -0
  53. data/lib/igniter/property_testing/generators.rb +115 -0
  54. data/lib/igniter/property_testing/result.rb +45 -0
  55. data/lib/igniter/property_testing/run.rb +43 -0
  56. data/lib/igniter/property_testing/runner.rb +47 -0
  57. data/lib/igniter/property_testing.rb +64 -0
  58. data/lib/igniter/provenance/builder.rb +97 -0
  59. data/lib/igniter/provenance/lineage.rb +82 -0
  60. data/lib/igniter/provenance/node_trace.rb +65 -0
  61. data/lib/igniter/provenance/text_formatter.rb +70 -0
  62. data/lib/igniter/provenance.rb +29 -0
  63. data/lib/igniter/registry.rb +67 -0
  64. data/lib/igniter/runtime/cache.rb +35 -6
  65. data/lib/igniter/runtime/execution.rb +8 -2
  66. data/lib/igniter/runtime/node_state.rb +7 -2
  67. data/lib/igniter/runtime/resolver.rb +84 -15
  68. data/lib/igniter/saga/compensation.rb +31 -0
  69. data/lib/igniter/saga/compensation_record.rb +20 -0
  70. data/lib/igniter/saga/executor.rb +85 -0
  71. data/lib/igniter/saga/formatter.rb +49 -0
  72. data/lib/igniter/saga/result.rb +47 -0
  73. data/lib/igniter/saga.rb +56 -0
  74. data/lib/igniter/stream_loop.rb +80 -0
  75. data/lib/igniter/supervisor.rb +167 -0
  76. data/lib/igniter/version.rb +1 -1
  77. data/lib/igniter.rb +10 -0
  78. 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}"
@@ -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?}"