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.
- checksums.yaml +4 -4
- data/README.md +217 -0
- data/docs/APPLICATION_V1.md +253 -0
- data/docs/CAPABILITIES_V1.md +207 -0
- data/docs/CONSENSUS_V1.md +477 -0
- data/docs/CONTENT_ADDRESSING_V1.md +221 -0
- data/docs/DATAFLOW_V1.md +274 -0
- data/docs/MESH_V1.md +732 -0
- data/docs/NODE_CACHE_V1.md +324 -0
- data/docs/PROACTIVE_AGENTS_V1.md +293 -0
- data/docs/SERVER_V1.md +200 -1
- data/docs/SKILLS_V1.md +213 -0
- data/docs/STORE_ADAPTERS.md +41 -13
- data/docs/TEMPORAL_V1.md +174 -0
- data/docs/TOOLS_V1.md +347 -0
- data/docs/TRANSCRIPTION_V1.md +403 -0
- data/examples/README.md +37 -0
- data/examples/consensus.rb +239 -0
- data/examples/dataflow.rb +308 -0
- data/examples/elocal_webhook.rb +1 -0
- data/examples/incremental.rb +142 -0
- data/examples/llm_tools.rb +237 -0
- data/examples/mesh.rb +239 -0
- data/examples/mesh_discovery.rb +267 -0
- data/examples/mesh_gossip.rb +162 -0
- data/examples/ringcentral_routing.rb +1 -1
- data/lib/igniter/agents/ai/alert_agent.rb +111 -0
- data/lib/igniter/agents/ai/chain_agent.rb +127 -0
- data/lib/igniter/agents/ai/critic_agent.rb +163 -0
- data/lib/igniter/agents/ai/evaluator_agent.rb +193 -0
- data/lib/igniter/agents/ai/evolution_agent.rb +286 -0
- data/lib/igniter/agents/ai/health_check_agent.rb +122 -0
- data/lib/igniter/agents/ai/observer_agent.rb +184 -0
- data/lib/igniter/agents/ai/planner_agent.rb +210 -0
- data/lib/igniter/agents/ai/router_agent.rb +131 -0
- data/lib/igniter/agents/ai/self_reflection_agent.rb +175 -0
- data/lib/igniter/agents/observability/metrics_agent.rb +130 -0
- data/lib/igniter/agents/pipeline/batch_processor_agent.rb +131 -0
- data/lib/igniter/agents/proactive_agent.rb +208 -0
- data/lib/igniter/agents/reliability/retry_agent.rb +99 -0
- data/lib/igniter/agents/scheduling/cron_agent.rb +110 -0
- data/lib/igniter/agents.rb +56 -0
- data/lib/igniter/application/app_config.rb +32 -0
- data/lib/igniter/application/autoloader.rb +18 -0
- data/lib/igniter/application/generator.rb +157 -0
- data/lib/igniter/application/scheduler.rb +109 -0
- data/lib/igniter/application/yml_loader.rb +39 -0
- data/lib/igniter/application.rb +174 -0
- data/lib/igniter/capabilities.rb +68 -0
- data/lib/igniter/compiler/validators/dependencies_validator.rb +50 -2
- data/lib/igniter/compiler/validators/remote_validator.rb +2 -0
- data/lib/igniter/consensus/cluster.rb +183 -0
- data/lib/igniter/consensus/errors.rb +14 -0
- data/lib/igniter/consensus/executors.rb +43 -0
- data/lib/igniter/consensus/node.rb +320 -0
- data/lib/igniter/consensus/read_query.rb +30 -0
- data/lib/igniter/consensus/state_machine.rb +58 -0
- data/lib/igniter/consensus.rb +58 -0
- data/lib/igniter/content_addressing.rb +133 -0
- data/lib/igniter/contract.rb +12 -0
- data/lib/igniter/dataflow/aggregate_operators.rb +147 -0
- data/lib/igniter/dataflow/aggregate_state.rb +77 -0
- data/lib/igniter/dataflow/diff.rb +37 -0
- data/lib/igniter/dataflow/diff_state.rb +81 -0
- data/lib/igniter/dataflow/incremental_collection_result.rb +39 -0
- data/lib/igniter/dataflow/window_filter.rb +48 -0
- data/lib/igniter/dataflow.rb +65 -0
- data/lib/igniter/dsl/contract_builder.rb +71 -7
- data/lib/igniter/executor.rb +60 -0
- data/lib/igniter/extensions/capabilities.rb +39 -0
- data/lib/igniter/extensions/content_addressing.rb +5 -0
- data/lib/igniter/extensions/dataflow.rb +117 -0
- data/lib/igniter/extensions/incremental.rb +50 -0
- data/lib/igniter/extensions/mesh.rb +31 -0
- data/lib/igniter/fingerprint.rb +43 -0
- data/lib/igniter/incremental/formatter.rb +81 -0
- data/lib/igniter/incremental/result.rb +69 -0
- data/lib/igniter/incremental/tracker.rb +108 -0
- data/lib/igniter/incremental.rb +50 -0
- data/lib/igniter/integrations/llm/config.rb +48 -4
- data/lib/igniter/integrations/llm/executor.rb +221 -28
- data/lib/igniter/integrations/llm/providers/anthropic.rb +37 -4
- data/lib/igniter/integrations/llm/providers/openai.rb +34 -5
- data/lib/igniter/integrations/llm/transcription/providers/assemblyai.rb +200 -0
- data/lib/igniter/integrations/llm/transcription/providers/base.rb +122 -0
- data/lib/igniter/integrations/llm/transcription/providers/deepgram.rb +162 -0
- data/lib/igniter/integrations/llm/transcription/providers/openai.rb +102 -0
- data/lib/igniter/integrations/llm/transcription/transcriber.rb +145 -0
- data/lib/igniter/integrations/llm/transcription/transcript_result.rb +29 -0
- data/lib/igniter/integrations/llm.rb +37 -1
- data/lib/igniter/memory/agent_memory.rb +104 -0
- data/lib/igniter/memory/episode.rb +29 -0
- data/lib/igniter/memory/fact.rb +27 -0
- data/lib/igniter/memory/memorable.rb +90 -0
- data/lib/igniter/memory/reflection_cycle.rb +96 -0
- data/lib/igniter/memory/reflection_record.rb +28 -0
- data/lib/igniter/memory/store.rb +115 -0
- data/lib/igniter/memory/stores/in_memory.rb +136 -0
- data/lib/igniter/memory/stores/sqlite.rb +284 -0
- data/lib/igniter/memory.rb +80 -0
- data/lib/igniter/mesh/announcer.rb +55 -0
- data/lib/igniter/mesh/config.rb +45 -0
- data/lib/igniter/mesh/discovery.rb +39 -0
- data/lib/igniter/mesh/errors.rb +31 -0
- data/lib/igniter/mesh/gossip.rb +47 -0
- data/lib/igniter/mesh/peer.rb +21 -0
- data/lib/igniter/mesh/peer_registry.rb +51 -0
- data/lib/igniter/mesh/poller.rb +77 -0
- data/lib/igniter/mesh/router.rb +109 -0
- data/lib/igniter/mesh.rb +85 -0
- data/lib/igniter/metrics/collector.rb +131 -0
- data/lib/igniter/metrics/prometheus_exporter.rb +104 -0
- data/lib/igniter/metrics/snapshot.rb +8 -0
- data/lib/igniter/metrics.rb +37 -0
- data/lib/igniter/model/aggregate_node.rb +34 -0
- data/lib/igniter/model/collection_node.rb +3 -2
- data/lib/igniter/model/compute_node.rb +13 -0
- data/lib/igniter/model/remote_node.rb +18 -2
- data/lib/igniter/node_cache.rb +231 -0
- data/lib/igniter/replication/bootstrapper.rb +61 -0
- data/lib/igniter/replication/bootstrappers/gem.rb +32 -0
- data/lib/igniter/replication/bootstrappers/git.rb +39 -0
- data/lib/igniter/replication/bootstrappers/tarball.rb +56 -0
- data/lib/igniter/replication/expansion_plan.rb +38 -0
- data/lib/igniter/replication/expansion_planner.rb +142 -0
- data/lib/igniter/replication/manifest.rb +45 -0
- data/lib/igniter/replication/network_topology.rb +123 -0
- data/lib/igniter/replication/node_role.rb +42 -0
- data/lib/igniter/replication/reflective_replication_agent.rb +238 -0
- data/lib/igniter/replication/replication_agent.rb +87 -0
- data/lib/igniter/replication/role_registry.rb +73 -0
- data/lib/igniter/replication/ssh_session.rb +77 -0
- data/lib/igniter/replication.rb +54 -0
- data/lib/igniter/runtime/cache.rb +35 -6
- data/lib/igniter/runtime/execution.rb +26 -2
- data/lib/igniter/runtime/input_validator.rb +6 -2
- data/lib/igniter/runtime/node_state.rb +7 -2
- data/lib/igniter/runtime/resolver.rb +323 -31
- data/lib/igniter/runtime/stores/redis_store.rb +41 -4
- data/lib/igniter/server/client.rb +44 -1
- data/lib/igniter/server/config.rb +13 -6
- data/lib/igniter/server/handlers/event_handler.rb +4 -0
- data/lib/igniter/server/handlers/execute_handler.rb +6 -0
- data/lib/igniter/server/handlers/liveness_handler.rb +20 -0
- data/lib/igniter/server/handlers/manifest_handler.rb +34 -0
- data/lib/igniter/server/handlers/metrics_handler.rb +51 -0
- data/lib/igniter/server/handlers/peers_handler.rb +115 -0
- data/lib/igniter/server/handlers/readiness_handler.rb +47 -0
- data/lib/igniter/server/http_server.rb +54 -17
- data/lib/igniter/server/router.rb +54 -21
- data/lib/igniter/server/server_logger.rb +52 -0
- data/lib/igniter/server.rb +6 -0
- data/lib/igniter/skill/feedback.rb +116 -0
- data/lib/igniter/skill/output_schema.rb +110 -0
- data/lib/igniter/skill.rb +218 -0
- data/lib/igniter/temporal.rb +84 -0
- data/lib/igniter/tool/discoverable.rb +151 -0
- data/lib/igniter/tool.rb +52 -0
- data/lib/igniter/tool_registry.rb +144 -0
- data/lib/igniter/version.rb +1 -1
- data/lib/igniter.rb +17 -0
- 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 |
|
data/docs/DATAFLOW_V1.md
ADDED
|
@@ -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 |
|