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,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
class Agent
|
|
5
|
+
# Runs an agent's message loop in a dedicated Ruby Thread.
|
|
6
|
+
#
|
|
7
|
+
# Responsibilities:
|
|
8
|
+
# - Pop messages from the Mailbox and dispatch to registered handlers
|
|
9
|
+
# - Fire scheduled timers when their interval elapses
|
|
10
|
+
# - Apply handler return-value semantics to StateHolder
|
|
11
|
+
# - Invoke the on_crash callback when the thread dies unexpectedly
|
|
12
|
+
# - Fire lifecycle hooks (after_start, after_crash, after_stop)
|
|
13
|
+
#
|
|
14
|
+
# Handler return-value semantics:
|
|
15
|
+
# Hash → replace agent state with the returned hash
|
|
16
|
+
# :stop → close the mailbox and exit the loop cleanly
|
|
17
|
+
# nil → leave state unchanged (no reply sent to sync caller)
|
|
18
|
+
# other → leave state unchanged; if message has reply_to, send value as reply
|
|
19
|
+
class Runner
|
|
20
|
+
def initialize(agent_class:, mailbox:, state_holder:, on_crash: nil)
|
|
21
|
+
@agent_class = agent_class
|
|
22
|
+
@mailbox = mailbox
|
|
23
|
+
@state_holder = state_holder
|
|
24
|
+
@on_crash = on_crash
|
|
25
|
+
@thread = nil
|
|
26
|
+
@timers = build_timers
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Start the message loop in a background thread. Returns the Thread.
|
|
30
|
+
def start
|
|
31
|
+
@thread = Thread.new { run_loop }
|
|
32
|
+
@thread.abort_on_exception = false
|
|
33
|
+
@thread
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
attr_reader :thread
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def run_loop # rubocop:disable Metrics/MethodLength
|
|
41
|
+
fire_hooks(:start)
|
|
42
|
+
loop do
|
|
43
|
+
delay = nearest_timer_delay
|
|
44
|
+
message = @mailbox.pop(timeout: delay)
|
|
45
|
+
fire_due_timers
|
|
46
|
+
break if message.nil? && @mailbox.closed?
|
|
47
|
+
|
|
48
|
+
dispatch(message) if message
|
|
49
|
+
end
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
@on_crash&.call(e)
|
|
52
|
+
fire_hooks(:crash, error: e)
|
|
53
|
+
ensure
|
|
54
|
+
fire_hooks(:stop)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def dispatch(message) # rubocop:disable Metrics/MethodLength
|
|
58
|
+
handler = @agent_class.handlers[message.type]
|
|
59
|
+
|
|
60
|
+
unless handler
|
|
61
|
+
# Unknown message type — send nil reply if caller is waiting
|
|
62
|
+
send_reply(message, nil)
|
|
63
|
+
return
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
state = @state_holder.get
|
|
67
|
+
result = handler.call(state: state, payload: message.payload)
|
|
68
|
+
|
|
69
|
+
case result
|
|
70
|
+
when Hash
|
|
71
|
+
@state_holder.set(result)
|
|
72
|
+
send_reply(message, nil)
|
|
73
|
+
when :stop
|
|
74
|
+
send_reply(message, nil)
|
|
75
|
+
@mailbox.close
|
|
76
|
+
when nil
|
|
77
|
+
send_reply(message, nil)
|
|
78
|
+
else
|
|
79
|
+
# Non-state return value — treat as sync reply payload
|
|
80
|
+
send_reply(message, result)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def send_reply(message, value)
|
|
85
|
+
return unless message.reply_to
|
|
86
|
+
|
|
87
|
+
message.reply_to.push(
|
|
88
|
+
Message.new(type: :reply, payload: { value: value })
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Returns seconds until the next timer fires, or nil if no timers.
|
|
93
|
+
def nearest_timer_delay
|
|
94
|
+
return nil if @timers.empty?
|
|
95
|
+
|
|
96
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
97
|
+
next_at = @timers.map { |t| t[:next_at] }.min
|
|
98
|
+
[next_at - now, 0].max
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def fire_due_timers
|
|
102
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
103
|
+
@timers.each do |timer|
|
|
104
|
+
next if timer[:next_at] > now
|
|
105
|
+
|
|
106
|
+
state = @state_holder.get
|
|
107
|
+
result = timer[:handler].call(state: state)
|
|
108
|
+
@state_holder.set(result) if result.is_a?(Hash)
|
|
109
|
+
timer[:next_at] = now + timer[:interval]
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def build_timers
|
|
114
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
115
|
+
@agent_class.timers.map do |t|
|
|
116
|
+
t.merge(next_at: now + t[:interval])
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def fire_hooks(type, **args)
|
|
121
|
+
@agent_class.hooks[type]&.each do |hook|
|
|
122
|
+
hook.call(**args)
|
|
123
|
+
rescue StandardError
|
|
124
|
+
nil # hooks must not crash the runner
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
class Agent
|
|
5
|
+
# Mutex-guarded wrapper around an agent's current state hash.
|
|
6
|
+
# The state is always stored as a frozen Hash so callers can read it
|
|
7
|
+
# without holding the lock.
|
|
8
|
+
class StateHolder
|
|
9
|
+
def initialize(initial_state)
|
|
10
|
+
@state = initial_state.freeze
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def get
|
|
15
|
+
@mutex.synchronize { @state }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def set(new_state)
|
|
19
|
+
@mutex.synchronize { @state = new_state.freeze }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "agent/message"
|
|
4
|
+
require_relative "agent/mailbox"
|
|
5
|
+
require_relative "agent/state_holder"
|
|
6
|
+
require_relative "agent/runner"
|
|
7
|
+
require_relative "agent/ref"
|
|
8
|
+
|
|
9
|
+
module Igniter
|
|
10
|
+
# Base class for stateful, message-driven actors.
|
|
11
|
+
#
|
|
12
|
+
# Subclass Agent and use the class-level DSL to declare:
|
|
13
|
+
# - `initial_state` — default state hash for new instances
|
|
14
|
+
# - `on` — handler for a named message type
|
|
15
|
+
# - `schedule` — recurring timer handler
|
|
16
|
+
# - `mailbox_size` — queue capacity (default: 256)
|
|
17
|
+
# - `mailbox_overflow` — policy when queue is full (:block/:drop_oldest/
|
|
18
|
+
# :drop_newest/:error, default: :block)
|
|
19
|
+
# - `after_start` — hook called when the agent thread begins
|
|
20
|
+
# - `after_crash` — hook called with the error when the thread crashes
|
|
21
|
+
# - `after_stop` — hook called when the agent thread exits
|
|
22
|
+
#
|
|
23
|
+
# Handler return-value semantics:
|
|
24
|
+
# Hash → new state (replaces current state)
|
|
25
|
+
# :stop → shut down the agent cleanly
|
|
26
|
+
# nil → unchanged state
|
|
27
|
+
# other → unchanged state; sent as reply to sync `call()` callers
|
|
28
|
+
#
|
|
29
|
+
# Example:
|
|
30
|
+
#
|
|
31
|
+
# class CounterAgent < Igniter::Agent
|
|
32
|
+
# initial_state counter: 0
|
|
33
|
+
#
|
|
34
|
+
# on :increment do |state:, payload:, **|
|
|
35
|
+
# state.merge(counter: state[:counter] + payload.fetch(:by, 1))
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# on :count do |state:, **|
|
|
39
|
+
# state[:counter] # returned as sync reply
|
|
40
|
+
# end
|
|
41
|
+
# end
|
|
42
|
+
#
|
|
43
|
+
# ref = CounterAgent.start
|
|
44
|
+
# ref.send(:increment, by: 5)
|
|
45
|
+
# ref.call(:count) # => 5
|
|
46
|
+
# ref.stop
|
|
47
|
+
#
|
|
48
|
+
class Agent
|
|
49
|
+
# ── Custom error types ────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
class MailboxFullError < Igniter::Error; end
|
|
52
|
+
class TimeoutError < Igniter::Error; end
|
|
53
|
+
|
|
54
|
+
# ── Class-level defaults ──────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
@handlers = {}
|
|
57
|
+
@timers = []
|
|
58
|
+
@default_state = {}
|
|
59
|
+
@mailbox_capacity = Mailbox::DEFAULT_CAPACITY
|
|
60
|
+
@mailbox_overflow = :block
|
|
61
|
+
@hooks = { start: [], crash: [], stop: [] }
|
|
62
|
+
|
|
63
|
+
class << self
|
|
64
|
+
attr_reader :handlers, :timers, :mailbox_capacity, :hooks
|
|
65
|
+
|
|
66
|
+
# ── Inheritance ─────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
def inherited(subclass)
|
|
69
|
+
super
|
|
70
|
+
subclass.instance_variable_set(:@handlers, {})
|
|
71
|
+
subclass.instance_variable_set(:@timers, [])
|
|
72
|
+
subclass.instance_variable_set(:@default_state, {})
|
|
73
|
+
subclass.instance_variable_set(:@mailbox_capacity, Mailbox::DEFAULT_CAPACITY)
|
|
74
|
+
subclass.instance_variable_set(:@mailbox_overflow, :block)
|
|
75
|
+
subclass.instance_variable_set(:@hooks, { start: [], crash: [], stop: [] })
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# ── DSL ─────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
# Set the initial state hash. Pass a plain Hash or a block returning one.
|
|
81
|
+
def initial_state(hash = nil, &block)
|
|
82
|
+
if block
|
|
83
|
+
@default_state_proc = block
|
|
84
|
+
else
|
|
85
|
+
@default_state = (hash || {}).freeze
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Return the resolved default state (evaluated fresh when a block was given).
|
|
90
|
+
def default_state
|
|
91
|
+
@default_state_proc ? @default_state_proc.call : @default_state
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Register a handler for messages of the given +type+.
|
|
95
|
+
def on(type, &handler)
|
|
96
|
+
@handlers[type.to_sym] = handler
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Register a recurring timer. The handler is called every +every+ seconds.
|
|
100
|
+
# Returning a Hash from the handler updates state; nil leaves it unchanged.
|
|
101
|
+
def schedule(name, every:, &handler)
|
|
102
|
+
@timers << { name: name.to_sym, interval: every.to_f, handler: handler }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Maximum number of messages in the mailbox before overflow policy applies.
|
|
106
|
+
def mailbox_size(capacity)
|
|
107
|
+
@mailbox_capacity = capacity
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Overflow policy when the mailbox is full.
|
|
111
|
+
# One of: :block (default), :drop_oldest, :drop_newest, :error
|
|
112
|
+
def mailbox_overflow(policy)
|
|
113
|
+
@mailbox_overflow = policy
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def after_start(&hook)
|
|
117
|
+
@hooks[:start] << hook
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def after_crash(&hook)
|
|
121
|
+
@hooks[:crash] << hook
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def after_stop(&hook)
|
|
125
|
+
@hooks[:stop] << hook
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# ── Factory ─────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
# Start the agent and return a Ref. The agent runs in a background Thread.
|
|
131
|
+
#
|
|
132
|
+
# Options:
|
|
133
|
+
# initial_state: Hash — override class-level default state
|
|
134
|
+
# on_crash: callable(error) — supervisor crash hook
|
|
135
|
+
# name: Symbol/String — register in Igniter::Registry under this name
|
|
136
|
+
#
|
|
137
|
+
def start(initial_state: nil, on_crash: nil, name: nil) # rubocop:disable Metrics/MethodLength
|
|
138
|
+
state_holder = StateHolder.new(initial_state || default_state)
|
|
139
|
+
mailbox = Mailbox.new(capacity: mailbox_capacity, overflow: @mailbox_overflow)
|
|
140
|
+
runner = Runner.new(
|
|
141
|
+
agent_class: self,
|
|
142
|
+
mailbox: mailbox,
|
|
143
|
+
state_holder: state_holder,
|
|
144
|
+
on_crash: on_crash
|
|
145
|
+
)
|
|
146
|
+
thread = runner.start
|
|
147
|
+
ref = Ref.new(thread: thread, mailbox: mailbox, state_holder: state_holder)
|
|
148
|
+
|
|
149
|
+
Igniter::Registry.register!(name, ref) if name
|
|
150
|
+
|
|
151
|
+
ref
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -14,14 +14,32 @@ module Igniter
|
|
|
14
14
|
|
|
15
15
|
def call
|
|
16
16
|
@context.runtime_nodes.each do |node|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
if node.kind == :compute
|
|
18
|
+
validate_callable_signature!(node)
|
|
19
|
+
elsif node.kind == :effect
|
|
20
|
+
validate_effect_adapter!(node)
|
|
21
|
+
end
|
|
20
22
|
end
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
private
|
|
24
26
|
|
|
27
|
+
def validate_effect_adapter!(node)
|
|
28
|
+
adapter = node.adapter_class
|
|
29
|
+
unless adapter.is_a?(Class) && adapter <= Igniter::Effect
|
|
30
|
+
raise @context.validation_error(
|
|
31
|
+
node,
|
|
32
|
+
"Effect '#{node.name}' adapter must be a subclass of Igniter::Effect"
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
validate_parameters_signature!(
|
|
37
|
+
node,
|
|
38
|
+
adapter.instance_method(:call).parameters,
|
|
39
|
+
adapter.name || "effect"
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
25
43
|
def validate_callable_signature!(node)
|
|
26
44
|
callable = node.callable
|
|
27
45
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Differential
|
|
5
|
+
# Captures a single output that differed between primary and candidate.
|
|
6
|
+
class Divergence
|
|
7
|
+
attr_reader :output_name, :primary_value, :candidate_value, :kind
|
|
8
|
+
|
|
9
|
+
# @param output_name [Symbol]
|
|
10
|
+
# @param primary_value [Object]
|
|
11
|
+
# @param candidate_value [Object]
|
|
12
|
+
# @param kind [Symbol] :value_mismatch | :type_mismatch
|
|
13
|
+
def initialize(output_name:, primary_value:, candidate_value:, kind:)
|
|
14
|
+
@output_name = output_name
|
|
15
|
+
@primary_value = primary_value
|
|
16
|
+
@candidate_value = candidate_value
|
|
17
|
+
@kind = kind
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Numeric difference (candidate − primary). nil for non-numeric values.
|
|
22
|
+
def delta
|
|
23
|
+
return nil unless primary_value.is_a?(Numeric) && candidate_value.is_a?(Numeric)
|
|
24
|
+
|
|
25
|
+
candidate_value - primary_value
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Differential
|
|
5
|
+
# Renders a Differential::Report as a human-readable text block.
|
|
6
|
+
#
|
|
7
|
+
# Example:
|
|
8
|
+
#
|
|
9
|
+
# Primary: PricingV1
|
|
10
|
+
# Candidate: PricingV2
|
|
11
|
+
# Match: NO
|
|
12
|
+
#
|
|
13
|
+
# DIVERGENCES (1):
|
|
14
|
+
# :tax
|
|
15
|
+
# primary: 15.0
|
|
16
|
+
# candidate: 22.5
|
|
17
|
+
# delta: +7.5
|
|
18
|
+
#
|
|
19
|
+
# CANDIDATE ONLY (1):
|
|
20
|
+
# :discount = 10.0
|
|
21
|
+
#
|
|
22
|
+
module Formatter
|
|
23
|
+
VALUE_MAX = 60
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
def format(report) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
27
|
+
lines = []
|
|
28
|
+
lines << "Primary: #{report.primary_class.name}"
|
|
29
|
+
lines << "Candidate: #{report.candidate_class.name}"
|
|
30
|
+
lines << "Match: #{report.match? ? "YES" : "NO"}"
|
|
31
|
+
|
|
32
|
+
if report.primary_error
|
|
33
|
+
lines << ""
|
|
34
|
+
lines << "PRIMARY ERROR: #{report.primary_error.message}"
|
|
35
|
+
return lines.join("\n")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if report.candidate_error
|
|
39
|
+
lines << ""
|
|
40
|
+
lines << "CANDIDATE ERROR: #{report.candidate_error.message}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
lines << ""
|
|
44
|
+
|
|
45
|
+
if report.divergences.empty? && report.primary_only.empty? && report.candidate_only.empty?
|
|
46
|
+
lines << "All shared outputs match."
|
|
47
|
+
else
|
|
48
|
+
append_divergences(report, lines)
|
|
49
|
+
append_only_section("CANDIDATE ONLY", report.candidate_only, lines)
|
|
50
|
+
append_only_section("PRIMARY ONLY", report.primary_only, lines)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
lines.join("\n")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def append_divergences(report, lines) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
59
|
+
return if report.divergences.empty?
|
|
60
|
+
|
|
61
|
+
lines << "DIVERGENCES (#{report.divergences.size}):"
|
|
62
|
+
report.divergences.each do |div|
|
|
63
|
+
lines << " :#{div.output_name}"
|
|
64
|
+
lines << " primary: #{fmt(div.primary_value)}"
|
|
65
|
+
lines << " candidate: #{fmt(div.candidate_value)}"
|
|
66
|
+
next unless div.delta
|
|
67
|
+
|
|
68
|
+
d = div.delta
|
|
69
|
+
lines << " delta: #{d >= 0 ? "+#{d}" : d}"
|
|
70
|
+
end
|
|
71
|
+
lines << ""
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def append_only_section(label, hash, lines)
|
|
75
|
+
return if hash.empty?
|
|
76
|
+
|
|
77
|
+
lines << "#{label} (#{hash.size}):"
|
|
78
|
+
hash.each { |name, val| lines << " :#{name} = #{fmt(val)}" }
|
|
79
|
+
lines << ""
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def fmt(value) # rubocop:disable Metrics/CyclomaticComplexity
|
|
83
|
+
str = case value
|
|
84
|
+
when nil then "nil"
|
|
85
|
+
when String then value.inspect
|
|
86
|
+
when Symbol then value.inspect
|
|
87
|
+
when Hash then "{#{value.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")}}"
|
|
88
|
+
when Array then "[#{value.map(&:inspect).join(", ")}]"
|
|
89
|
+
else value.inspect
|
|
90
|
+
end
|
|
91
|
+
str.length > VALUE_MAX ? "#{str[0, VALUE_MAX - 3]}..." : str
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Differential
|
|
5
|
+
# Structured result of comparing two contract implementations.
|
|
6
|
+
#
|
|
7
|
+
# Attributes:
|
|
8
|
+
# primary_class — the reference contract class
|
|
9
|
+
# candidate_class — the contract being validated against the primary
|
|
10
|
+
# inputs — Hash of inputs used for both executions
|
|
11
|
+
# divergences — Array<Divergence> for outputs that differ in value
|
|
12
|
+
# primary_only — Hash{ Symbol => value } outputs absent in candidate
|
|
13
|
+
# candidate_only — Hash{ Symbol => value } outputs absent in primary
|
|
14
|
+
# primary_error — Igniter::Error raised by primary (usually nil)
|
|
15
|
+
# candidate_error — Igniter::Error raised by candidate (nil on success)
|
|
16
|
+
class Report
|
|
17
|
+
attr_reader :primary_class, :candidate_class, :inputs,
|
|
18
|
+
:divergences, :primary_only, :candidate_only,
|
|
19
|
+
:primary_error, :candidate_error
|
|
20
|
+
|
|
21
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
22
|
+
primary_class:, candidate_class:, inputs:,
|
|
23
|
+
divergences:, primary_only:, candidate_only:,
|
|
24
|
+
primary_error: nil, candidate_error: nil
|
|
25
|
+
)
|
|
26
|
+
@primary_class = primary_class
|
|
27
|
+
@candidate_class = candidate_class
|
|
28
|
+
@inputs = inputs
|
|
29
|
+
@divergences = divergences.freeze
|
|
30
|
+
@primary_only = primary_only.freeze
|
|
31
|
+
@candidate_only = candidate_only.freeze
|
|
32
|
+
@primary_error = primary_error
|
|
33
|
+
@candidate_error = candidate_error
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# True when candidate produces identical outputs with no errors.
|
|
38
|
+
def match?
|
|
39
|
+
divergences.empty? &&
|
|
40
|
+
primary_only.empty? &&
|
|
41
|
+
candidate_only.empty? &&
|
|
42
|
+
primary_error.nil? &&
|
|
43
|
+
candidate_error.nil?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# One-line summary suitable for logging.
|
|
47
|
+
def summary # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
48
|
+
if match?
|
|
49
|
+
"match"
|
|
50
|
+
else
|
|
51
|
+
parts = []
|
|
52
|
+
parts << "#{divergences.size} value(s) differ" if divergences.any?
|
|
53
|
+
parts << "#{primary_only.size} output(s) only in primary" if primary_only.any?
|
|
54
|
+
parts << "#{candidate_only.size} output(s) only in candidate" if candidate_only.any?
|
|
55
|
+
parts << "candidate error: #{candidate_error.message}" if candidate_error
|
|
56
|
+
parts << "primary error: #{primary_error.message}" if primary_error
|
|
57
|
+
"diverged — #{parts.join(", ")}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Human-readable ASCII report.
|
|
62
|
+
def explain
|
|
63
|
+
Formatter.format(self)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
alias to_s explain
|
|
67
|
+
|
|
68
|
+
# Structured (serialisable) representation.
|
|
69
|
+
def to_h # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
70
|
+
{
|
|
71
|
+
primary: primary_class.name,
|
|
72
|
+
candidate: candidate_class.name,
|
|
73
|
+
match: match?,
|
|
74
|
+
divergences: divergences.map do |d|
|
|
75
|
+
{ output: d.output_name, primary: d.primary_value, candidate: d.candidate_value,
|
|
76
|
+
kind: d.kind, delta: d.delta }
|
|
77
|
+
end,
|
|
78
|
+
primary_only: primary_only,
|
|
79
|
+
candidate_only: candidate_only,
|
|
80
|
+
primary_error: primary_error&.message,
|
|
81
|
+
candidate_error: candidate_error&.message
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Differential
|
|
5
|
+
# Executes two contract classes with identical inputs and builds a Report.
|
|
6
|
+
#
|
|
7
|
+
# Uses Thread.current[:igniter_skip_shadow] to prevent recursive shadow
|
|
8
|
+
# execution when a contract with shadow_with is run inside the runner.
|
|
9
|
+
class Runner
|
|
10
|
+
def initialize(primary_class, candidate_class, tolerance: nil)
|
|
11
|
+
@primary_class = primary_class
|
|
12
|
+
@candidate_class = candidate_class
|
|
13
|
+
@tolerance = tolerance
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Execute both contracts fresh from +inputs+ and compare outputs.
|
|
17
|
+
def run(inputs)
|
|
18
|
+
primary_exec, primary_error = execute(@primary_class, inputs)
|
|
19
|
+
candidate_exec, candidate_error = execute(@candidate_class, inputs)
|
|
20
|
+
|
|
21
|
+
primary_outputs = primary_exec ? extract_outputs(primary_exec) : {}
|
|
22
|
+
candidate_outputs = candidate_exec ? extract_outputs(candidate_exec) : {}
|
|
23
|
+
|
|
24
|
+
build_report(primary_outputs, candidate_outputs, inputs, primary_error, candidate_error)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Compare using an already-resolved primary execution (avoids re-running
|
|
28
|
+
# the primary contract and its side effects a second time).
|
|
29
|
+
def run_with_primary_execution(primary_execution, inputs)
|
|
30
|
+
primary_outputs = extract_outputs(primary_execution)
|
|
31
|
+
candidate_exec, candidate_error = execute(@candidate_class, inputs)
|
|
32
|
+
candidate_outputs = candidate_exec ? extract_outputs(candidate_exec) : {}
|
|
33
|
+
|
|
34
|
+
build_report(primary_outputs, candidate_outputs, inputs, nil, candidate_error)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Execute +klass+ with +inputs+, suppressing shadow execution to prevent
|
|
40
|
+
# recursive comparisons. Returns [execution, nil] on success or
|
|
41
|
+
# [nil, error] if the contract raises.
|
|
42
|
+
def execute(klass, inputs)
|
|
43
|
+
Thread.current[:igniter_skip_shadow] = true
|
|
44
|
+
contract = klass.new(inputs)
|
|
45
|
+
contract.resolve_all
|
|
46
|
+
[contract.execution, nil]
|
|
47
|
+
rescue Igniter::Error => e
|
|
48
|
+
[nil, e]
|
|
49
|
+
ensure
|
|
50
|
+
Thread.current[:igniter_skip_shadow] = nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Read all output values from a resolved execution's cache.
|
|
54
|
+
# Output nodes live in graph.outputs (not graph.nodes).
|
|
55
|
+
# Each output node's source_root (Symbol) names the computation node in cache.
|
|
56
|
+
def extract_outputs(execution)
|
|
57
|
+
graph = execution.compiled_graph
|
|
58
|
+
cache = execution.cache
|
|
59
|
+
|
|
60
|
+
graph.outputs.each_with_object({}) do |node, acc|
|
|
61
|
+
state = cache.fetch(node.source_root)
|
|
62
|
+
acc[node.name] = normalize_value(state&.value)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Flatten Runtime wrapper objects to plain Ruby values so that structural
|
|
67
|
+
# equality works across independently-resolved executions.
|
|
68
|
+
def normalize_value(val)
|
|
69
|
+
case val
|
|
70
|
+
when Runtime::Result then val.to_h
|
|
71
|
+
when Runtime::CollectionResult then val.summary
|
|
72
|
+
when Runtime::DeferredResult then { pending: true, event: val.waiting_on }
|
|
73
|
+
else val
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def build_report(primary_outputs, candidate_outputs, inputs, primary_error, candidate_error) # rubocop:disable Metrics/MethodLength
|
|
78
|
+
common = primary_outputs.keys & candidate_outputs.keys
|
|
79
|
+
divergences = compare_common(primary_outputs, candidate_outputs, common)
|
|
80
|
+
primary_only = slice_missing(primary_outputs, candidate_outputs)
|
|
81
|
+
candidate_only = slice_missing(candidate_outputs, primary_outputs)
|
|
82
|
+
|
|
83
|
+
Report.new(
|
|
84
|
+
primary_class: @primary_class,
|
|
85
|
+
candidate_class: @candidate_class,
|
|
86
|
+
inputs: inputs,
|
|
87
|
+
divergences: divergences,
|
|
88
|
+
primary_only: primary_only,
|
|
89
|
+
candidate_only: candidate_only,
|
|
90
|
+
primary_error: primary_error,
|
|
91
|
+
candidate_error: candidate_error
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Build Divergence objects for keys present in both hashes but with
|
|
96
|
+
# differing values.
|
|
97
|
+
def compare_common(primary, candidate, keys) # rubocop:disable Metrics/MethodLength
|
|
98
|
+
keys.filter_map do |key|
|
|
99
|
+
pval = primary[key]
|
|
100
|
+
cval = candidate[key]
|
|
101
|
+
next if values_match?(pval, cval)
|
|
102
|
+
|
|
103
|
+
Divergence.new(
|
|
104
|
+
output_name: key,
|
|
105
|
+
primary_value: pval,
|
|
106
|
+
candidate_value: cval,
|
|
107
|
+
kind: divergence_kind(pval, cval)
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Returns a hash of keys that exist in +source+ but are absent in +other+.
|
|
113
|
+
def slice_missing(source, other)
|
|
114
|
+
(source.keys - other.keys).each_with_object({}) { |k, h| h[k] = source[k] }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def values_match?(lhs, rhs)
|
|
118
|
+
return true if lhs == rhs
|
|
119
|
+
return false unless @tolerance
|
|
120
|
+
return false unless lhs.is_a?(Numeric) && rhs.is_a?(Numeric)
|
|
121
|
+
|
|
122
|
+
(lhs - rhs).abs <= @tolerance
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def divergence_kind(lhs, rhs)
|
|
126
|
+
lhs.instance_of?(rhs.class) ? :value_mismatch : :type_mismatch
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|