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,221 @@
1
+ # Content-Addressed Computation — v1
2
+
3
+ Content addressing gives `pure` executors a universal cache key derived from their logic
4
+ (fingerprint) and their input values. The same computation — regardless of which
5
+ contract, which execution, or which process produced it — always returns the cached result.
6
+
7
+ This is the Nix/Merkle model applied to contract nodes: **identical inputs → identical
8
+ output, fetched from cache**.
9
+
10
+ ## Quick Start
11
+
12
+ ```ruby
13
+ require "igniter/extensions/content_addressing"
14
+
15
+ class TaxCalculator < Igniter::Executor
16
+ pure # marks executor as side-effect-free
17
+ fingerprint "tax_calc_v1" # optional: bumps the cache key when logic changes
18
+
19
+ def call(country:, amount:)
20
+ TAX_RATES[country] * amount
21
+ end
22
+ end
23
+
24
+ class InvoiceContract < Igniter::Contract
25
+ define do
26
+ input :country
27
+ input :amount, type: :numeric
28
+
29
+ compute :tax, depends_on: %i[country amount], call: TaxCalculator
30
+
31
+ output :tax
32
+ end
33
+ end
34
+
35
+ # First execution — computes and caches the result
36
+ c1 = InvoiceContract.new(country: "UA", amount: 1000)
37
+ c1.result.tax # => 220.0 (computed)
38
+
39
+ # Second execution with identical inputs — served from the content cache
40
+ c2 = InvoiceContract.new(country: "UA", amount: 1000)
41
+ c2.result.tax # => 220.0 (cache hit — TaxCalculator was never called)
42
+ ```
43
+
44
+ ## How It Works
45
+
46
+ For every `pure` executor, the resolver computes a **content key** before calling
47
+ the executor:
48
+
49
+ ```
50
+ key = SHA-256( fingerprint + "\x00" + stable_serialize(dep_values) )[0..23]
51
+ ```
52
+
53
+ - **fingerprint** — the executor class name (or explicit `fingerprint "v1"` string).
54
+ - **stable_serialize** — deterministic, order-independent serialization of all dependency
55
+ values: Hash keys are sorted, Array elements are serialized recursively, primitives use
56
+ `inspect`.
57
+
58
+ The resolver looks up the key in the global `ContentAddressing.cache` before calling the
59
+ executor. On a hit it uses the cached value and emits a `:node_content_cache_hit` event.
60
+ On a miss it computes the result, stores it, and continues normally.
61
+
62
+ ## Executor DSL
63
+
64
+ ### `pure`
65
+
66
+ Marks the executor as having no side effects. Enables content-addressed caching.
67
+ Shorthand for `capabilities(:pure)`.
68
+
69
+ ```ruby
70
+ class MyExecutor < Igniter::Executor
71
+ pure
72
+ end
73
+ ```
74
+
75
+ ### `fingerprint "v1"`
76
+
77
+ Sets an explicit version string used as the first component of the content key.
78
+ Bump the fingerprint whenever the executor logic changes to immediately invalidate
79
+ all cached results for this executor.
80
+
81
+ ```ruby
82
+ class TaxCalculator < Igniter::Executor
83
+ pure
84
+ fingerprint "tax_calc_v2" # bumped — old v1 cache entries are ignored
85
+ end
86
+ ```
87
+
88
+ If `fingerprint` is not set, the executor's class name is used. Anonymous executors
89
+ use `"anonymous_executor"`.
90
+
91
+ ## Content Key
92
+
93
+ `Igniter::ContentAddressing::ContentKey` is an immutable value object:
94
+
95
+ ```ruby
96
+ key = Igniter::ContentAddressing::ContentKey.compute(TaxCalculator, { country: "UA", amount: 1000 })
97
+
98
+ key.hex # => "8f47805dc6dd7926" (24-hex digest prefix)
99
+ key.to_s # => "ca:8f47805dc6dd7926"
100
+ key == key # => true (equality by hex, not object identity)
101
+ key.frozen? # => true
102
+ ```
103
+
104
+ Keys are equal if their hex values match — two keys computed from the same executor and
105
+ the same dep values will always be equal, even if produced in separate processes.
106
+
107
+ ## Content Cache
108
+
109
+ The global cache is a thread-safe in-process Hash by default:
110
+
111
+ ```ruby
112
+ Igniter::ContentAddressing.cache # => #<Igniter::ContentAddressing::Cache ...>
113
+ Igniter::ContentAddressing.cache.stats # => { size: 3, hits: 12, misses: 4 }
114
+ Igniter::ContentAddressing.cache.size # => 3
115
+ Igniter::ContentAddressing.cache.clear # clears entries and resets counters
116
+ ```
117
+
118
+ ### Distributed Cache (Redis)
119
+
120
+ Replace the default cache with any object implementing `#fetch(key)` and `#store(key, value)`:
121
+
122
+ ```ruby
123
+ class RedisContentCache
124
+ def initialize(redis, ttl: 3600)
125
+ @redis = redis
126
+ @ttl = ttl
127
+ end
128
+
129
+ def fetch(key)
130
+ val = @redis.get(key.to_s)
131
+ val ? Marshal.load(val) : nil
132
+ end
133
+
134
+ def store(key, value)
135
+ @redis.setex(key.to_s, @ttl, Marshal.dump(value))
136
+ end
137
+ end
138
+
139
+ Igniter::ContentAddressing.cache = RedisContentCache.new(Redis.new)
140
+ ```
141
+
142
+ With a shared Redis cache, results are reused across deployments, canary instances,
143
+ and background workers — any process that computes `TaxCalculator(country: "UA", amount: 1000)`
144
+ once populates the cache for all others.
145
+
146
+ ## Runtime Events
147
+
148
+ The resolver emits `:node_content_cache_hit` when a cached result is used:
149
+
150
+ ```ruby
151
+ contract.execution.events.select { |e| e.type == :node_content_cache_hit }
152
+ # => [#<Event type=:node_content_cache_hit node=:tax ...>]
153
+ ```
154
+
155
+ ## Loading
156
+
157
+ ```ruby
158
+ require "igniter/extensions/content_addressing"
159
+ ```
160
+
161
+ This single require:
162
+ 1. Loads `lib/igniter/content_addressing.rb` (ContentKey, Cache, module-level cache accessor).
163
+ 2. Activates the resolver hooks via `lib/igniter/runtime/resolver.rb`.
164
+
165
+ Non-pure executors are completely unaffected — no overhead, no behavior change.
166
+
167
+ ## Combining with Temporal Contracts
168
+
169
+ When used with [temporal contracts](TEMPORAL_V1.md), the `as_of` value is part of the
170
+ dependency hash and therefore part of the content key. Historical and current timestamps
171
+ produce distinct cache entries, and identical timestamps produce cache hits:
172
+
173
+ ```ruby
174
+ require "igniter/temporal"
175
+ require "igniter/extensions/content_addressing"
176
+
177
+ class TaxRateExecutor < Igniter::Temporal::Executor
178
+ pure
179
+ fingerprint "tax_rate_v1"
180
+
181
+ def call(country:, as_of:)
182
+ RATES.dig(country, as_of.year) || 0.0
183
+ end
184
+ end
185
+ ```
186
+
187
+ Replaying a historical execution with the same `as_of` will hit the cache — the executor
188
+ is never called twice for the same (country, year) pair.
189
+
190
+ ## Fingerprint Invalidation Pattern
191
+
192
+ When you deploy a fix to a `pure` executor, bump the fingerprint so the old cached
193
+ results are ignored immediately:
194
+
195
+ ```ruby
196
+ # Before fix
197
+ class DiscountCalculator < Igniter::Executor
198
+ pure
199
+ fingerprint "discount_v1"
200
+ def call(amount:, code:) = amount * 0.9 # bug: off-by-one in rate
201
+ end
202
+
203
+ # After fix
204
+ class DiscountCalculator < Igniter::Executor
205
+ pure
206
+ fingerprint "discount_v2" # <-- bumped; v1 cache entries are silently ignored
207
+ def call(amount:, code:) = amount * 0.85
208
+ end
209
+ ```
210
+
211
+ Old cache entries with `"discount_v1"` prefix in the key are never read again.
212
+
213
+ ## Files
214
+
215
+ | File | Purpose |
216
+ |------|---------|
217
+ | `lib/igniter/content_addressing.rb` | `ContentKey`, `Cache`, module-level `cache` accessor |
218
+ | `lib/igniter/extensions/content_addressing.rb` | Entry point (`require "igniter/content_addressing"`) |
219
+ | `lib/igniter/executor.rb` | `pure`, `fingerprint`, `content_fingerprint`, `pure?` class DSL |
220
+ | `lib/igniter/runtime/resolver.rb` | `build_content_key` + cache fetch/store hooks in `resolve_compute` |
221
+ | `spec/igniter/content_addressing_spec.rb` | 19 examples |
@@ -0,0 +1,274 @@
1
+ # Incremental Dataflow — `mode: :incremental`
2
+
3
+ > **Status**: v1 shipped (2026-04)
4
+ > **Require**: `require "igniter/extensions/dataflow"`
5
+
6
+ ---
7
+
8
+ ## Overview
9
+
10
+ Incremental dataflow adds **O(change)** execution to `collection` nodes.
11
+ Instead of re-running every child contract on every `resolve_all`, the runtime:
12
+
13
+ 1. Computes a **Diff** — which item keys were added, removed, or changed since the last call.
14
+ 2. **Skips** child contracts for unchanged items (reuses cached results).
15
+ 3. **Retracts** removed items from the result automatically.
16
+ 4. **Applies sliding-window filtering** before the diff so memory stays bounded.
17
+
18
+ This is inspired by differential dataflow (Frank McSherry / Materialize) adapted to
19
+ Igniter's contract-graph model. It makes sensor pipelines, live analytics, and
20
+ event-driven workflows dramatically more efficient without any API changes to the
21
+ child contracts.
22
+
23
+ ---
24
+
25
+ ## Quick Start
26
+
27
+ ```ruby
28
+ require "igniter/extensions/dataflow"
29
+
30
+ class SensorAnalysis < Igniter::Contract
31
+ define do
32
+ input :sensor_id
33
+ input :value, type: :numeric
34
+ compute :status, depends_on: :value do |value:|
35
+ value > 75 ? :critical : value > 25 ? :warning : :normal
36
+ end
37
+ output :status
38
+ end
39
+ end
40
+
41
+ class SensorPipeline < Igniter::Contract
42
+ define do
43
+ input :readings, type: :array
44
+
45
+ collection :processed,
46
+ with: :readings,
47
+ each: SensorAnalysis,
48
+ key: :sensor_id,
49
+ mode: :incremental,
50
+ window: { last: 1000 } # optional
51
+
52
+ output :processed
53
+ end
54
+ end
55
+
56
+ pipeline = SensorPipeline.new(readings: initial_batch)
57
+ pipeline.resolve_all
58
+
59
+ # Push a diff — only changed sensors re-run
60
+ pipeline.feed_diff(:readings,
61
+ add: [{ sensor_id: "new-1", value: 10 }],
62
+ update: [{ sensor_id: "tmp-2", value: 90 }],
63
+ remove: ["hum-1"]
64
+ )
65
+ pipeline.resolve_all
66
+
67
+ diff = pipeline.collection_diff(:processed)
68
+ diff.added # => ["new-1"]
69
+ diff.changed # => ["tmp-2"]
70
+ diff.removed # => ["hum-1"]
71
+ diff.unchanged # => ["tmp-1", ...]
72
+ diff.processed_count # => 2 (new-1 + tmp-2)
73
+ ```
74
+
75
+ ---
76
+
77
+ ## DSL: `collection` with `mode: :incremental`
78
+
79
+ ```ruby
80
+ collection :name,
81
+ with: :input_name, # source input (must be Array)
82
+ each: ChildContract, # child contract class
83
+ key: :field_name, # unique key field in each item Hash
84
+ mode: :incremental, # enables differential execution
85
+ window: { last: N } # optional: keep last N items
86
+ # window: { seconds: 60, field: :ts } # optional: time window
87
+ ```
88
+
89
+ ### Parameters
90
+
91
+ | Parameter | Type | Required | Description |
92
+ |-----------|------|----------|-------------|
93
+ | `with:` | Symbol | yes | Input name that holds the Array |
94
+ | `each:` | Class | yes | Child contract class |
95
+ | `key:` | Symbol | yes | Hash field used as unique item identifier |
96
+ | `mode:` | Symbol | yes | `:incremental` to enable differential execution |
97
+ | `window:` | Hash | no | Sliding window filter (see below) |
98
+
99
+ ---
100
+
101
+ ## Sliding Window
102
+
103
+ The window is applied **before** diff computation, so items outside the window
104
+ are treated as if they were never present. This bounds both memory and latency.
105
+
106
+ ### `{ last: N }`
107
+
108
+ ```ruby
109
+ window: { last: 500 }
110
+ ```
111
+
112
+ Keeps the **last N items** from the input array.
113
+
114
+ ### `{ seconds: N, field: :sym }`
115
+
116
+ ```ruby
117
+ window: { seconds: 60, field: :ts }
118
+ ```
119
+
120
+ Keeps only items where `item[:ts] >= Time.now - 60`. The `:ts` field must
121
+ hold a `Time` object (or something that responds to `>=` for comparison with
122
+ `Time.now - N`).
123
+
124
+ ---
125
+
126
+ ## `#feed_diff` — event-style push
127
+
128
+ Instead of replacing the full input array, push only the delta:
129
+
130
+ ```ruby
131
+ contract.feed_diff(:input_name,
132
+ add: [{ sensor_id: "x", value: 5 }], # new items
133
+ remove: ["old-sensor"], # keys to remove
134
+ update: [{ sensor_id: "tmp-2", value: 90 }] # replace by key
135
+ )
136
+ ```
137
+
138
+ `remove:` accepts either **keys** (scalars) or **full Hash items** (the key is
139
+ extracted automatically using the collection node's `key_name`).
140
+
141
+ Returns `self` for chaining.
142
+
143
+ **Raises** `ArgumentError` when no incremental collection node uses the given input name.
144
+
145
+ ---
146
+
147
+ ## `#collection_diff` — inspect what changed
148
+
149
+ ```ruby
150
+ diff = contract.collection_diff(:collection_node_name)
151
+ ```
152
+
153
+ Returns `nil` before the first `resolve_all`, or an `Igniter::Dataflow::Diff`:
154
+
155
+ | Attribute | Type | Description |
156
+ |-----------|------|-------------|
157
+ | `added` | `Array` | Keys of items added in the last resolve |
158
+ | `removed` | `Array` | Keys of items removed in the last resolve |
159
+ | `changed` | `Array` | Keys of items whose content changed |
160
+ | `unchanged` | `Array` | Keys of items that were identical — no child contract re-run |
161
+ | `any_changes?` | Bool | `true` if anything was added, changed, or removed |
162
+ | `processed_count` | Int | `added.size + changed.size` — number of child contracts that ran |
163
+ | `explain` | String | Human-readable summary |
164
+ | `to_h` | Hash | Serialisable representation |
165
+
166
+ ---
167
+
168
+ ## `IncrementalCollectionResult`
169
+
170
+ The `output` value for an incremental collection is an `IncrementalCollectionResult`
171
+ (inherits from `CollectionResult`), which adds:
172
+
173
+ ```ruby
174
+ result = contract.result.collection_name # => IncrementalCollectionResult
175
+ result.diff # => Igniter::Dataflow::Diff
176
+ result.summary # extends base summary with :added/:removed/:changed/:unchanged counts
177
+ result.as_json # extends base JSON with :diff key
178
+ ```
179
+
180
+ All existing `CollectionResult` methods work as before:
181
+ `result["key"]`, `result.keys`, `result.successes`, `result.failures`, etc.
182
+
183
+ ---
184
+
185
+ ## Internal Architecture
186
+
187
+ ```
188
+ resolve_incremental_collection(node)
189
+
190
+ ├─ Resolve source items (same as :collect)
191
+ ├─ Apply WindowFilter (if node.window present)
192
+ ├─ DiffState#compute_diff → Diff
193
+ │ ├─ partition items into added / changed / unchanged
194
+ │ └─ identify removed keys (in @snapshots but not in current)
195
+
196
+ ├─ For each UNCHANGED key → reuse @cached_items[key]
197
+ ├─ For each REMOVED key → DiffState#retract!(key)
198
+ ├─ For each ADDED/CHANGED key → run child contract → DiffState#update!(key, ...)
199
+
200
+ └─ Return NodeState(IncrementalCollectionResult)
201
+ ```
202
+
203
+ ### `DiffState`
204
+
205
+ One `DiffState` instance per collection node, stored on `Execution#diff_states`.
206
+ Persists across `update_inputs` calls for the lifetime of the contract execution.
207
+
208
+ ```
209
+ @snapshots Hash{ key => fingerprint } change detection
210
+ @cached_items Hash{ key => Item } cached child results
211
+ ```
212
+
213
+ The **fingerprint** is content-based (order-independent for Hash items), so
214
+ reordering keys in an item Hash does not trigger a re-run.
215
+
216
+ ---
217
+
218
+ ## Compiler Validation
219
+
220
+ The compiler validates `window:` options at definition time:
221
+
222
+ ```ruby
223
+ # Accepted
224
+ window: { last: 100 }
225
+ window: { seconds: 60, field: :ts }
226
+
227
+ # Rejected at compile time (raises CompileError)
228
+ window: { bogus: 1 } # unknown key
229
+ window: { seconds: 60 } # missing :field
230
+ window: { last: -1 } # non-positive integer
231
+ ```
232
+
233
+ ---
234
+
235
+ ## Load Guard
236
+
237
+ If `mode: :incremental` is used without requiring the extension:
238
+
239
+ ```
240
+ Igniter::ResolutionError: Incremental dataflow requires the dataflow extension.
241
+ Add: require 'igniter/extensions/dataflow'
242
+ ```
243
+
244
+ ---
245
+
246
+ ## Performance Characteristics
247
+
248
+ | Scenario | Child contracts run |
249
+ |----------|-------------------|
250
+ | First resolve (N items) | N |
251
+ | All items unchanged | 0 |
252
+ | K items changed | K |
253
+ | K items added | K |
254
+ | K items removed | 0 (retraction only) |
255
+ | Mixed (K changed + M added) | K + M |
256
+
257
+ The `window:` filter caps the maximum work per round at `window_size`, not total dataset size.
258
+
259
+ ---
260
+
261
+ ## File Reference
262
+
263
+ | File | Purpose |
264
+ |------|---------|
265
+ | `lib/igniter/dataflow.rb` | Entry point |
266
+ | `lib/igniter/dataflow/diff.rb` | `Diff` struct |
267
+ | `lib/igniter/dataflow/diff_state.rb` | Per-node mutable state |
268
+ | `lib/igniter/dataflow/window_filter.rb` | `WindowFilter` — `last:` and `seconds:` |
269
+ | `lib/igniter/dataflow/incremental_collection_result.rb` | Result type with `.diff` |
270
+ | `lib/igniter/extensions/dataflow.rb` | Extension — patches `Contract` with `feed_diff`, `collection_diff` |
271
+ | `lib/igniter/runtime/resolver.rb` | `resolve_incremental_collection` |
272
+ | `lib/igniter/runtime/execution.rb` | `diff_state_for(node_name)` |
273
+ | `spec/igniter/dataflow_spec.rb` | 33 examples |
274
+ | `examples/dataflow.rb` | Sensor pipeline demo |