igniter 0.4.0 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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/invariants.rb +116 -0
- data/lib/igniter/extensions/provenance.rb +45 -0
- data/lib/igniter/extensions/saga.rb +74 -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/resolver.rb +15 -0
- 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 +57 -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,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# examples/invariants.rb
|
|
4
|
+
#
|
|
5
|
+
# Demonstrates Igniter Invariants and Property Testing — declare conditions
|
|
6
|
+
# that must always hold for a contract's outputs, then verify them against
|
|
7
|
+
# hundreds of randomly generated inputs.
|
|
8
|
+
#
|
|
9
|
+
# Run with: bundle exec ruby examples/invariants.rb
|
|
10
|
+
|
|
11
|
+
require_relative "../lib/igniter"
|
|
12
|
+
require_relative "../lib/igniter/extensions/invariants"
|
|
13
|
+
require_relative "../lib/igniter/property_testing"
|
|
14
|
+
|
|
15
|
+
# ── 1. Define a contract with invariants ──────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
class PricingContract < Igniter::Contract
|
|
18
|
+
define do
|
|
19
|
+
input :price
|
|
20
|
+
input :quantity
|
|
21
|
+
|
|
22
|
+
compute :subtotal, depends_on: %i[price quantity] do |price:, quantity:|
|
|
23
|
+
price * quantity
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
compute :discount, depends_on: :subtotal do |subtotal:|
|
|
27
|
+
subtotal > 100 ? subtotal * 0.1 : 0.0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
compute :total, depends_on: %i[subtotal discount] do |subtotal:, discount:|
|
|
31
|
+
subtotal - discount
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
output :total
|
|
35
|
+
output :subtotal
|
|
36
|
+
output :discount
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Block receives only declared output values as keyword args.
|
|
40
|
+
# Use ** to absorb outputs you don't need to check.
|
|
41
|
+
invariant(:total_non_negative) { |total:, **| total >= 0 }
|
|
42
|
+
invariant(:discount_non_negative) { |discount:, **| discount >= 0 }
|
|
43
|
+
invariant(:total_le_subtotal) { |total:, subtotal:, **| total <= subtotal }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# ── 2. Happy path — invariants pass ───────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
puts "=" * 60
|
|
49
|
+
puts "HAPPY PATH — invariants pass"
|
|
50
|
+
puts "=" * 60
|
|
51
|
+
puts
|
|
52
|
+
|
|
53
|
+
contract = PricingContract.new(price: 20.0, quantity: 10)
|
|
54
|
+
contract.resolve_all
|
|
55
|
+
|
|
56
|
+
puts " total: #{contract.result.total}"
|
|
57
|
+
puts " subtotal: #{contract.result.subtotal}"
|
|
58
|
+
puts " discount: #{contract.result.discount}"
|
|
59
|
+
puts
|
|
60
|
+
puts " check_invariants: #{contract.check_invariants.inspect}"
|
|
61
|
+
puts
|
|
62
|
+
|
|
63
|
+
# ── 3. Manual violation check — does not raise ───────────────────────────────
|
|
64
|
+
|
|
65
|
+
puts "=" * 60
|
|
66
|
+
puts "MANUAL CHECK — does not raise, returns violations"
|
|
67
|
+
puts "=" * 60
|
|
68
|
+
puts
|
|
69
|
+
|
|
70
|
+
class NegativePriceContract < Igniter::Contract
|
|
71
|
+
define do
|
|
72
|
+
input :price
|
|
73
|
+
input :quantity
|
|
74
|
+
compute :total, depends_on: %i[price quantity] do |price:, quantity:|
|
|
75
|
+
price * quantity # can be negative if price < 0
|
|
76
|
+
end
|
|
77
|
+
output :total
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
invariant(:total_non_negative) { |total:, **| total >= 0 }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
c = NegativePriceContract.new(price: -5.0, quantity: 3)
|
|
84
|
+
|
|
85
|
+
# Disable auto-raise to inspect violations manually
|
|
86
|
+
Thread.current[:igniter_skip_invariants] = true
|
|
87
|
+
c.resolve_all
|
|
88
|
+
Thread.current[:igniter_skip_invariants] = false
|
|
89
|
+
|
|
90
|
+
violations = c.check_invariants
|
|
91
|
+
puts " violations: #{violations.map(&:name).inspect}"
|
|
92
|
+
puts " total: #{c.result.total}"
|
|
93
|
+
puts
|
|
94
|
+
|
|
95
|
+
# ── 4. Automatic raise on violation ───────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
puts "=" * 60
|
|
98
|
+
puts "AUTO-RAISE — resolve_all raises InvariantError"
|
|
99
|
+
puts "=" * 60
|
|
100
|
+
puts
|
|
101
|
+
|
|
102
|
+
begin
|
|
103
|
+
NegativePriceContract.new(price: -5.0, quantity: 3).resolve_all
|
|
104
|
+
rescue Igniter::InvariantError => e
|
|
105
|
+
puts " Caught InvariantError: #{e.message}"
|
|
106
|
+
puts " violations: #{e.violations.map(&:name).inspect}"
|
|
107
|
+
end
|
|
108
|
+
puts
|
|
109
|
+
|
|
110
|
+
# ── 5. Property testing — all inputs valid ────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
puts "=" * 60
|
|
113
|
+
puts "PROPERTY TEST — PricingContract with valid inputs"
|
|
114
|
+
puts "=" * 60
|
|
115
|
+
puts
|
|
116
|
+
|
|
117
|
+
G = Igniter::PropertyTesting::Generators
|
|
118
|
+
|
|
119
|
+
result = PricingContract.property_test(
|
|
120
|
+
generators: {
|
|
121
|
+
price: G.float(0.0..500.0),
|
|
122
|
+
quantity: G.positive_integer(max: 100)
|
|
123
|
+
},
|
|
124
|
+
runs: 200,
|
|
125
|
+
seed: 42
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
puts result.explain
|
|
129
|
+
puts
|
|
130
|
+
|
|
131
|
+
# ── 6. Property testing — finds a counterexample ─────────────────────────────
|
|
132
|
+
|
|
133
|
+
puts "=" * 60
|
|
134
|
+
puts "PROPERTY TEST — NegativePriceContract with unconstrained inputs"
|
|
135
|
+
puts "=" * 60
|
|
136
|
+
puts
|
|
137
|
+
|
|
138
|
+
failing_result = NegativePriceContract.property_test(
|
|
139
|
+
generators: {
|
|
140
|
+
price: G.float(-100.0..100.0), # can be negative!
|
|
141
|
+
quantity: G.positive_integer(max: 10)
|
|
142
|
+
},
|
|
143
|
+
runs: 100,
|
|
144
|
+
seed: 1
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
puts failing_result.explain
|
|
148
|
+
puts
|
|
149
|
+
puts " First counterexample inputs: #{failing_result.counterexample&.inputs.inspect}"
|
|
150
|
+
puts
|
|
151
|
+
|
|
152
|
+
# ── 7. Property testing — execution errors captured ──────────────────────────
|
|
153
|
+
|
|
154
|
+
puts "=" * 60
|
|
155
|
+
puts "PROPERTY TEST — execution errors captured as failed runs"
|
|
156
|
+
puts "=" * 60
|
|
157
|
+
puts
|
|
158
|
+
|
|
159
|
+
class FragileContract < Igniter::Contract
|
|
160
|
+
define do
|
|
161
|
+
input :value
|
|
162
|
+
compute :result, depends_on: :value do |value:|
|
|
163
|
+
raise ArgumentError, "value must be positive" if value <= 0
|
|
164
|
+
|
|
165
|
+
Math.sqrt(value)
|
|
166
|
+
end
|
|
167
|
+
output :result
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
invariant(:result_non_negative) { |result:, **| result >= 0 }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
fragile_result = FragileContract.property_test(
|
|
174
|
+
generators: { value: G.integer(min: -10, max: 10) },
|
|
175
|
+
runs: 50,
|
|
176
|
+
seed: 7
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
puts fragile_result.explain
|