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,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ class Agent
5
+ # External handle to a running agent.
6
+ #
7
+ # Callers interact with agents exclusively through Ref — they never touch
8
+ # the Mailbox, StateHolder, or Thread directly. This allows the Supervisor
9
+ # to swap out internals on restart without invalidating existing Ref objects.
10
+ class Ref
11
+ def initialize(thread:, mailbox:, state_holder:)
12
+ @thread = thread
13
+ @mailbox = mailbox
14
+ @state_holder = state_holder
15
+ @mutex = Mutex.new
16
+ end
17
+
18
+ # Asynchronous fire-and-forget. Returns self.
19
+ def send(type, payload = {})
20
+ mailbox.push(Message.new(type: type.to_sym, payload: payload))
21
+ self
22
+ end
23
+
24
+ # Synchronous request-reply. Blocks until the handler responds or timeout
25
+ # elapses. Raises Igniter::Agent::TimeoutError on timeout.
26
+ def call(type, payload = {}, timeout: 5)
27
+ reply_box = Mailbox.new(capacity: 1, overflow: :drop_newest)
28
+ mailbox.push(Message.new(type: type.to_sym, payload: payload, reply_to: reply_box))
29
+ reply = reply_box.pop(timeout: timeout)
30
+ raise TimeoutError, "Agent did not reply within #{timeout}s" unless reply
31
+
32
+ reply.payload[:value]
33
+ end
34
+
35
+ # Request graceful shutdown. Closes the mailbox so the runner exits after
36
+ # processing any in-flight message. Blocks until the thread finishes.
37
+ def stop(timeout: 5)
38
+ mailbox.close
39
+ thread&.join(timeout)
40
+ self
41
+ end
42
+
43
+ # Forcefully terminate the agent thread.
44
+ def kill
45
+ thread&.kill
46
+ mailbox.close
47
+ self
48
+ end
49
+
50
+ def alive?
51
+ thread&.alive? || false
52
+ end
53
+
54
+ # Read the current state snapshot without blocking the agent.
55
+ def state
56
+ state_holder.get
57
+ end
58
+
59
+ # Supervisor-internal: swap out internals when the agent restarts.
60
+ # Callers keep the same Ref object; the new thread/mailbox/state_holder
61
+ # are transparently injected.
62
+ def rebind(thread:, mailbox:, state_holder:)
63
+ @mutex.synchronize do
64
+ @thread = thread
65
+ @mailbox = mailbox
66
+ @state_holder = state_holder
67
+ end
68
+ self
69
+ end
70
+
71
+ private
72
+
73
+ def thread
74
+ @mutex.synchronize { @thread }
75
+ end
76
+
77
+ def mailbox
78
+ @mutex.synchronize { @mailbox }
79
+ end
80
+
81
+ def state_holder
82
+ @mutex.synchronize { @state_holder }
83
+ end
84
+ end
85
+ end
86
+ end
@@ -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
- next unless node.kind == :compute
18
-
19
- validate_callable_signature!(node)
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