igniter 0.4.3 → 0.5.0

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 (162) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +217 -0
  3. data/docs/APPLICATION_V1.md +253 -0
  4. data/docs/CAPABILITIES_V1.md +207 -0
  5. data/docs/CONSENSUS_V1.md +477 -0
  6. data/docs/CONTENT_ADDRESSING_V1.md +221 -0
  7. data/docs/DATAFLOW_V1.md +274 -0
  8. data/docs/MESH_V1.md +732 -0
  9. data/docs/NODE_CACHE_V1.md +324 -0
  10. data/docs/PROACTIVE_AGENTS_V1.md +293 -0
  11. data/docs/SERVER_V1.md +200 -1
  12. data/docs/SKILLS_V1.md +213 -0
  13. data/docs/STORE_ADAPTERS.md +41 -13
  14. data/docs/TEMPORAL_V1.md +174 -0
  15. data/docs/TOOLS_V1.md +347 -0
  16. data/docs/TRANSCRIPTION_V1.md +403 -0
  17. data/examples/README.md +37 -0
  18. data/examples/consensus.rb +239 -0
  19. data/examples/dataflow.rb +308 -0
  20. data/examples/elocal_webhook.rb +1 -0
  21. data/examples/incremental.rb +142 -0
  22. data/examples/llm_tools.rb +237 -0
  23. data/examples/mesh.rb +239 -0
  24. data/examples/mesh_discovery.rb +267 -0
  25. data/examples/mesh_gossip.rb +162 -0
  26. data/examples/ringcentral_routing.rb +1 -1
  27. data/lib/igniter/agents/ai/alert_agent.rb +111 -0
  28. data/lib/igniter/agents/ai/chain_agent.rb +127 -0
  29. data/lib/igniter/agents/ai/critic_agent.rb +163 -0
  30. data/lib/igniter/agents/ai/evaluator_agent.rb +193 -0
  31. data/lib/igniter/agents/ai/evolution_agent.rb +286 -0
  32. data/lib/igniter/agents/ai/health_check_agent.rb +122 -0
  33. data/lib/igniter/agents/ai/observer_agent.rb +184 -0
  34. data/lib/igniter/agents/ai/planner_agent.rb +210 -0
  35. data/lib/igniter/agents/ai/router_agent.rb +131 -0
  36. data/lib/igniter/agents/ai/self_reflection_agent.rb +175 -0
  37. data/lib/igniter/agents/observability/metrics_agent.rb +130 -0
  38. data/lib/igniter/agents/pipeline/batch_processor_agent.rb +131 -0
  39. data/lib/igniter/agents/proactive_agent.rb +208 -0
  40. data/lib/igniter/agents/reliability/retry_agent.rb +99 -0
  41. data/lib/igniter/agents/scheduling/cron_agent.rb +110 -0
  42. data/lib/igniter/agents.rb +56 -0
  43. data/lib/igniter/application/app_config.rb +32 -0
  44. data/lib/igniter/application/autoloader.rb +18 -0
  45. data/lib/igniter/application/generator.rb +157 -0
  46. data/lib/igniter/application/scheduler.rb +109 -0
  47. data/lib/igniter/application/yml_loader.rb +39 -0
  48. data/lib/igniter/application.rb +174 -0
  49. data/lib/igniter/capabilities.rb +68 -0
  50. data/lib/igniter/compiler/validators/dependencies_validator.rb +50 -2
  51. data/lib/igniter/compiler/validators/remote_validator.rb +2 -0
  52. data/lib/igniter/consensus/cluster.rb +183 -0
  53. data/lib/igniter/consensus/errors.rb +14 -0
  54. data/lib/igniter/consensus/executors.rb +43 -0
  55. data/lib/igniter/consensus/node.rb +320 -0
  56. data/lib/igniter/consensus/read_query.rb +30 -0
  57. data/lib/igniter/consensus/state_machine.rb +58 -0
  58. data/lib/igniter/consensus.rb +58 -0
  59. data/lib/igniter/content_addressing.rb +133 -0
  60. data/lib/igniter/contract.rb +12 -0
  61. data/lib/igniter/dataflow/aggregate_operators.rb +147 -0
  62. data/lib/igniter/dataflow/aggregate_state.rb +77 -0
  63. data/lib/igniter/dataflow/diff.rb +37 -0
  64. data/lib/igniter/dataflow/diff_state.rb +81 -0
  65. data/lib/igniter/dataflow/incremental_collection_result.rb +39 -0
  66. data/lib/igniter/dataflow/window_filter.rb +48 -0
  67. data/lib/igniter/dataflow.rb +65 -0
  68. data/lib/igniter/dsl/contract_builder.rb +71 -7
  69. data/lib/igniter/executor.rb +60 -0
  70. data/lib/igniter/extensions/capabilities.rb +39 -0
  71. data/lib/igniter/extensions/content_addressing.rb +5 -0
  72. data/lib/igniter/extensions/dataflow.rb +117 -0
  73. data/lib/igniter/extensions/incremental.rb +50 -0
  74. data/lib/igniter/extensions/mesh.rb +31 -0
  75. data/lib/igniter/fingerprint.rb +43 -0
  76. data/lib/igniter/incremental/formatter.rb +81 -0
  77. data/lib/igniter/incremental/result.rb +69 -0
  78. data/lib/igniter/incremental/tracker.rb +108 -0
  79. data/lib/igniter/incremental.rb +50 -0
  80. data/lib/igniter/integrations/llm/config.rb +48 -4
  81. data/lib/igniter/integrations/llm/executor.rb +221 -28
  82. data/lib/igniter/integrations/llm/providers/anthropic.rb +37 -4
  83. data/lib/igniter/integrations/llm/providers/openai.rb +34 -5
  84. data/lib/igniter/integrations/llm/transcription/providers/assemblyai.rb +200 -0
  85. data/lib/igniter/integrations/llm/transcription/providers/base.rb +122 -0
  86. data/lib/igniter/integrations/llm/transcription/providers/deepgram.rb +162 -0
  87. data/lib/igniter/integrations/llm/transcription/providers/openai.rb +102 -0
  88. data/lib/igniter/integrations/llm/transcription/transcriber.rb +145 -0
  89. data/lib/igniter/integrations/llm/transcription/transcript_result.rb +29 -0
  90. data/lib/igniter/integrations/llm.rb +37 -1
  91. data/lib/igniter/memory/agent_memory.rb +104 -0
  92. data/lib/igniter/memory/episode.rb +29 -0
  93. data/lib/igniter/memory/fact.rb +27 -0
  94. data/lib/igniter/memory/memorable.rb +90 -0
  95. data/lib/igniter/memory/reflection_cycle.rb +96 -0
  96. data/lib/igniter/memory/reflection_record.rb +28 -0
  97. data/lib/igniter/memory/store.rb +115 -0
  98. data/lib/igniter/memory/stores/in_memory.rb +136 -0
  99. data/lib/igniter/memory/stores/sqlite.rb +284 -0
  100. data/lib/igniter/memory.rb +80 -0
  101. data/lib/igniter/mesh/announcer.rb +55 -0
  102. data/lib/igniter/mesh/config.rb +45 -0
  103. data/lib/igniter/mesh/discovery.rb +39 -0
  104. data/lib/igniter/mesh/errors.rb +31 -0
  105. data/lib/igniter/mesh/gossip.rb +47 -0
  106. data/lib/igniter/mesh/peer.rb +21 -0
  107. data/lib/igniter/mesh/peer_registry.rb +51 -0
  108. data/lib/igniter/mesh/poller.rb +77 -0
  109. data/lib/igniter/mesh/router.rb +109 -0
  110. data/lib/igniter/mesh.rb +85 -0
  111. data/lib/igniter/metrics/collector.rb +131 -0
  112. data/lib/igniter/metrics/prometheus_exporter.rb +104 -0
  113. data/lib/igniter/metrics/snapshot.rb +8 -0
  114. data/lib/igniter/metrics.rb +37 -0
  115. data/lib/igniter/model/aggregate_node.rb +34 -0
  116. data/lib/igniter/model/collection_node.rb +3 -2
  117. data/lib/igniter/model/compute_node.rb +13 -0
  118. data/lib/igniter/model/remote_node.rb +18 -2
  119. data/lib/igniter/node_cache.rb +231 -0
  120. data/lib/igniter/replication/bootstrapper.rb +61 -0
  121. data/lib/igniter/replication/bootstrappers/gem.rb +32 -0
  122. data/lib/igniter/replication/bootstrappers/git.rb +39 -0
  123. data/lib/igniter/replication/bootstrappers/tarball.rb +56 -0
  124. data/lib/igniter/replication/expansion_plan.rb +38 -0
  125. data/lib/igniter/replication/expansion_planner.rb +142 -0
  126. data/lib/igniter/replication/manifest.rb +45 -0
  127. data/lib/igniter/replication/network_topology.rb +123 -0
  128. data/lib/igniter/replication/node_role.rb +42 -0
  129. data/lib/igniter/replication/reflective_replication_agent.rb +238 -0
  130. data/lib/igniter/replication/replication_agent.rb +87 -0
  131. data/lib/igniter/replication/role_registry.rb +73 -0
  132. data/lib/igniter/replication/ssh_session.rb +77 -0
  133. data/lib/igniter/replication.rb +54 -0
  134. data/lib/igniter/runtime/cache.rb +35 -6
  135. data/lib/igniter/runtime/execution.rb +26 -2
  136. data/lib/igniter/runtime/input_validator.rb +6 -2
  137. data/lib/igniter/runtime/node_state.rb +7 -2
  138. data/lib/igniter/runtime/resolver.rb +323 -31
  139. data/lib/igniter/runtime/stores/redis_store.rb +41 -4
  140. data/lib/igniter/server/client.rb +44 -1
  141. data/lib/igniter/server/config.rb +13 -6
  142. data/lib/igniter/server/handlers/event_handler.rb +4 -0
  143. data/lib/igniter/server/handlers/execute_handler.rb +6 -0
  144. data/lib/igniter/server/handlers/liveness_handler.rb +20 -0
  145. data/lib/igniter/server/handlers/manifest_handler.rb +34 -0
  146. data/lib/igniter/server/handlers/metrics_handler.rb +51 -0
  147. data/lib/igniter/server/handlers/peers_handler.rb +115 -0
  148. data/lib/igniter/server/handlers/readiness_handler.rb +47 -0
  149. data/lib/igniter/server/http_server.rb +54 -17
  150. data/lib/igniter/server/router.rb +54 -21
  151. data/lib/igniter/server/server_logger.rb +52 -0
  152. data/lib/igniter/server.rb +6 -0
  153. data/lib/igniter/skill/feedback.rb +116 -0
  154. data/lib/igniter/skill/output_schema.rb +110 -0
  155. data/lib/igniter/skill.rb +218 -0
  156. data/lib/igniter/temporal.rb +84 -0
  157. data/lib/igniter/tool/discoverable.rb +151 -0
  158. data/lib/igniter/tool.rb +52 -0
  159. data/lib/igniter/tool_registry.rb +144 -0
  160. data/lib/igniter/version.rb +1 -1
  161. data/lib/igniter.rb +17 -0
  162. metadata +128 -1
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "igniter"
4
+ require "igniter/integrations/agents"
5
+ require_relative "consensus/errors"
6
+ require_relative "consensus/state_machine"
7
+ require_relative "consensus/node"
8
+ require_relative "consensus/cluster"
9
+ require_relative "consensus/executors"
10
+ require_relative "consensus/read_query"
11
+
12
+ module Igniter
13
+ # Consensus protocol primitives built on Igniter::Agent and Igniter::Contract.
14
+ #
15
+ # Provides a Raft-inspired cluster where:
16
+ # - +Igniter::Consensus::Node+ encapsulates the full Raft protocol as an Agent
17
+ # - +Igniter::Consensus::Cluster+ manages node lifecycle + high-level read/write
18
+ # - +Igniter::Consensus::StateMachine+ lets users define custom command reducers
19
+ # - +Igniter::Consensus::ReadQuery+ is a ready-made Contract for cluster reads
20
+ #
21
+ # == Minimal example
22
+ #
23
+ # require "igniter/consensus"
24
+ #
25
+ # cluster = Igniter::Consensus::Cluster.start(nodes: %i[n1 n2 n3 n4 n5])
26
+ # cluster.wait_for_leader
27
+ #
28
+ # cluster.write(key: :price, value: 99) # default KV protocol
29
+ # cluster.read(:price) # => 99
30
+ #
31
+ # cluster.stop!
32
+ #
33
+ # == Custom state machine
34
+ #
35
+ # class PriceStore < Igniter::Consensus::StateMachine
36
+ # apply :set do |state, cmd| state.merge(cmd[:key] => cmd[:value]) end
37
+ # apply :delete do |state, cmd| state.reject { |k, _| k == cmd[:key] } end
38
+ # end
39
+ #
40
+ # cluster = Igniter::Consensus::Cluster.start(
41
+ # nodes: %i[n1 n2 n3 n4 n5],
42
+ # state_machine: PriceStore,
43
+ # )
44
+ # cluster.write(type: :set, key: :price, value: 99)
45
+ #
46
+ # == Contract integration
47
+ #
48
+ # class PriceCheck < Igniter::Contract
49
+ # define do
50
+ # input :cluster
51
+ # compute :leader, with: :cluster, call: Igniter::Consensus::FindLeader
52
+ # compute :price, with: [:leader], call: MyPriceReader
53
+ # output :price
54
+ # end
55
+ # end
56
+ module Consensus
57
+ end
58
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Igniter
6
+ # Content-addressed computation cache for pure executors.
7
+ #
8
+ # When an executor is declared `pure`, its output is fully determined by:
9
+ # 1. The executor's content fingerprint (class name or explicit version string)
10
+ # 2. The serialized dependency values
11
+ #
12
+ # The content key is SHA-256(fingerprint + serialized_deps), truncated to 24 hex chars.
13
+ # This key is used as a universal cache key — valid across executions, processes, and nodes.
14
+ #
15
+ # == Usage
16
+ #
17
+ # require "igniter/extensions/content_addressing"
18
+ #
19
+ # class TaxCalculator < Igniter::Executor
20
+ # pure # enables content-addressed caching
21
+ # fingerprint "tax_calc_v1" # optional explicit version (invalidates cache on bump)
22
+ #
23
+ # def call(country:, amount:)
24
+ # TAX_RATES[country] * amount # deterministic — same inputs, same output
25
+ # end
26
+ # end
27
+ #
28
+ # On first call with a given (country, amount) pair, the result is computed and cached.
29
+ # Subsequent calls (even in different executions) return the cached value instantly.
30
+ #
31
+ # == Shared cache (distributed nodes)
32
+ #
33
+ # Replace the default in-process cache with a Redis-backed one:
34
+ #
35
+ # Igniter::ContentAddressing.cache = MyRedisContentCache.new(redis: Redis.new)
36
+ # # Must implement: #fetch(key) → value | nil, #store(key, value)
37
+ module ContentAddressing
38
+ # Immutable content key derived from executor fingerprint + serialized dep values.
39
+ class ContentKey
40
+ attr_reader :hex
41
+
42
+ def initialize(hex)
43
+ @hex = hex.freeze
44
+ freeze
45
+ end
46
+
47
+ def to_s = "ca:#{@hex}"
48
+ def inspect = "#<ContentKey #{self}>"
49
+ def ==(other) = other.is_a?(ContentKey) && other.hex == hex
50
+
51
+ class << self
52
+ # Compute a ContentKey from an executor class and its resolved dependency values.
53
+ def compute(executor_class, dep_values)
54
+ fp = executor_class.content_fingerprint
55
+ deps = stable_serialize(dep_values)
56
+ hex = Digest::SHA256.hexdigest("#{fp}\x00#{deps}")[0..23]
57
+ new(hex)
58
+ end
59
+
60
+ private
61
+
62
+ # Serialize a value to a stable, order-independent string.
63
+ # Used as part of the content key — must produce identical output for equal values.
64
+ def stable_serialize(val) # rubocop:disable Metrics/CyclomaticComplexity
65
+ case val
66
+ when Hash
67
+ pairs = val.sort_by { |k, _| k.to_s }.map { |k, v| "#{k}:#{stable_serialize(v)}" }
68
+ "{#{pairs.join(",")}}"
69
+ when Array then "[#{val.map { |v| stable_serialize(v) }.join(",")}]"
70
+ when String then val.inspect
71
+ when Symbol then ":#{val}"
72
+ when Numeric, NilClass, TrueClass, FalseClass then val.inspect
73
+ else val.hash.to_s
74
+ end
75
+ end
76
+ end
77
+ end
78
+
79
+ # Thread-safe in-process content cache.
80
+ # Replace with a distributed implementation to share results across nodes.
81
+ class Cache
82
+ def initialize
83
+ @store = {}
84
+ @mu = Mutex.new
85
+ @hits = 0
86
+ @misses = 0
87
+ end
88
+
89
+ # Retrieve a cached value. Returns nil on miss.
90
+ def fetch(key)
91
+ @mu.synchronize do
92
+ val = @store[key.hex]
93
+ if val.nil?
94
+ @misses += 1
95
+ nil
96
+ else
97
+ @hits += 1
98
+ val
99
+ end
100
+ end
101
+ end
102
+
103
+ # Store a value under the given content key.
104
+ def store(key, value)
105
+ @mu.synchronize { @store[key.hex] = value }
106
+ end
107
+
108
+ def size = @mu.synchronize { @store.size }
109
+
110
+ def clear
111
+ @mu.synchronize do
112
+ @store.clear
113
+ @hits = 0
114
+ @misses = 0
115
+ end
116
+ end
117
+
118
+ def stats
119
+ @mu.synchronize { { size: @store.size, hits: @hits, misses: @misses } }
120
+ end
121
+ end
122
+
123
+ class << self
124
+ # Global content cache. Default: thread-safe in-process Hash.
125
+ # Can be replaced with any object responding to #fetch(key) and #store(key, value).
126
+ attr_writer :cache
127
+
128
+ def cache
129
+ @cache ||= Cache.new
130
+ end
131
+ end
132
+ end
133
+ end
@@ -23,6 +23,18 @@ module Igniter
23
23
  @execution_options = { runner: runner, max_workers: max_workers }.compact
24
24
  end
25
25
 
26
+ # Ergonomic alias for run_with. Accepts pool_size: as a clearer name for
27
+ # max_workers: when using the thread_pool runner.
28
+ #
29
+ # class MyContract < Igniter::Contract
30
+ # runner :thread_pool, pool_size: 4
31
+ # define do ... end
32
+ # end
33
+ def runner(strategy, pool_size: nil, max_workers: nil, **opts)
34
+ workers = pool_size || max_workers
35
+ @execution_options = { runner: strategy, max_workers: workers }.merge(opts).compact
36
+ end
37
+
26
38
  def restore_from_store(execution_id, store: nil)
27
39
  snapshot = (store || Igniter.execution_store).fetch(execution_id)
28
40
  restore(snapshot)
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Dataflow
5
+ # Built-in operator strategies for AggregateNode.
6
+ #
7
+ # Each operator is an Operator struct with:
8
+ # initial_fn – Proc returning a fresh initial accumulator value
9
+ # project – Proc(item) → contribution (what the item "contributes")
10
+ # add – Proc(acc, contribution) → new_acc (nil when recompute: true)
11
+ # remove – Proc(acc, contribution) → new_acc (nil when recompute: true)
12
+ # finalize – Proc(acc, contributions_or_count) → public value
13
+ # recompute – Boolean: when true, finalize receives the full @contributions Hash
14
+ # instead of @accum (used for min/max which aren't O(1)-retractable)
15
+ #
16
+ # == Built-ins
17
+ #
18
+ # count(filter: nil) — total items, optionally filtered
19
+ # sum(projection:) — sum a numeric value extracted per item
20
+ # avg(projection:) — running arithmetic mean
21
+ # min(projection:) — current minimum (O(n) on retraction)
22
+ # max(projection:) — current maximum (O(n) on retraction)
23
+ # group_count(projection:) — {group_key => item_count}
24
+ # custom(initial:, add:, remove:) — user-supplied retractable logic
25
+ #
26
+ module AggregateOperators
27
+ # Internal struct. Fields are callables (Proc) or primitives.
28
+ Operator = Struct.new(
29
+ :initial_fn, :project, :add, :remove, :finalize, :recompute,
30
+ keyword_init: true
31
+ ) do
32
+ # Returns a fresh initial accumulator for a new AggregateState.
33
+ def initial
34
+ initial_fn.call
35
+ end
36
+ end
37
+
38
+ # Count items, optionally filtered by a predicate.
39
+ #
40
+ # @param filter [Proc, nil] ->(item) { true/false }; nil = count all
41
+ def self.count(filter: nil)
42
+ Operator.new(
43
+ initial_fn: -> { 0 },
44
+ project: ->(item) { filter.nil? || filter.call(item) ? 1 : 0 },
45
+ add: ->(acc, v) { acc + v },
46
+ remove: ->(acc, v) { acc - v },
47
+ finalize: ->(acc, _) { acc },
48
+ recompute: false
49
+ )
50
+ end
51
+
52
+ # Sum a numeric value extracted from each item.
53
+ #
54
+ # @param projection [Proc] ->(item) { item.result.value.to_f }
55
+ def self.sum(projection:)
56
+ Operator.new(
57
+ initial_fn: -> { 0 },
58
+ project: ->(item) { projection.call(item).to_f },
59
+ add: ->(acc, v) { acc + v },
60
+ remove: ->(acc, v) { acc - v },
61
+ finalize: ->(acc, _) { acc },
62
+ recompute: false
63
+ )
64
+ end
65
+
66
+ # Running arithmetic mean.
67
+ #
68
+ # @param projection [Proc] ->(item) { numeric }
69
+ def self.avg(projection:) # rubocop:disable Metrics/AbcSize
70
+ Operator.new(
71
+ initial_fn: -> { { sum: 0.0, count: 0 } },
72
+ project: ->(item) { projection.call(item).to_f },
73
+ add: ->(acc, v) { { sum: acc[:sum] + v, count: acc[:count] + 1 } },
74
+ remove: ->(acc, v) { { sum: acc[:sum] - v, count: acc[:count] - 1 } },
75
+ finalize: ->(acc, _) { acc[:count].zero? ? 0.0 : acc[:sum] / acc[:count] },
76
+ recompute: false
77
+ )
78
+ end
79
+
80
+ # Current minimum (O(n) rescan on item removal — n = window size).
81
+ #
82
+ # @param projection [Proc] ->(item) { numeric }
83
+ def self.min(projection:)
84
+ Operator.new(
85
+ initial_fn: -> { nil },
86
+ project: ->(item) { projection.call(item) },
87
+ add: nil,
88
+ remove: nil,
89
+ finalize: ->(_acc, contribs) { contribs.values.min },
90
+ recompute: true
91
+ )
92
+ end
93
+
94
+ # Current maximum (O(n) rescan on item removal — n = window size).
95
+ #
96
+ # @param projection [Proc] ->(item) { numeric }
97
+ def self.max(projection:)
98
+ Operator.new(
99
+ initial_fn: -> { nil },
100
+ project: ->(item) { projection.call(item) },
101
+ add: nil,
102
+ remove: nil,
103
+ finalize: ->(_acc, contribs) { contribs.values.max },
104
+ recompute: true
105
+ )
106
+ end
107
+
108
+ # Group items by a key and count members per group.
109
+ # Returns a Hash {group_key => count}.
110
+ #
111
+ # @param projection [Proc] ->(item) { group_key }
112
+ def self.group_count(projection:) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
113
+ remove_fn = lambda do |acc, gk|
114
+ count = (acc[gk] || 1) - 1
115
+ count <= 0 ? acc.reject { |k, _| k == gk } : acc.merge(gk => count)
116
+ end
117
+ Operator.new(
118
+ initial_fn: -> { {} },
119
+ project: ->(item) { projection.call(item) },
120
+ add: ->(acc, gk) { acc.merge(gk => (acc[gk] || 0) + 1) },
121
+ remove: remove_fn,
122
+ finalize: ->(acc, _) { acc },
123
+ recompute: false
124
+ )
125
+ end
126
+
127
+ # Custom retractable aggregate with user-supplied logic.
128
+ #
129
+ # Both +add+ and +remove+ receive the full CollectionResult::Item so that
130
+ # users can reference +item.result.field+ in both lambdas.
131
+ #
132
+ # @param initial [Object] initial accumulator value (cloned on each new Execution)
133
+ # @param add [Proc] ->(acc, item) { new_acc }
134
+ # @param remove [Proc] ->(acc, item) { new_acc }
135
+ def self.custom(initial:, add:, remove:)
136
+ Operator.new(
137
+ initial_fn: -> { initial },
138
+ project: ->(item) { item },
139
+ add: ->(acc, item) { add.call(acc, item) },
140
+ remove: ->(acc, item) { remove.call(acc, item) },
141
+ finalize: ->(acc, _) { acc },
142
+ recompute: false
143
+ )
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Dataflow
5
+ # Mutable state for a maintained aggregate node.
6
+ #
7
+ # Stores per-item contributions so that the aggregate can be updated
8
+ # incrementally using only the diff (added/changed/removed) from the upstream
9
+ # incremental collection — not the full item set.
10
+ #
11
+ # One AggregateState instance lives on the Execution (keyed by node name) and
12
+ # persists across update_inputs calls for the lifetime of the contract execution.
13
+ #
14
+ # == Update semantics
15
+ #
16
+ # added → contribute! (project + add into @accum)
17
+ # removed → retract! (subtract from @accum using stored contribution)
18
+ # changed → retract!(old) then contribute!(new) — true differential update
19
+ # unchanged → no-op
20
+ #
21
+ class AggregateState
22
+ def initialize(operator)
23
+ @operator = operator
24
+ @contributions = {} # key => contribution (what the item "contributes")
25
+ @accum = operator.initial
26
+ end
27
+
28
+ # Apply a diff from an IncrementalCollectionResult, updating the aggregate
29
+ # state in O(changed + added + removed) time.
30
+ #
31
+ # @param diff [Igniter::Dataflow::Diff]
32
+ # @param collection_result [Igniter::Dataflow::IncrementalCollectionResult]
33
+ def apply_diff!(diff, collection_result)
34
+ diff.changed.each do |key|
35
+ retract!(key)
36
+ contribute!(key, collection_result[key])
37
+ end
38
+ diff.added.each { |key| contribute!(key, collection_result[key]) }
39
+ diff.removed.each { |key| retract!(key) }
40
+ end
41
+
42
+ # Current aggregate value (finalized from accumulated state).
43
+ def value
44
+ if @operator.recompute
45
+ @operator.finalize.call(nil, @contributions)
46
+ else
47
+ @operator.finalize.call(@accum, @contributions.size)
48
+ end
49
+ end
50
+
51
+ # Number of items currently tracked in the aggregate.
52
+ def item_count
53
+ @contributions.size
54
+ end
55
+
56
+ private
57
+
58
+ def contribute!(key, item)
59
+ contribution = @operator.project.call(item)
60
+ return if contribution.nil?
61
+
62
+ @contributions[key] = contribution
63
+ return if @operator.recompute
64
+
65
+ @accum = @operator.add.call(@accum, contribution)
66
+ end
67
+
68
+ def retract!(key)
69
+ old_contribution = @contributions.delete(key)
70
+ return unless old_contribution
71
+ return if @operator.recompute
72
+
73
+ @accum = @operator.remove.call(@accum, old_contribution)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Dataflow
5
+ # Immutable record of what changed in an incremental collection resolve.
6
+ #
7
+ # @attr added [Array<Object>] keys of items added since the last resolve
8
+ # @attr removed [Array<Object>] keys of items removed since the last resolve
9
+ # @attr changed [Array<Object>] keys of items whose content changed
10
+ # @attr unchanged [Array<Object>] keys of items that were identical to the last resolve
11
+ Diff = Struct.new(:added, :removed, :changed, :unchanged, keyword_init: true) do
12
+ # Returns true if any items were added, removed, or changed.
13
+ def any_changes?
14
+ added.any? || removed.any? || changed.any?
15
+ end
16
+
17
+ # Returns the total number of items that needed processing (added + changed).
18
+ def processed_count
19
+ added.size + changed.size
20
+ end
21
+
22
+ # Returns a compact human-readable summary.
23
+ def explain # rubocop:disable Metrics/AbcSize
24
+ parts = []
25
+ parts << "added(#{added.size}): #{added.inspect}" unless added.empty?
26
+ parts << "removed(#{removed.size}): #{removed.inspect}" unless removed.empty?
27
+ parts << "changed(#{changed.size}): #{changed.inspect}" unless changed.empty?
28
+ parts << "unchanged(#{unchanged.size})" unless unchanged.empty?
29
+ parts.empty? ? "(no changes)" : parts.join(", ")
30
+ end
31
+
32
+ def to_h
33
+ { added: added, removed: removed, changed: changed, unchanged: unchanged }
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Dataflow
5
+ # Mutable state for an incremental collection node.
6
+ #
7
+ # Stores fingerprints of previous items and their resolved CollectionResult::Items
8
+ # so the resolver can skip unchanged items on subsequent updates.
9
+ #
10
+ # One DiffState instance lives on the Execution (keyed by node name) and persists
11
+ # across update_inputs calls for the lifetime of the contract execution.
12
+ class DiffState
13
+ attr_reader :cached_items
14
+
15
+ def initialize
16
+ @snapshots = {} # key => fingerprint string
17
+ @cached_items = {} # key => Runtime::CollectionResult::Item
18
+ end
19
+
20
+ # Compute a Diff between the current normalized items and the previous state.
21
+ #
22
+ # @param current_items [Array<Hash>] normalized item input hashes
23
+ # @param key_fn [Proc] extracts the item key from an item hash
24
+ # @return [Diff]
25
+ def compute_diff(current_items, key_fn)
26
+ current_keys = current_items.to_h { |i| [key_fn.call(i), i] }
27
+ added, changed, unchanged = partition_items(current_items, key_fn)
28
+ removed = @snapshots.keys.reject { |k| current_keys.key?(k) }
29
+ build_diff(added, removed, changed, unchanged, key_fn)
30
+ end
31
+
32
+ # Record a resolved item (after running its child contract).
33
+ def update!(key, item_inputs, collection_result_item)
34
+ @snapshots[key] = fingerprint(item_inputs)
35
+ @cached_items[key] = collection_result_item
36
+ end
37
+
38
+ # Remove a previously tracked item (on removal from the input).
39
+ def retract!(key)
40
+ @snapshots.delete(key)
41
+ @cached_items.delete(key)
42
+ end
43
+
44
+ # Returns the cached CollectionResult::Item for a key, or nil.
45
+ def cached_item_for(key)
46
+ @cached_items[key]
47
+ end
48
+
49
+ private
50
+
51
+ def partition_items(items, key_fn)
52
+ items.each_with_object([[], [], []]) do |item, (added, changed, unchanged)|
53
+ k = key_fn.call(item)
54
+ if !@snapshots.key?(k)
55
+ added << item
56
+ elsif @snapshots[k] != fingerprint(item)
57
+ changed << item
58
+ else
59
+ unchanged << item
60
+ end
61
+ end
62
+ end
63
+
64
+ def build_diff(added, removed, changed, unchanged, key_fn)
65
+ Diff.new(
66
+ added: added.map { |i| key_fn.call(i) },
67
+ removed: removed,
68
+ changed: changed.map { |i| key_fn.call(i) },
69
+ unchanged: unchanged.map { |i| key_fn.call(i) }
70
+ )
71
+ end
72
+
73
+ # Stable fingerprint for change detection — order-independent for Hash items.
74
+ def fingerprint(item)
75
+ return item.hash.to_s unless item.is_a?(Hash)
76
+
77
+ item.sort_by { |k, _| k.to_s }.map { |k, v| "#{k}:#{v.inspect}" }.hash.to_s
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Dataflow
5
+ # A CollectionResult that carries a Diff describing what changed in the last resolve.
6
+ #
7
+ # Inherits all CollectionResult behaviour (successes, failures, summary, to_h, etc.)
8
+ # and adds:
9
+ #
10
+ # result.diff → Igniter::Dataflow::Diff
11
+ # result.diff.added → keys added in the last update
12
+ # result.diff.removed → keys removed in the last update
13
+ # result.diff.changed → keys whose item content changed
14
+ # result.diff.unchanged → keys that were identical — no child contract was re-run
15
+ #
16
+ class IncrementalCollectionResult < Runtime::CollectionResult
17
+ attr_reader :diff
18
+
19
+ def initialize(items:, diff:)
20
+ super(items: items, mode: :incremental)
21
+ @diff = diff
22
+ end
23
+
24
+ # Extends the base summary with incremental diff counters.
25
+ def summary
26
+ super.merge(
27
+ added: diff.added.size,
28
+ removed: diff.removed.size,
29
+ changed: diff.changed.size,
30
+ unchanged: diff.unchanged.size
31
+ )
32
+ end
33
+
34
+ def as_json(*)
35
+ super.merge(diff: diff.to_h)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Dataflow
5
+ # Applies a sliding window filter to an items array before incremental diff computation.
6
+ #
7
+ # Two window modes are supported:
8
+ #
9
+ # window: { last: 100 }
10
+ # Keep only the last 100 items (by position in the array — most recent last).
11
+ #
12
+ # window: { seconds: 300, field: :received_at }
13
+ # Keep only items where item[field] >= Time.now - seconds.
14
+ # The field value must respond to `>=` with a Time (e.g., Time, DateTime).
15
+ class WindowFilter
16
+ def initialize(options)
17
+ @options = options
18
+ end
19
+
20
+ # @param items [Array<Hash>] normalized item input hashes
21
+ # @return [Array<Hash>] filtered items
22
+ def apply(items)
23
+ return items unless @options
24
+
25
+ if @options.key?(:last)
26
+ apply_last_n(items)
27
+ elsif @options.key?(:seconds)
28
+ apply_time_window(items)
29
+ else
30
+ items
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def apply_last_n(items)
37
+ n = @options[:last]
38
+ items.last(n)
39
+ end
40
+
41
+ def apply_time_window(items)
42
+ field = @options[:field].to_sym
43
+ cutoff = Time.now - @options[:seconds]
44
+ items.select { |item| item[field] >= cutoff }
45
+ end
46
+ end
47
+ end
48
+ end