custodian-core 0.1.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 +7 -0
- data/CHANGELOG.md +67 -0
- data/LICENSE.txt +21 -0
- data/README.md +204 -0
- data/app/models/custodian/core/application_record.rb +9 -0
- data/app/models/custodian/core/custody.rb +74 -0
- data/app/models/custodian/core/custody_node_rule.rb +39 -0
- data/app/models/custodian/core/custody_repudiated_node.rb +14 -0
- data/app/models/custodian/core/graph.rb +22 -0
- data/app/models/custodian/core/node.rb +37 -0
- data/custodian-core.gemspec +50 -0
- data/db/migrate/20260703015208_create_custodian_core_nodes.rb +19 -0
- data/db/migrate/20260703103004_create_custodian_core_graphs.rb +17 -0
- data/db/migrate/20260703215106_create_custodian_core_custodies.rb +29 -0
- data/db/migrate/20260707000238_create_custodian_core_custody_node_rules.rb +20 -0
- data/db/migrate/20260707000247_create_custodian_core_custody_repudiated_nodes.rb +17 -0
- data/lib/custodian/core/action_registry.rb +100 -0
- data/lib/custodian/core/adjuster.rb +375 -0
- data/lib/custodian/core/engine.rb +14 -0
- data/lib/custodian/core/version.rb +7 -0
- data/lib/custodian/core.rb +13 -0
- data/sig/custodian/core.rbs +6 -0
- metadata +168 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Custodian
|
|
4
|
+
module Core
|
|
5
|
+
module ActionRegistry
|
|
6
|
+
class AlreadyRegisteredError < Custodian::Core::Error; end
|
|
7
|
+
class NotRegisteredError < Custodian::Core::Error; end
|
|
8
|
+
class InvalidOutcomeError < Custodian::Core::Error; end
|
|
9
|
+
|
|
10
|
+
VALID_SYMBOL_OUTCOMES = %i[resolved failed].freeze
|
|
11
|
+
|
|
12
|
+
@registry = {}
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def register(action_name, &block)
|
|
17
|
+
name = action_name.to_sym
|
|
18
|
+
@mutex.synchronize do
|
|
19
|
+
raise AlreadyRegisteredError, "action #{name.inspect} is already registered" if @registry.key?(name)
|
|
20
|
+
|
|
21
|
+
@registry[name] = block
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(action_name, node, custody, remaining)
|
|
26
|
+
name = action_name.to_sym
|
|
27
|
+
block = @mutex.synchronize { @registry[name] }
|
|
28
|
+
raise NotRegisteredError, "action #{name.inspect} is not registered" unless block
|
|
29
|
+
|
|
30
|
+
outcome = block.call(node, custody, remaining)
|
|
31
|
+
validate_outcome!(name, outcome, remaining: remaining)
|
|
32
|
+
outcome
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def unregister(action_name)
|
|
36
|
+
name = action_name.to_sym
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
raise NotRegisteredError, "action #{name.inspect} is not registered" unless @registry.key?(name)
|
|
39
|
+
|
|
40
|
+
@registry.delete(name)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def registered?(action_name)
|
|
45
|
+
@mutex.synchronize { @registry.key?(action_name.to_sym) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Removes all registrations. Intended for test isolation: call this
|
|
49
|
+
# in a before/around hook so each spec starts from a clean registry.
|
|
50
|
+
def clear!
|
|
51
|
+
@mutex.synchronize { @registry.clear }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Public so callers outside the registry (e.g. Adjuster's phase
|
|
55
|
+
# hook) can validate an outcome using the exact same rule that
|
|
56
|
+
# governs actions invoked through #call: :resolved, :failed, or a
|
|
57
|
+
# non-negative Numeric.
|
|
58
|
+
def validate_outcome!(name, outcome, remaining: nil)
|
|
59
|
+
return if VALID_SYMBOL_OUTCOMES.include?(outcome)
|
|
60
|
+
|
|
61
|
+
validate_numeric_outcome!(name, outcome, remaining) if outcome.is_a?(Numeric)
|
|
62
|
+
return if outcome.is_a?(Numeric)
|
|
63
|
+
|
|
64
|
+
raise InvalidOutcomeError,
|
|
65
|
+
"action #{name.inspect} returned an invalid outcome: #{outcome.inspect} " \
|
|
66
|
+
"(expected :resolved, :failed, or a Numeric)"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def validate_numeric_outcome!(name, outcome, remaining)
|
|
72
|
+
validate_finite_outcome!(name, outcome)
|
|
73
|
+
validate_non_negative_outcome!(name, outcome)
|
|
74
|
+
return if remaining.nil? || outcome <= remaining
|
|
75
|
+
|
|
76
|
+
raise InvalidOutcomeError,
|
|
77
|
+
"action #{name.inspect} returned #{outcome.inspect}, exceeding remaining demand #{remaining.inspect}"
|
|
78
|
+
rescue ArgumentError, NoMethodError
|
|
79
|
+
raise InvalidOutcomeError,
|
|
80
|
+
"action #{name.inspect} returned an incompatible Numeric outcome: #{outcome.inspect}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def validate_finite_outcome!(name, outcome)
|
|
84
|
+
return if outcome.finite?
|
|
85
|
+
|
|
86
|
+
raise InvalidOutcomeError,
|
|
87
|
+
"action #{name.inspect} returned a non-finite Numeric outcome: #{outcome.inspect}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def validate_non_negative_outcome!(name, outcome)
|
|
91
|
+
return if outcome >= 0
|
|
92
|
+
|
|
93
|
+
raise InvalidOutcomeError,
|
|
94
|
+
"action #{name.inspect} returned a negative Numeric outcome: #{outcome.inspect} " \
|
|
95
|
+
"(an action must not increase demand; Numeric outcomes must be >= 0)"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Custodian
|
|
6
|
+
module Core
|
|
7
|
+
module Adjuster
|
|
8
|
+
VALID_STRICTNESS_VALUES = %i[valid trustworthy].freeze
|
|
9
|
+
|
|
10
|
+
@phases = []
|
|
11
|
+
@phases_mutex = Mutex.new
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# Registers a resolution phase, run (in registration order) between
|
|
15
|
+
# direct custodies and escalation for every node still unresolved.
|
|
16
|
+
# This is the extension point a future sibling-generosity satellite
|
|
17
|
+
# plugs into; this step only builds the hook, not any phase itself.
|
|
18
|
+
def register_phase(name, handler)
|
|
19
|
+
@phases_mutex.synchronize do
|
|
20
|
+
@phases << { name: name, handler: handler }.freeze
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Removes all registered phases. Intended for test isolation.
|
|
25
|
+
def clear_phases!
|
|
26
|
+
@phases_mutex.synchronize { @phases = [] }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Walks root_node's subtree post-order (leaves first), invoking
|
|
30
|
+
# registered actions through each node's custodies, and escalating
|
|
31
|
+
# unresolved demand to the direct parent (see the class docs for the
|
|
32
|
+
# full encapsulation design). Pure resolution: never persists or
|
|
33
|
+
# mutates any Node/Custody record. Actions themselves may have side
|
|
34
|
+
# effects; that is their business, not the Adjuster's.
|
|
35
|
+
def resolve_tree(root_node, strictness: :valid)
|
|
36
|
+
validate_strictness!(strictness)
|
|
37
|
+
state = build_resolution_state(root_node, strictness)
|
|
38
|
+
|
|
39
|
+
post_order(root_node, state[:children_by_parent_id]) do |node|
|
|
40
|
+
resolve_and_escalate(node, state)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
settle_numeric_escalations!(state[:results], state[:parent_id_by_node_id], root_node.id)
|
|
44
|
+
state[:results]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Computes, for every node in root_node's subtree, its consolidated
|
|
48
|
+
# demand (own demand_value + aggregated demand_value of numeric
|
|
49
|
+
# descendants). Pure calculation: no persistence, no ActionRegistry
|
|
50
|
+
# calls, no mutation of any record.
|
|
51
|
+
#
|
|
52
|
+
# Loads the whole subtree in a single query, then works entirely in
|
|
53
|
+
# memory (children are grouped by parent_id parsed from the ancestry
|
|
54
|
+
# column, never via DB-querying association methods) so the query
|
|
55
|
+
# count does not scale with tree size.
|
|
56
|
+
def aggregate_demand(root_node)
|
|
57
|
+
nodes = root_node.subtree.to_a
|
|
58
|
+
children_by_parent_id = nodes.group_by { |node| parent_id_of(node) }
|
|
59
|
+
|
|
60
|
+
results = {}
|
|
61
|
+
post_order(root_node, children_by_parent_id) do |node|
|
|
62
|
+
results[node.id] = compute(node, children_by_parent_id[node.id] || [], results)
|
|
63
|
+
end
|
|
64
|
+
results
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def parent_id_of(node)
|
|
70
|
+
return nil if node.ancestry.nil?
|
|
71
|
+
|
|
72
|
+
node.ancestry.split("/").last.to_i
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def post_order(node, children_by_parent_id, &block)
|
|
76
|
+
stack = [[node, false]]
|
|
77
|
+
|
|
78
|
+
until stack.empty?
|
|
79
|
+
current, visited = stack.pop
|
|
80
|
+
next block.call(current) if visited
|
|
81
|
+
|
|
82
|
+
enqueue_for_post_order(stack, current, children_by_parent_id)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def enqueue_for_post_order(stack, node, children_by_parent_id)
|
|
87
|
+
stack << [node, true]
|
|
88
|
+
(children_by_parent_id[node.id] || []).reverse_each { |child| stack << [child, false] }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def compute(node, children, results)
|
|
92
|
+
binary = node.demand_type == "binary"
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
own_demand: binary ? nil : node.demand_value,
|
|
96
|
+
aggregated_demand: binary ? nil : node.demand_value + numeric_children_sum(children, results),
|
|
97
|
+
binary: binary,
|
|
98
|
+
unresolved_binary_count: unresolved_binary_count(binary, children, results)
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Binary children are a magnitude firewall: their own aggregated_demand
|
|
103
|
+
# is nil and their numeric descendants never tunnel through them.
|
|
104
|
+
def numeric_children_sum(children, results)
|
|
105
|
+
children.reject { |child| results[child.id][:binary] }
|
|
106
|
+
.sum(BigDecimal("0")) { |child| results[child.id][:aggregated_demand] }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def unresolved_binary_count(binary, children, results)
|
|
110
|
+
(binary ? 1 : 0) + children.sum { |child| results[child.id][:unresolved_binary_count] }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
# resolve_tree bookkeeping
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def build_resolution_state(root_node, strictness)
|
|
118
|
+
nodes = root_node.subtree.to_a
|
|
119
|
+
|
|
120
|
+
{
|
|
121
|
+
strictness: strictness,
|
|
122
|
+
results: {},
|
|
123
|
+
children_by_parent_id: nodes.group_by { |node| parent_id_of(node) },
|
|
124
|
+
parent_id_by_node_id: nodes.to_h { |node| [node.id, parent_id_of(node)] },
|
|
125
|
+
custodies_by_ward_id: custodies_by_ward_id(nodes),
|
|
126
|
+
aggregation: aggregate_demand(root_node),
|
|
127
|
+
phases: phases_snapshot
|
|
128
|
+
}.merge(empty_accumulators)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def empty_accumulators
|
|
132
|
+
{
|
|
133
|
+
inherited_shortfall_by_node_id: Hash.new(BigDecimal("0")),
|
|
134
|
+
pending_binaries_by_node_id: Hash.new { |hash, key| hash[key] = [] }
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def custodies_by_ward_id(nodes)
|
|
139
|
+
Custody.where(ward_id: nodes.map(&:id)).order(:priority_weight, :id).group_by(&:ward_id)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def resolve_and_escalate(node, state)
|
|
143
|
+
pending_binaries = state[:pending_binaries_by_node_id][node.id]
|
|
144
|
+
|
|
145
|
+
result = resolve_node(node, state, pending_binaries)
|
|
146
|
+
state[:results][node.id] = result
|
|
147
|
+
|
|
148
|
+
escalate(node, result, pending_binaries, state)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def escalate(node, result, pending_binaries, state)
|
|
152
|
+
parent_id = state[:parent_id_by_node_id][node.id]
|
|
153
|
+
return unless parent_id
|
|
154
|
+
|
|
155
|
+
escalate_numeric_shortfall(result, parent_id, state)
|
|
156
|
+
escalate_binary_pending(node, result, pending_binaries, parent_id, state)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def escalate_numeric_shortfall(result, parent_id, state)
|
|
160
|
+
return if result[:binary] || !result[:gap]&.positive?
|
|
161
|
+
|
|
162
|
+
state[:inherited_shortfall_by_node_id][parent_id] += result[:gap]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def escalate_binary_pending(node, result, pending_binaries, parent_id, state)
|
|
166
|
+
pending = state[:pending_binaries_by_node_id][parent_id]
|
|
167
|
+
pending << node if result[:binary] && !result[:binary_resolved]
|
|
168
|
+
pending.concat(pending_binaries.reject { |p| state[:results][p.id][:binary_resolved] })
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# ------------------------------------------------------------------
|
|
172
|
+
# Per-node resolution
|
|
173
|
+
# ------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
def resolve_node(node, state, pending_binaries)
|
|
176
|
+
binary = node.demand_type == "binary"
|
|
177
|
+
own_demand, inherited_shortfall, total_demand = demand_figures(node, binary, state)
|
|
178
|
+
attempts = []
|
|
179
|
+
figures = { binary: binary, total_demand: total_demand }
|
|
180
|
+
|
|
181
|
+
binary_resolved, remaining, pending_ids =
|
|
182
|
+
attempt_resolution(node, figures, pending_binaries, state, attempts)
|
|
183
|
+
|
|
184
|
+
numeric_view(binary, total_demand, remaining).merge(
|
|
185
|
+
own_demand: own_demand, inherited_shortfall: inherited_shortfall, binary: binary,
|
|
186
|
+
binary_resolved: binary_resolved, pending_binaries_escalated: pending_ids, attempts: attempts
|
|
187
|
+
)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def attempt_resolution(node, figures, pending_binaries, state, attempts)
|
|
191
|
+
eligible = eligible_for(node, state)
|
|
192
|
+
context = resolution_context(node, state)
|
|
193
|
+
phases = state[:phases]
|
|
194
|
+
binary_resolved = figures[:binary] ? resolve_own_binary(node, eligible, attempts, context, phases) : nil
|
|
195
|
+
runtime = { context: context, phases: phases }
|
|
196
|
+
remaining = resolve_numeric_pool(node, eligible, figures[:total_demand], attempts, runtime)
|
|
197
|
+
pending_ids = resolve_pending_binaries(eligible, pending_binaries, state[:results])
|
|
198
|
+
[binary_resolved, remaining, pending_ids]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def demand_figures(node, binary, state)
|
|
202
|
+
own_demand = binary ? nil : node.demand_value
|
|
203
|
+
inherited_shortfall = state[:inherited_shortfall_by_node_id][node.id]
|
|
204
|
+
[own_demand, inherited_shortfall, (own_demand || BigDecimal("0")) + inherited_shortfall]
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def eligible_for(node, state)
|
|
208
|
+
custodies = state[:custodies_by_ward_id][node.id] || []
|
|
209
|
+
custodies.select { |custody| valid_under_strictness?(custody, state[:strictness]) }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def resolution_context(node, state)
|
|
213
|
+
siblings = (state[:children_by_parent_id][state[:parent_id_by_node_id][node.id]] || []) - [node]
|
|
214
|
+
{ siblings: siblings, aggregation: state[:aggregation], strictness: state[:strictness] }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def numeric_view(binary, total_demand, remaining)
|
|
218
|
+
return { demanded: nil, resolved_amount: nil, gap: nil } if binary
|
|
219
|
+
|
|
220
|
+
{ demanded: total_demand, resolved_amount: total_demand - remaining, gap: remaining }
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# A binary node has no magnitude: only :resolved settles it. :failed
|
|
224
|
+
# and any Numeric outcome are both treated as "not yet", try the next
|
|
225
|
+
# custody. Phases run afterwards, same rule, if still unresolved.
|
|
226
|
+
def resolve_own_binary(node, eligible, attempts, context, phases)
|
|
227
|
+
eligible.select { |custody| custody.applies_to?(node) }.each do |custody|
|
|
228
|
+
outcome = ActionRegistry.call(custody.action_name, node, custody, nil)
|
|
229
|
+
attempts << { custody_id: custody.id, action_name: custody.action_name, outcome: outcome, via: :direct }
|
|
230
|
+
return true if outcome == :resolved
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
run_phases(node, nil, context, attempts, phases) { |outcome| outcome == :resolved }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def resolve_numeric_pool(node, eligible, total_demand, attempts, runtime)
|
|
237
|
+
validate_total_demand!(total_demand)
|
|
238
|
+
remaining = total_demand
|
|
239
|
+
return remaining unless remaining.positive?
|
|
240
|
+
|
|
241
|
+
remaining = try_numeric_custodies(node, eligible, remaining, attempts)
|
|
242
|
+
return remaining if remaining.zero?
|
|
243
|
+
|
|
244
|
+
run_phases(node, remaining, runtime[:context], attempts, runtime[:phases]) do |outcome|
|
|
245
|
+
remaining = apply_outcome(remaining, outcome)
|
|
246
|
+
remaining.zero?
|
|
247
|
+
end
|
|
248
|
+
remaining
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def validate_total_demand!(total_demand)
|
|
252
|
+
return unless total_demand.negative?
|
|
253
|
+
|
|
254
|
+
raise ArgumentError, "total demand must be non-negative, got #{total_demand.inspect}"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def try_numeric_custodies(node, eligible, remaining, attempts)
|
|
258
|
+
eligible.select { |custody| custody.applies_to?(node) }.each do |custody|
|
|
259
|
+
outcome = ActionRegistry.call(custody.action_name, node, custody, remaining)
|
|
260
|
+
attempts << { custody_id: custody.id, action_name: custody.action_name, outcome: outcome, via: :direct }
|
|
261
|
+
remaining = apply_outcome(remaining, outcome)
|
|
262
|
+
break if remaining.zero?
|
|
263
|
+
end
|
|
264
|
+
remaining
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def apply_outcome(remaining, outcome)
|
|
268
|
+
case outcome
|
|
269
|
+
when :resolved then BigDecimal("0")
|
|
270
|
+
when :failed then remaining
|
|
271
|
+
else remaining - outcome
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Runs registered phases, in registration order, between direct
|
|
276
|
+
# custodies and escalation. Each phase's outcome is validated with
|
|
277
|
+
# the exact same rule ActionRegistry.call uses. Stops at the first
|
|
278
|
+
# phase whose result satisfies the block's "done?" check.
|
|
279
|
+
def run_phases(node, remaining, context, attempts, phases)
|
|
280
|
+
phases.each do |phase|
|
|
281
|
+
outcome = phase[:handler].call(node, remaining, context)
|
|
282
|
+
ActionRegistry.validate_outcome!(phase[:name], outcome, remaining: remaining)
|
|
283
|
+
attempts << { custody_id: nil, action_name: phase[:name], outcome: outcome, via: :phase }
|
|
284
|
+
|
|
285
|
+
done = yield(outcome)
|
|
286
|
+
return done if done
|
|
287
|
+
end
|
|
288
|
+
false
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Pending binaries inherited from children: this node's OWN custodies
|
|
292
|
+
# are invoked once per pending item, but with `node` being the
|
|
293
|
+
# ORIGINAL escalated binary node (not this node) - the action needs
|
|
294
|
+
# to know what it's actually resolving, even though the custody
|
|
295
|
+
# consulted belongs to the node currently being processed (its
|
|
296
|
+
# custodian is acting on the pending node's behalf). The attempt is
|
|
297
|
+
# recorded on the PENDING NODE's own attempts, not this node's.
|
|
298
|
+
def resolve_pending_binaries(eligible, pending_binaries, results)
|
|
299
|
+
pending_binaries.map do |pending_node|
|
|
300
|
+
results[pending_node.id][:binary_resolved] = resolve_pending_binary?(eligible, pending_node, results)
|
|
301
|
+
pending_node.id
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def resolve_pending_binary?(eligible, pending_node, results)
|
|
306
|
+
eligible.select { |custody| custody.applies_to?(pending_node) }.each do |custody|
|
|
307
|
+
outcome = ActionRegistry.call(custody.action_name, pending_node, custody, nil)
|
|
308
|
+
results[pending_node.id][:attempts] << {
|
|
309
|
+
custody_id: custody.id, action_name: custody.action_name, outcome: outcome, via: :escalation
|
|
310
|
+
}
|
|
311
|
+
return true if outcome == :resolved
|
|
312
|
+
end
|
|
313
|
+
false
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# ------------------------------------------------------------------
|
|
317
|
+
# Numeric escalation settlement (see resolve_tree)
|
|
318
|
+
# ------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
# A numeric node that couldn't fully resolve its total_demand (own +
|
|
321
|
+
# already-inherited) has its shortfall folded into the parent's total
|
|
322
|
+
# DURING the main post-order pass (see resolve_tree). Whether that
|
|
323
|
+
# shortfall was EVER actually covered can only be known once we've
|
|
324
|
+
# walked all the way up: a node's escalation is "resolved" if any
|
|
325
|
+
# ancestor's own final gap is zero (that ancestor's custodies covered
|
|
326
|
+
# the whole combined pool, including this node's contribution).
|
|
327
|
+
def settle_numeric_escalations!(results, parent_id_by_node_id, root_id)
|
|
328
|
+
results.each do |node_id, result|
|
|
329
|
+
next if node_id == root_id || result[:binary] || !result[:gap]&.positive?
|
|
330
|
+
|
|
331
|
+
settle_one_escalation!(node_id, result, parent_id_by_node_id, results)
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def settle_one_escalation!(node_id, result, parent_id_by_node_id, results)
|
|
336
|
+
if ancestor_resolved?(node_id, parent_id_by_node_id, results)
|
|
337
|
+
result[:gap] = BigDecimal("0")
|
|
338
|
+
result[:attempts] << { custody_id: nil, action_name: nil, outcome: :resolved, via: :escalation }
|
|
339
|
+
else
|
|
340
|
+
result[:attempts] << { custody_id: nil, action_name: nil, outcome: :failed, via: :escalation }
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def ancestor_resolved?(node_id, parent_id_by_node_id, results)
|
|
345
|
+
ancestor_id = parent_id_by_node_id[node_id]
|
|
346
|
+
while ancestor_id
|
|
347
|
+
ancestor_result = results[ancestor_id]
|
|
348
|
+
return true if !ancestor_result[:binary] && ancestor_result[:gap]&.zero?
|
|
349
|
+
|
|
350
|
+
ancestor_id = parent_id_by_node_id[ancestor_id]
|
|
351
|
+
end
|
|
352
|
+
false
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def valid_under_strictness?(custody, strictness)
|
|
356
|
+
case strictness
|
|
357
|
+
when :valid then custody.currently_valid?
|
|
358
|
+
when :trustworthy then custody.trustworthy?
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def validate_strictness!(strictness)
|
|
363
|
+
return if VALID_STRICTNESS_VALUES.include?(strictness)
|
|
364
|
+
|
|
365
|
+
raise ArgumentError,
|
|
366
|
+
"unsupported strictness #{strictness.inspect}; expected one of #{VALID_STRICTNESS_VALUES.inspect}"
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def phases_snapshot
|
|
370
|
+
@phases_mutex.synchronize { @phases.dup.freeze }
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_support/core_ext"
|
|
5
|
+
require "action_dispatch"
|
|
6
|
+
require "rails/engine"
|
|
7
|
+
|
|
8
|
+
module Custodian
|
|
9
|
+
module Core
|
|
10
|
+
class Engine < ::Rails::Engine
|
|
11
|
+
isolate_namespace Custodian::Core
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "core/version"
|
|
4
|
+
require_relative "core/engine"
|
|
5
|
+
|
|
6
|
+
module Custodian
|
|
7
|
+
module Core
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
require_relative "core/action_registry"
|
|
13
|
+
require_relative "core/adjuster"
|
metadata
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: custodian-core
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Lucas nunes de sousa
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: actionpack
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.0'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '9.0'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '7.0'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '9.0'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: activerecord
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '7.0'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '9.0'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '7.0'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '9.0'
|
|
52
|
+
- !ruby/object:Gem::Dependency
|
|
53
|
+
name: activesupport
|
|
54
|
+
requirement: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '7.0'
|
|
59
|
+
- - "<"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '9.0'
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '7.0'
|
|
69
|
+
- - "<"
|
|
70
|
+
- !ruby/object:Gem::Version
|
|
71
|
+
version: '9.0'
|
|
72
|
+
- !ruby/object:Gem::Dependency
|
|
73
|
+
name: ancestry
|
|
74
|
+
requirement: !ruby/object:Gem::Requirement
|
|
75
|
+
requirements:
|
|
76
|
+
- - ">="
|
|
77
|
+
- !ruby/object:Gem::Version
|
|
78
|
+
version: '4.0'
|
|
79
|
+
- - "<"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '6.0'
|
|
82
|
+
type: :runtime
|
|
83
|
+
prerelease: false
|
|
84
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '4.0'
|
|
89
|
+
- - "<"
|
|
90
|
+
- !ruby/object:Gem::Version
|
|
91
|
+
version: '6.0'
|
|
92
|
+
- !ruby/object:Gem::Dependency
|
|
93
|
+
name: railties
|
|
94
|
+
requirement: !ruby/object:Gem::Requirement
|
|
95
|
+
requirements:
|
|
96
|
+
- - ">="
|
|
97
|
+
- !ruby/object:Gem::Version
|
|
98
|
+
version: '7.0'
|
|
99
|
+
- - "<"
|
|
100
|
+
- !ruby/object:Gem::Version
|
|
101
|
+
version: '9.0'
|
|
102
|
+
type: :runtime
|
|
103
|
+
prerelease: false
|
|
104
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
105
|
+
requirements:
|
|
106
|
+
- - ">="
|
|
107
|
+
- !ruby/object:Gem::Version
|
|
108
|
+
version: '7.0'
|
|
109
|
+
- - "<"
|
|
110
|
+
- !ruby/object:Gem::Version
|
|
111
|
+
version: '9.0'
|
|
112
|
+
description: 'Custodian::Core resolves a chain of custody over a tree of responsibilities:
|
|
113
|
+
it identifies the next responsible party and demands the action a previous ward
|
|
114
|
+
could not fulfill, climbing the tree until someone can act. It carries no business
|
|
115
|
+
rules from any specific domain - concrete domains are implemented as satellite gems
|
|
116
|
+
that register actions into this core via a registry.'
|
|
117
|
+
email:
|
|
118
|
+
- lucas.ns.software.engineer@gmail.com
|
|
119
|
+
executables: []
|
|
120
|
+
extensions: []
|
|
121
|
+
extra_rdoc_files: []
|
|
122
|
+
files:
|
|
123
|
+
- CHANGELOG.md
|
|
124
|
+
- LICENSE.txt
|
|
125
|
+
- README.md
|
|
126
|
+
- app/models/custodian/core/application_record.rb
|
|
127
|
+
- app/models/custodian/core/custody.rb
|
|
128
|
+
- app/models/custodian/core/custody_node_rule.rb
|
|
129
|
+
- app/models/custodian/core/custody_repudiated_node.rb
|
|
130
|
+
- app/models/custodian/core/graph.rb
|
|
131
|
+
- app/models/custodian/core/node.rb
|
|
132
|
+
- custodian-core.gemspec
|
|
133
|
+
- db/migrate/20260703015208_create_custodian_core_nodes.rb
|
|
134
|
+
- db/migrate/20260703103004_create_custodian_core_graphs.rb
|
|
135
|
+
- db/migrate/20260703215106_create_custodian_core_custodies.rb
|
|
136
|
+
- db/migrate/20260707000238_create_custodian_core_custody_node_rules.rb
|
|
137
|
+
- db/migrate/20260707000247_create_custodian_core_custody_repudiated_nodes.rb
|
|
138
|
+
- lib/custodian/core.rb
|
|
139
|
+
- lib/custodian/core/action_registry.rb
|
|
140
|
+
- lib/custodian/core/adjuster.rb
|
|
141
|
+
- lib/custodian/core/engine.rb
|
|
142
|
+
- lib/custodian/core/version.rb
|
|
143
|
+
- sig/custodian/core.rbs
|
|
144
|
+
homepage: https://github.com/lucasnssoftwareengineer-alt/custodian-core
|
|
145
|
+
licenses:
|
|
146
|
+
- MIT
|
|
147
|
+
metadata:
|
|
148
|
+
source_code_uri: https://github.com/lucasnssoftwareengineer-alt/custodian-core
|
|
149
|
+
changelog_uri: https://github.com/lucasnssoftwareengineer-alt/custodian-core/blob/main/CHANGELOG.md
|
|
150
|
+
rubygems_mfa_required: 'true'
|
|
151
|
+
rdoc_options: []
|
|
152
|
+
require_paths:
|
|
153
|
+
- lib
|
|
154
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
155
|
+
requirements:
|
|
156
|
+
- - ">="
|
|
157
|
+
- !ruby/object:Gem::Version
|
|
158
|
+
version: 3.2.0
|
|
159
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
160
|
+
requirements:
|
|
161
|
+
- - ">="
|
|
162
|
+
- !ruby/object:Gem::Version
|
|
163
|
+
version: '0'
|
|
164
|
+
requirements: []
|
|
165
|
+
rubygems_version: 3.6.9
|
|
166
|
+
specification_version: 4
|
|
167
|
+
summary: A domain-agnostic chain-of-custody engine for Ruby.
|
|
168
|
+
test_files: []
|