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,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
|
data/lib/igniter/contract.rb
CHANGED
|
@@ -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
|