@zalom/plastic 2.0.0-alpha.22 → 2.0.0-alpha.24

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 (103) hide show
  1. package/PLASTIC.md +6 -5
  2. package/agents/plastic-enforcer.md +6 -3
  3. package/agents/plastic-executor.md +4 -0
  4. package/agents/plastic-node-research.md +28 -0
  5. package/agents/plastic-node-verify.md +27 -0
  6. package/agents/plastic-node-work.md +32 -0
  7. package/bin/lib/context_budget.rb +1 -1
  8. package/hooks/hooks.json +12 -0
  9. package/hooks/statusline +28 -0
  10. package/hooks/stop +5 -0
  11. package/package.json +1 -1
  12. package/scripts/dashboard.rb +1 -1
  13. package/scripts/doctor.rb +80 -6
  14. package/scripts/end-intent +22 -19
  15. package/scripts/exec-worktree +5 -5
  16. package/scripts/graph-measure +249 -0
  17. package/scripts/hook-call-budget +6 -6
  18. package/scripts/hook-capture +16 -15
  19. package/scripts/hook-record +21 -14
  20. package/scripts/hook-savepoint +26 -3
  21. package/scripts/hook-session-start +49 -10
  22. package/scripts/hook-stop +57 -0
  23. package/scripts/lib/active_delivery.rb +105 -0
  24. package/scripts/lib/agent_models.rb +10 -1
  25. package/scripts/lib/arm.rb +41 -102
  26. package/scripts/lib/codex_adapter.rb +197 -0
  27. package/scripts/lib/{packet_wrapper.rb → data_boundary.rb} +7 -7
  28. package/scripts/lib/day_summary.rb +19 -11
  29. package/scripts/lib/doctor_core.rb +8 -3
  30. package/scripts/lib/doctor_session_ledger.rb +3 -52
  31. package/scripts/lib/engine_permissions.rb +88 -0
  32. package/scripts/lib/exec_worktree.rb +24 -21
  33. package/scripts/lib/graph_edges.rb +4 -4
  34. package/scripts/lib/graph_file.rb +4 -4
  35. package/scripts/lib/graph_measure.rb +645 -0
  36. package/scripts/lib/graph_measure_budget.rb +409 -0
  37. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  38. package/scripts/lib/graph_measure_models.rb +411 -0
  39. package/scripts/lib/graph_measure_report.rb +532 -0
  40. package/scripts/lib/graph_tree.rb +2 -2
  41. package/scripts/lib/handoff.rb +40 -13
  42. package/scripts/lib/harness_adapter.rb +184 -0
  43. package/scripts/lib/hook_registry.rb +13 -1
  44. package/scripts/lib/hook_replay.rb +23 -5
  45. package/scripts/lib/index_entry.rb +53 -0
  46. package/scripts/lib/index_projection.rb +1 -1
  47. package/scripts/lib/insights.rb +1 -1
  48. package/scripts/lib/installer_core.rb +228 -9
  49. package/scripts/lib/intent_screen.rb +1 -1
  50. package/scripts/lib/intent_validator.rb +2 -2
  51. package/scripts/lib/lock.rb +8 -9
  52. package/scripts/lib/meter_watch.rb +15 -9
  53. package/scripts/lib/node_file.rb +3 -3
  54. package/scripts/lib/{node_packet.rb → node_input.rb} +49 -49
  55. package/scripts/lib/node_input_compatibility.rb +62 -0
  56. package/scripts/lib/node_ledger.rb +11 -2
  57. package/scripts/lib/node_progress.rb +153 -0
  58. package/scripts/lib/outcome_report.rb +2 -2
  59. package/scripts/lib/project_config.rb +45 -0
  60. package/scripts/lib/ready_set.rb +1 -1
  61. package/scripts/lib/report_screen.rb +10 -6
  62. package/scripts/lib/roadmap_graph.rb +1 -1
  63. package/scripts/lib/roadmap_queue.rb +1 -1
  64. package/scripts/lib/roadmap_render.rb +1 -1
  65. package/scripts/lib/roadmap_savepoint.rb +1 -1
  66. package/scripts/lib/runner_absorb.rb +36 -10
  67. package/scripts/lib/runner_dispatch.rb +66 -51
  68. package/scripts/lib/runner_sweep.rb +4 -4
  69. package/scripts/lib/runner_until_empty.rb +252 -0
  70. package/scripts/lib/runner_watch.rb +389 -0
  71. package/scripts/lib/savepoint.rb +9 -10
  72. package/scripts/lib/scaffold_intent.rb +6 -3
  73. package/scripts/lib/session_close.rb +30 -28
  74. package/scripts/lib/session_git.rb +22 -16
  75. package/scripts/lib/session_ledger.rb +44 -4
  76. package/scripts/lib/stop_gate.rb +95 -0
  77. package/scripts/lib/verify_intent.rb +2 -2
  78. package/scripts/lib/work_graph_validator.rb +6 -6
  79. package/scripts/lib/worktree.rb +24 -24
  80. package/scripts/lib/worktree_sweep.rb +3 -3
  81. package/scripts/new-intent +1 -1
  82. package/scripts/{node-packet → node-input} +15 -15
  83. package/scripts/node-run +224 -0
  84. package/scripts/plastic-lock +33 -32
  85. package/scripts/read-config +6 -0
  86. package/scripts/runner +203 -19
  87. package/scripts/verify-intent +1 -1
  88. package/skills/auto/SKILL.md +8 -8
  89. package/skills/auto/references/end-tail.md +9 -11
  90. package/skills/conventions/references/completion-and-done.md +9 -10
  91. package/skills/conventions/references/knowledge-graph.md +9 -0
  92. package/skills/conventions/references/locks-and-worktrees.md +10 -12
  93. package/skills/direct/SKILL.md +2 -2
  94. package/skills/doctor/SKILL.md +4 -4
  95. package/skills/intent-creating/evals/evals.json +1 -1
  96. package/skills/intent-executing/SKILL.md +7 -1
  97. package/skills/releasing/SKILL.md +2 -2
  98. package/skills/releasing/references/promotion-and-tagging.md +1 -1
  99. package/skills/releasing/references/release-lines.md +1 -1
  100. package/skills/tutorial/references/track-2-auto.md +1 -1
  101. package/templates/index.md +1 -1
  102. package/templates/project.yml +1 -1
  103. package/scripts/lib/bridge.rb +0 -116
@@ -0,0 +1,409 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+ require "json"
6
+ require "tmpdir"
7
+ require_relative "node_ledger"
8
+ require_relative "node_file"
9
+ require_relative "node_input"
10
+ require_relative "node_input_compatibility"
11
+ require_relative "data_boundary"
12
+
13
+ # GraphMeasureBudget (intent 343, G10, n4): whether the `budget:` a node's
14
+ # envelope declares (327 C19, D47) is a ceiling that ever actually held.
15
+ # Intent 340 found at 22:14Z on 2026-09-10 that every runner-built node input
16
+ # was capped at 8000 tokens whatever the node declared (v1 major M7); this
17
+ # module is the measurement of whether that still holds, from the ledger
18
+ # and the real input files alone.
19
+ #
20
+ # Read-only (spec D2): it opens `nodes/*.md`, `savepoint.md` and files under
21
+ # `attempts/`, and writes nothing. A measure with no source prints
22
+ # `:unavailable`, never zero and never blank (spec D3); a input file named
23
+ # by `input=` that is not on disk is counted and named by its sha, never
24
+ # silently treated as zero bytes (spec D4).
25
+ #
26
+ # Adds no second parser and no second estimator: `NodeLedger` owns the
27
+ # transition-line format, `NodeFile` owns the envelope's `budget:`,
28
+ # `NodeInput` owns node input path resolution and attempt numbering, and
29
+ # `DataBoundary.estimate_tokens` is the one token formula, the same the
30
+ # input builder enforced with (spec D8).
31
+ module GraphMeasureBudget
32
+ module_function
33
+
34
+ SAVEPOINT_FILE = "savepoint.md"
35
+ UNAVAILABLE = "unavailable"
36
+
37
+ # {ok:, nodes: {id => {declared_budget:, attempts: [...]}}, ceiling: {...}}.
38
+ # Never raises: a missing savepoint.md, a node with no envelope file, and a
39
+ # input= naming a file that does not exist on disk all resolve to
40
+ # `:unavailable` fields rather than an exception.
41
+ def read(intent_dir, node_reader: NodeFile.method(:parse))
42
+ dir = File.expand_path(intent_dir.to_s)
43
+ content = read_content(File.join(dir, SAVEPOINT_FILE))
44
+ entries = NodeLedger.entries_from_content(content)
45
+
46
+ running_indices = Hash.new { |h, k| h[k] = [] }
47
+ entries.each_with_index do |e, idx|
48
+ next if e[:torn] || e[:state] != "running"
49
+
50
+ running_indices[e[:subject]] << idx
51
+ end
52
+
53
+ nodes = {}
54
+ running_indices.each do |subject, indices|
55
+ declared_budget = declared_budget_for(dir, subject, node_reader)
56
+ attempts = indices.map do |idx|
57
+ entry = entries[idx]
58
+ # Row 4.5: the attempt number is NodeInput's own count of non-torn
59
+ # `running` lines up to and including this one, never
60
+ # ReadySet.attempts_count (which resets after `done`, `superseded`
61
+ # or `abandoned` and answers "attempts left", not "which attempt was
62
+ # this line").
63
+ attempt_number = NodeInput.compute_attempt_number(
64
+ intent_dir: dir, node: subject, lease_flag_given: false, entries: entries[0..idx]
65
+ )
66
+ build_attempt(dir, subject, attempt_number, entry, declared_budget)
67
+ end
68
+ nodes[subject] = { declared_budget: declared_budget, attempts: attempts }
69
+ end
70
+
71
+ record = { ok: true, nodes: nodes, ceiling: detect_ceiling(nodes) }
72
+ deep_freeze(record)
73
+ end
74
+
75
+ # --- rendering -----------------------------------------------------------
76
+
77
+ def render_json(record)
78
+ JSON.generate(model(record))
79
+ end
80
+
81
+ def render_text(record)
82
+ m = model(record)
83
+ lines = []
84
+ lines.concat(node_block(m["nodes"]))
85
+ lines << ""
86
+ lines.concat(ceiling_block(m["ceiling"]))
87
+ "#{lines.join("\n")}\n"
88
+ end
89
+
90
+ def model(record)
91
+ {
92
+ "nodes" => sort_ids(record[:nodes].keys).map { |id| node_model(id, record[:nodes][id]) },
93
+ "ceiling" => ceiling_model(record[:ceiling]),
94
+ }
95
+ end
96
+
97
+ def node_model(id, node)
98
+ {
99
+ "id" => id,
100
+ "declared_budget" => av(node[:declared_budget]),
101
+ "attempts" => node[:attempts].map { |a| attempt_model(a) },
102
+ }
103
+ end
104
+ private_class_method :node_model
105
+
106
+ def attempt_model(a)
107
+ {
108
+ "attempt" => a[:attempt],
109
+ "input_sha_declared" => av(a[:input_sha_declared]),
110
+ "file_exists" => a[:file_exists],
111
+ "input_sha_actual" => av(a[:input_sha_actual]),
112
+ "sha_match" => a[:sha_match],
113
+ "bytes" => av(a[:bytes]),
114
+ "estimate_tokens" => av(a[:estimate_tokens]),
115
+ "hop" => a[:hop],
116
+ "effective_tokens" => av(a[:effective_tokens]),
117
+ "over_budget" => av(a[:over_budget]),
118
+ }
119
+ end
120
+ private_class_method :attempt_model
121
+
122
+ def ceiling_model(c)
123
+ {
124
+ "detected" => c[:detected],
125
+ "value" => c[:value] ? c[:value] : UNAVAILABLE,
126
+ "node_count" => c[:node_count],
127
+ "attempt_count" => c[:attempt_count],
128
+ "reason" => c[:reason].to_s,
129
+ }
130
+ end
131
+ private_class_method :ceiling_model
132
+
133
+ def node_block(nodes)
134
+ lines = ["== Budget =="]
135
+ if nodes.empty?
136
+ lines << "(no node input attempts recorded)"
137
+ return lines
138
+ end
139
+
140
+ lines << "| Node | Attempt | Declared budget | Bytes | Est tokens | Hop | Effective tokens | Over budget | Sha match |"
141
+ lines << "| --- | --- | --- | --- | --- | --- | --- | --- | --- |"
142
+ nodes.each do |n|
143
+ n["attempts"].each do |a|
144
+ lines << "| #{n['id']} | #{a['attempt']} | #{n['declared_budget']} | #{a['bytes']} | " \
145
+ "#{a['estimate_tokens']} | #{a['hop']} | #{a['effective_tokens']} | #{a['over_budget']} | #{a['sha_match']} |"
146
+ end
147
+ end
148
+ lines
149
+ end
150
+ private_class_method :node_block
151
+
152
+ def ceiling_block(c)
153
+ [
154
+ "== Suspected ceiling ==",
155
+ "detected: #{c['detected']}",
156
+ "value: #{c['value']}#{c['value'] == UNAVAILABLE ? '' : ' tokens'}",
157
+ "based on: #{c['node_count']} nodes, #{c['attempt_count']} attempts (reason: #{c['reason']})",
158
+ ]
159
+ end
160
+ private_class_method :ceiling_block
161
+
162
+ # --- reading ---------------------------------------------------------------
163
+
164
+ def read_content(path)
165
+ return "" unless path && File.exist?(path)
166
+
167
+ File.read(path).scrub
168
+ end
169
+ private_class_method :read_content
170
+
171
+ # B6 (v1 review): `node_reader` (NodeFile.parse by default) reads its own
172
+ # path and does not scrub, so one bad byte anywhere in a node envelope
173
+ # raised out of NodeFile's regex matching and took the whole `budget` verb
174
+ # down (reproduced by appending an invalid UTF-8 byte to a copy of 340's
175
+ # nodes/n1.md: `budget` exited 1 with "internal error: invalid byte
176
+ # sequence in UTF-8", the same as `cohorts`, while `intent` - which never
177
+ # calls NodeFile.parse without GraphMeasure's own `with_safe_path` guard -
178
+ # stayed at exit 0). `GraphMeasure.with_safe_path` already solves exactly
179
+ # this, but it is `private_class_method` (carried from n1, same pattern
180
+ # n5's `GraphMeasureModels` already uses for its own `present?` and
181
+ # `resolve_kind`): a local copy, not a second parser or a reopen.
182
+ def declared_budget_for(dir, node, node_reader)
183
+ path = NodeInput.find_node_path(dir, node)
184
+ return :unavailable unless path
185
+
186
+ parsed = with_safe_path(path) { |p| p ? node_reader.call(p) : nil }
187
+ budget = parsed && parsed[:budget]
188
+ budget.is_a?(Integer) ? budget : :unavailable
189
+ end
190
+ private_class_method :declared_budget_for
191
+
192
+ # Own copy of GraphMeasure's `with_safe_path` (private there): scrub a
193
+ # bad-UTF-8 file into a throwaway Dir.mktmpdir copy before handing it to a
194
+ # reader that does not scrub itself, never writing into `intent_dir`
195
+ # (spec D2).
196
+ def with_safe_path(path)
197
+ return yield(nil) unless path && File.exist?(path)
198
+
199
+ raw = File.read(path)
200
+ scrubbed = raw.scrub
201
+ return yield(path) if scrubbed == raw
202
+
203
+ Dir.mktmpdir("graph-measure-budget-scrub") do |tmp|
204
+ safe_path = File.join(tmp, File.basename(path))
205
+ File.write(safe_path, scrubbed)
206
+ yield(safe_path)
207
+ end
208
+ end
209
+ private_class_method :with_safe_path
210
+
211
+ def build_attempt(dir, subject, attempt_number, entry, declared_budget)
212
+ fields = entry[:fields] || {}
213
+ input_sha_declared = present?(fields["input"]) ? fields["input"] : :unavailable
214
+ # D8: hop= is written once, on the running line; absent means no hop
215
+ # block was appended (the feature predates this line, or hop is off),
216
+ # never an unknown quantity to subtract.
217
+ hop = present?(fields["hop"]) ? fields["hop"].to_i : 0
218
+ path = NodeInputCompatibility.input_path(intent_dir: dir, node: subject, attempt: attempt_number)
219
+ file_exists = File.exist?(path)
220
+
221
+ if file_exists
222
+ raw = File.binread(path)
223
+ bytes = raw.bytesize
224
+ sha_actual = Digest::SHA256.hexdigest(raw)[0, 12]
225
+ estimate = DataBoundary.estimate_tokens(raw)
226
+ effective = estimate - hop
227
+ over_budget = declared_budget.is_a?(Integer) ? effective > declared_budget : :unavailable
228
+ sha_match = sha_actual == input_sha_declared
229
+ else
230
+ bytes = :unavailable
231
+ sha_actual = :unavailable
232
+ estimate = :unavailable
233
+ effective = :unavailable
234
+ over_budget = :unavailable
235
+ sha_match = false
236
+ end
237
+
238
+ {
239
+ attempt: attempt_number,
240
+ input_sha_declared: input_sha_declared,
241
+ input_path: path,
242
+ file_exists: file_exists,
243
+ input_sha_actual: sha_actual,
244
+ sha_match: sha_match,
245
+ bytes: bytes,
246
+ estimate_tokens: estimate,
247
+ hop: hop,
248
+ effective_tokens: effective,
249
+ over_budget: over_budget,
250
+ raw: entry[:raw],
251
+ }
252
+ end
253
+ private_class_method :build_attempt
254
+
255
+ def present?(value)
256
+ !(value.nil? || value.to_s.strip.empty?)
257
+ end
258
+ private_class_method :present?
259
+
260
+ # --- the suspected ceiling: spec matrix rows 4.8, 4.9 -----------------------
261
+
262
+ # A run of node inputs whose estimates all fall under one value well below
263
+ # their own declared budgets (row 4.8): the candidate ceiling is the
264
+ # largest effective estimate among the usable attempts (every attempt
265
+ # whose declared budget and input file are both known), and it is only
266
+ # reported when that value sits strictly under EVERY one of those
267
+ # attempts' own declared budgets - never a "cluster within a few percent"
268
+ # of each other, which never fires against a real spread of node input sizes.
269
+ # Row 4.9: fewer than two distinct nodes never reports a ceiling; one
270
+ # node's own repeated attempts are not evidence of a ceiling shared across
271
+ # the intent.
272
+ #
273
+ # B5 (v1 review): `candidate < u[:budget]` alone reads "nothing went over
274
+ # budget", which is true of every intent that simply stayed under its own
275
+ # declared budgets - reproduced with two nodes estimated at 1999 and 1998
276
+ # effective tokens against declared budgets of 2000 each, which the old
277
+ # code reported as "detected: true, value: 1999" though there was no
278
+ # ceiling there at all: both attempts used over 99.9% of their own
279
+ # declared allowance, they just never quite crossed it.
280
+ #
281
+ # "Well below" needs a stated threshold: WELL_BELOW_RATIO is that
282
+ # threshold, and a ceiling is reported only when the candidate uses no
283
+ # more than this fraction of EVERY usable attempt's own declared budget.
284
+ # Reproduced against 340's real node inputs: declared budgets there run from
285
+ # 50000 to 160000 tokens (327 D47's per-kind defaults), and every
286
+ # attempt's effective token count clusters between 3380 and 7717 - the
287
+ # candidate (7717) is at most 7717/50000 = 15.4% of even the SMALLEST
288
+ # declared budget among the usable attempts, so it clears this threshold
289
+ # by a wide margin and 340 still reports a detected ceiling. The
290
+ # synthetic 1999-vs-2000 case clears none of it (99.95%, not <= 50%) and
291
+ # correctly reports no ceiling.
292
+ WELL_BELOW_RATIO = 0.5
293
+
294
+ # NEW-2 (v2 review, D21): `candidate <= budget * WELL_BELOW_RATIO` checked
295
+ # against EVERY usable attempt in the intent, with no requirement that
296
+ # more than one of them actually sit anywhere near the candidate. That
297
+ # fires on any spread of generous, unrelated budgets (reproduced: nodes at
298
+ # 3000, 7000 and 1200 effective tokens against declared budgets of
299
+ # 100000, 100000 and 150000 reported "detected: true, value: 7000" though
300
+ # only the 7000 attempt sits anywhere near that number), and it misses a
301
+ # genuine shared ceiling under tight budgets, where the honest utilization
302
+ # of a real cluster crosses 50% (reproduced: three nodes converging
303
+ # tightly on 7717/7690/7650 against a declared 10000 each reported
304
+ # "not_well_below_declared_budgets"). D21's own fix: a candidate is
305
+ # evidence of a ceiling only once several distinct usable attempts
306
+ # actually approach it from below (CLUSTER_BAND_RATIO), and the "well
307
+ # below budget" bar only needs to be strict (WELL_BELOW_RATIO) when that
308
+ # cluster is thin (fewer than SEVERAL_CLUSTER_THRESHOLD distinct nodes);
309
+ # once several distinct nodes converge on the same value, the convergence
310
+ # itself is the primary evidence and the budget bar relaxes to
311
+ # CLUSTERED_WELL_BELOW_RATIO. 340's real cluster (five distinct nodes
312
+ # converging between 6237 and 7717) clears either bar by a wide margin;
313
+ # the two-node 1999/1998-vs-2000 case never reaches the cluster threshold
314
+ # of three, so it still runs against the strict 50% bar unchanged.
315
+ CLUSTER_BAND_RATIO = 0.8
316
+ SEVERAL_CLUSTER_THRESHOLD = 3
317
+ # D25 (after v3 M2): 0.9 let a cluster using ninety percent of its own
318
+ # declared budget report a ceiling - the case where the declared budget IS
319
+ # the ceiling, the opposite of what C19 asks for. 0.8 is "far under" and
320
+ # still clears the pinned cluster case (7717/10000 = 77.17%).
321
+ CLUSTERED_WELL_BELOW_RATIO = 0.8
322
+
323
+ def detect_ceiling(nodes)
324
+ usable = []
325
+ nodes.each do |id, node|
326
+ budget = node[:declared_budget]
327
+ # D21: an attempt whose declared budget is absent or zero carries no
328
+ # evidence either way and is disqualified from the comparison, never
329
+ # the whole intent (reproduced: two nodes declaring a budget of 0
330
+ # with 0 effective tokens each reported "detected: true, value: 0",
331
+ # an artifact of comparing a candidate against a budget of 0 rather
332
+ # than real evidence of a ceiling).
333
+ next unless budget.is_a?(Integer) && budget.positive?
334
+
335
+ node[:attempts].each do |a|
336
+ next unless a[:effective_tokens].is_a?(Integer)
337
+
338
+ usable << { node: id, effective: a[:effective_tokens], budget: budget }
339
+ end
340
+ end
341
+
342
+ distinct_nodes = usable.map { |u| u[:node] }.uniq
343
+ attempt_count = usable.length
344
+
345
+ if distinct_nodes.length < 2
346
+ return { detected: false, reason: :insufficient_nodes, value: nil,
347
+ node_count: distinct_nodes.length, attempt_count: attempt_count }
348
+ end
349
+
350
+ candidate = usable.map { |u| u[:effective] }.max
351
+ cluster = usable.select { |u| u[:effective] >= candidate * CLUSTER_BAND_RATIO }
352
+ cluster_nodes = cluster.map { |u| u[:node] }.uniq
353
+
354
+ if cluster_nodes.length < 2
355
+ return { detected: false, reason: :no_cluster, value: nil,
356
+ node_count: distinct_nodes.length, attempt_count: attempt_count }
357
+ end
358
+
359
+ ratio = cluster_nodes.length >= SEVERAL_CLUSTER_THRESHOLD ? CLUSTERED_WELL_BELOW_RATIO : WELL_BELOW_RATIO
360
+ well_below = cluster.all? { |u| candidate <= u[:budget] * ratio }
361
+
362
+ unless well_below
363
+ return { detected: false, reason: :not_well_below_declared_budgets, value: nil,
364
+ node_count: distinct_nodes.length, attempt_count: attempt_count }
365
+ end
366
+
367
+ # M1 (v3 review, D25): SEVERAL_CLUSTER_THRESHOLD was read only to pick
368
+ # which budget bar applies, never enforced as the bar for "several"
369
+ # itself, so any two-node band that cleared WELL_BELOW_RATIO still
370
+ # reported a ceiling. Clearing the budget bar is necessary but not
371
+ # sufficient: D21 asks for several distinct usable attempts to approach
372
+ # the candidate, and two is a band, not several.
373
+ if cluster_nodes.length < SEVERAL_CLUSTER_THRESHOLD
374
+ return { detected: false, reason: :insufficient_cluster, value: nil,
375
+ node_count: distinct_nodes.length, attempt_count: attempt_count }
376
+ end
377
+
378
+ { detected: true, reason: :ok, value: candidate, node_count: distinct_nodes.length, attempt_count: attempt_count }
379
+ end
380
+ private_class_method :detect_ceiling
381
+
382
+ # --- formatting helpers ------------------------------------------------------
383
+
384
+ def av(value)
385
+ return UNAVAILABLE if value.nil? || value == :unavailable
386
+
387
+ value
388
+ end
389
+ private_class_method :av
390
+
391
+ def sort_ids(ids)
392
+ ids.sort_by do |id|
393
+ m = id.to_s.match(/\A([A-Za-z]+)(\d+)\z/)
394
+ m ? [m[1], m[2].to_i] : [id.to_s, 0]
395
+ end
396
+ end
397
+ private_class_method :sort_ids
398
+
399
+ def deep_freeze(obj)
400
+ case obj
401
+ when Hash
402
+ obj.each { |k, v| deep_freeze(k); deep_freeze(v) }
403
+ when Array
404
+ obj.each { |v| deep_freeze(v) }
405
+ end
406
+ obj.freeze
407
+ end
408
+ private_class_method :deep_freeze
409
+ end