@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,487 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require_relative "node_ledger"
6
+ require_relative "graph_measure"
7
+ require_relative "graph_measure_models"
8
+ require_relative "runner_dispatch"
9
+
10
+ # GraphMeasureCohorts (intent 343, G10, n6): the second half of the `cohorts`
11
+ # verb - approve-then-fix per verify model, hop on versus off, delivery
12
+ # latency, the evidence bar, and the two concurrency ceilings. n5's
13
+ # GraphMeasureModels owns the model-comparison half of the same verb; this
14
+ # module owns everything C33 adds on top of it.
15
+ #
16
+ # Read-only (spec D2): no lock, no store write, no git working tree opened -
17
+ # every metric here is derived from `savepoint.md` alone. It adds no second
18
+ # parser and no second per-intent clock: `NodeLedger.entries_from_content`
19
+ # (the same reader n5 uses) supplies the raw, file-ordered transition lines
20
+ # approve-then-fix needs across every subject, and `GraphMeasure.read` (n1's
21
+ # own store walk over a single intent) supplies the clock, the attempts and
22
+ # their active spans that latency and the hop comparison need - reusing the
23
+ # richest reader this intent already built rather than re-deriving sessions,
24
+ # pauses or active-span intersection a second time. The bar section reuses
25
+ # `GraphMeasureModels`' own qualified-field counts (`qualified:` is
26
+ # injectable so a caller who already has that record never triggers a
27
+ # second store walk to get it); only the population loop that drives
28
+ # approve-then-fix, hop cohorts, latency and concurrency is this module's
29
+ # own, because none of those four are exposed by `GraphMeasureModels`.
30
+ #
31
+ # `NodeLedger.present?` is `private_class_method` (carried from n1): this
32
+ # module keeps its own local copy rather than reopen it, exactly the way
33
+ # `GraphMeasureModels` already does for its own `present?` and
34
+ # `resolve_kind`.
35
+ module GraphMeasureCohorts
36
+ module_function
37
+
38
+ SAVEPOINT_FILE = "savepoint.md"
39
+ BAR = 30
40
+ BAR_METRICS = %w[done running_holder model hop].freeze
41
+ BACKGROUND_TEAMS_CEILING =
42
+ "one background delivery team at a time (roadmap prose only; no config source)"
43
+ HOP_FIELD_RE = /\bhop=/.freeze
44
+
45
+ # {ok:, approve_then_fix:, hop_cohorts:, latency:, bar:, concurrency:}.
46
+ # `qualified:` lets a caller pass the `qualified` hash a `GraphMeasureModels`
47
+ # read already produced (spec D3/D9: the bar counts against those same
48
+ # per-ledger field counts); when omitted this module reads it itself, once.
49
+ def read(store_dir, now: Time.now, bar: BAR, qualified: nil, project_config: {}, global_config: {})
50
+ dir = File.expand_path(store_dir.to_s)
51
+ qualified ||= GraphMeasureModels.read(dir, project_config: project_config, global_config: global_config)[:qualified]
52
+
53
+ approve_acc = {}
54
+ hop_acc = { on: hop_bucket, off: hop_bucket }
55
+ latency_rows = []
56
+ latency_excluded = []
57
+ concurrency_rows = []
58
+
59
+ each_child_name(dir).each do |name|
60
+ path = File.join(dir, name)
61
+ next unless File.directory?(path)
62
+
63
+ status, content = read_savepoint(path)
64
+ next unless status == :ok
65
+
66
+ measure = GraphMeasure.read(path, now: now)
67
+ entries = NodeLedger.entries_from_content(content).reject { |e| e[:torn] }
68
+ # Row 6.12 already excludes an intent with no `Done` line from
69
+ # latency; the same exclusion never reached the hop arms (row 8.13,
70
+ # v1 review M5). A still-open intent's attempts have no known active
71
+ # span (B2), and even after B2 stops fabricating a 0.0 for them, their
72
+ # CLOSED attempts (a failed_verification, say) still counted toward
73
+ # `n` and toward the confound's intent set - inflating a comparison
74
+ # meant to read as "one intent's two delivery phases" into "two
75
+ # intents", silencing `confound_note` exactly when the confound is
76
+ # most real. Reproduced by hand against the real store: before this
77
+ # fix, `graph-measure cohorts` against this very intent's own store
78
+ # printed "confound: (none)" for 340's hop split, because this still-
79
+ # open intent's own hop-tracking attempts were leaking into the "on"
80
+ # arm's intent set; after B2 and this exclusion it correctly names
81
+ # "single intent 340--runner-core-in-session supplies both arms".
82
+ has_done = !!(measure[:clock][:start] && measure[:clock][:end])
83
+
84
+ accumulate_approve_then_fix!(entries, measure[:nodes], approve_acc)
85
+ accumulate_hop_cohorts!(name, content, measure[:nodes], hop_acc, has_done)
86
+ accumulate_latency!(name, measure[:clock], measure[:scaffold], latency_rows, latency_excluded)
87
+ accumulate_concurrency!(name, measure[:nodes], now, concurrency_rows)
88
+ end
89
+
90
+ record = {
91
+ ok: true,
92
+ approve_then_fix: finalize_approve(approve_acc),
93
+ hop_cohorts: finalize_hop(hop_acc),
94
+ latency: finalize_latency(latency_rows, latency_excluded),
95
+ bar: bar_section(qualified, bar),
96
+ concurrency: finalize_concurrency(concurrency_rows),
97
+ }
98
+ deep_freeze(record)
99
+ end
100
+
101
+ # --- rendering ---------------------------------------------------------------
102
+
103
+ def render_json(record)
104
+ JSON.generate(model(record))
105
+ end
106
+
107
+ def render_text(record)
108
+ lines = []
109
+ lines.concat(approve_block(record[:approve_then_fix]))
110
+ lines << ""
111
+ lines.concat(hop_block(record[:hop_cohorts]))
112
+ lines << ""
113
+ lines.concat(latency_block(record[:latency]))
114
+ lines << ""
115
+ lines.concat(bar_block(record[:bar]))
116
+ lines << ""
117
+ lines.concat(concurrency_block(record[:concurrency]))
118
+ "#{lines.join("\n")}\n"
119
+ end
120
+
121
+ def model(record)
122
+ {
123
+ "approve_then_fix" => record[:approve_then_fix].transform_values { |v| approve_row_model(v) },
124
+ "hop_cohorts" => hop_model(record[:hop_cohorts]),
125
+ "latency" => latency_model(record[:latency]),
126
+ "bar" => bar_model(record[:bar]),
127
+ "concurrency" => concurrency_model(record[:concurrency]),
128
+ }
129
+ end
130
+
131
+ def approve_row_model(v)
132
+ { "accepted" => v[:accepted], "fixed" => v[:fixed], "rate" => v[:rate] }
133
+ end
134
+ private_class_method :approve_row_model
135
+
136
+ def hop_model(h)
137
+ { "on" => arm_model(h[:on]), "off" => arm_model(h[:off]), "confound" => h[:confound] }
138
+ end
139
+ private_class_method :hop_model
140
+
141
+ def arm_model(a)
142
+ { "n" => a[:n], "failed_verification_rate" => a[:failed_verification_rate],
143
+ "median_active_span_seconds" => a[:median_active_span_seconds] }
144
+ end
145
+ private_class_method :arm_model
146
+
147
+ def latency_model(l)
148
+ {
149
+ "count" => l[:count],
150
+ "median_seconds" => l[:median_seconds],
151
+ "min_seconds" => l[:min_seconds],
152
+ "max_seconds" => l[:max_seconds],
153
+ "rows" => l[:rows].map { |r| latency_row_model(r) },
154
+ "excluded_no_done" => l[:excluded_no_done],
155
+ }
156
+ end
157
+ private_class_method :latency_model
158
+
159
+ def latency_row_model(r)
160
+ { "intent" => r[:intent], "anchor" => r[:anchor].to_s, "wall_clock_seconds" => r[:wall_clock_seconds],
161
+ "scaffold_gap_seconds" => r[:scaffold_gap_seconds] }
162
+ end
163
+ private_class_method :latency_row_model
164
+
165
+ def bar_model(bar)
166
+ bar.transform_values { |v| v.transform_keys(&:to_s) }
167
+ end
168
+ private_class_method :bar_model
169
+
170
+ def concurrency_model(c)
171
+ {
172
+ "dispatch_ceiling" => c[:dispatch_ceiling],
173
+ "background_teams_ceiling" => c[:background_teams_ceiling],
174
+ "observed" => c[:observed].map { |r| { "intent" => r[:intent], "max_simultaneous_running" => r[:max_simultaneous_running] } },
175
+ }
176
+ end
177
+ private_class_method :concurrency_model
178
+
179
+ def approve_block(by_model)
180
+ lines = ["== Approve-then-fix =="]
181
+ if by_model.empty?
182
+ lines << "(no verify verdicts recorded)"
183
+ return lines
184
+ end
185
+
186
+ by_model.each do |m, v|
187
+ lines << "#{m}: #{v[:fixed]}/#{v[:accepted]} accepted verdicts followed by later work (n=#{v[:accepted]}), " \
188
+ "rate #{rate_text(v[:rate])}"
189
+ end
190
+ lines
191
+ end
192
+ private_class_method :approve_block
193
+
194
+ def hop_block(h)
195
+ lines = ["== Hop cohorts =="]
196
+ lines << "hop on: n=#{h[:on][:n]} failed_verification_rate=#{rate_text(h[:on][:failed_verification_rate])} " \
197
+ "median_active_span=#{span_minutes_text(h[:on][:median_active_span_seconds])}"
198
+ lines << "hop off: n=#{h[:off][:n]} failed_verification_rate=#{rate_text(h[:off][:failed_verification_rate])} " \
199
+ "median_active_span=#{span_minutes_text(h[:off][:median_active_span_seconds])}"
200
+ lines << "confound: #{h[:confound] || '(none)'}"
201
+ lines
202
+ end
203
+ private_class_method :hop_block
204
+
205
+ def latency_block(l)
206
+ lines = ["== Delivery latency =="]
207
+ lines << "qualified intents: #{l[:count]}"
208
+ lines << "median: #{span_hours_text(l[:median_seconds])}"
209
+ lines << "min: #{span_hours_text(l[:min_seconds])}"
210
+ lines << "max: #{span_hours_text(l[:max_seconds])}"
211
+ lines << "excluded (no Done line): #{l[:excluded_no_done].empty? ? '(none)' : l[:excluded_no_done].join(', ')}"
212
+ l[:rows].each do |r|
213
+ lines << " #{r[:intent]}: #{span_hours_text(r[:wall_clock_seconds])} " \
214
+ "(anchor=#{r[:anchor]}, scaffold_gap=#{span_hours_text(r[:scaffold_gap_seconds])})"
215
+ end
216
+ lines
217
+ end
218
+ private_class_method :latency_block
219
+
220
+ def bar_block(bar)
221
+ lines = ["== Evidence bar (#{BAR} qualified deliveries per metric) =="]
222
+ bar.each do |metric, v|
223
+ lines << "#{metric}: #{v[:qualified]} qualified against a bar of #{v[:bar]} (shortfall #{v[:shortfall]})"
224
+ end
225
+ lines
226
+ end
227
+ private_class_method :bar_block
228
+
229
+ def concurrency_block(c)
230
+ lines = ["== Concurrency =="]
231
+ lines << "per-intent dispatch ceiling: RunnerDispatch::DEFAULT_LIMIT = #{c[:dispatch_ceiling]}"
232
+ lines << "background-agent ceiling: #{c[:background_teams_ceiling]}"
233
+ c[:observed].each { |r| lines << " #{r[:intent]}: max simultaneous running = #{r[:max_simultaneous_running]}" }
234
+ lines
235
+ end
236
+ private_class_method :concurrency_block
237
+
238
+ def rate_text(r)
239
+ r == :unavailable ? "unavailable" : format("%.1f%%", r * 100)
240
+ end
241
+ private_class_method :rate_text
242
+
243
+ def span_minutes_text(s)
244
+ s == :unavailable ? "unavailable" : format("%.1f min", s / 60.0)
245
+ end
246
+ private_class_method :span_minutes_text
247
+
248
+ def span_hours_text(s)
249
+ (s.nil? || s == :unavailable) ? "unavailable" : format("%.2fh", s / 3600.0)
250
+ end
251
+ private_class_method :span_hours_text
252
+
253
+ # --- the store walk (own copy: GraphMeasureModels' equivalent is private) ----
254
+
255
+ def each_child_name(dir)
256
+ return [] unless File.directory?(dir)
257
+
258
+ Dir.children(dir).reject { |e| e.start_with?(".") }.sort
259
+ rescue Errno::EACCES, Errno::ENOENT
260
+ []
261
+ end
262
+ private_class_method :each_child_name
263
+
264
+ def read_savepoint(intent_dir)
265
+ content = File.read(File.join(intent_dir, SAVEPOINT_FILE))
266
+ [:ok, content.scrub]
267
+ rescue Errno::ENOENT
268
+ [:missing, nil]
269
+ rescue Errno::EACCES
270
+ [:unreadable, nil]
271
+ end
272
+ private_class_method :read_savepoint
273
+
274
+ # --- approve-then-fix: spec rows 6.1-6.6 --------------------------------------
275
+
276
+ # `entries` is the full, file-ordered, non-torn transition-line list for
277
+ # ONE intent (every subject interleaved, spec row 6.2's "transition lines
278
+ # only" - a Commit line, a Lock takeover line, and every stage line are
279
+ # never transition candidates and never reach this list at all). A verify
280
+ # node's `done` line with `verdict=accept` is "fixed" when ANY later line
281
+ # in this same file-ordered list, regardless of its own timestamp (row
282
+ # 6.4), belongs to a work-kind subject.
283
+ def accumulate_approve_then_fix!(entries, nodes, acc)
284
+ entries.each_with_index do |e, i|
285
+ next unless e[:state] == "done"
286
+
287
+ kind = nodes.dig(e[:subject], :kind)
288
+ next unless kind == "verify"
289
+
290
+ model = nodes.dig(e[:subject], :model)
291
+ model_key = present?(model) ? model.to_s : "unavailable"
292
+ bucket = (acc[model_key] ||= { accepted: 0, fixed: 0 })
293
+
294
+ next unless e[:fields]["verdict"] == "accept"
295
+
296
+ bucket[:accepted] += 1
297
+ followed = entries[(i + 1)..].any? { |later| nodes.dig(later[:subject], :kind) == "work" }
298
+ bucket[:fixed] += 1 if followed
299
+ end
300
+ end
301
+ private_class_method :accumulate_approve_then_fix!
302
+
303
+ def finalize_approve(acc)
304
+ acc.transform_values do |b|
305
+ rate = b[:accepted].zero? ? :unavailable : (b[:fixed].to_f / b[:accepted])
306
+ { accepted: b[:accepted], fixed: b[:fixed], rate: rate }
307
+ end
308
+ end
309
+ private_class_method :finalize_approve
310
+
311
+ # --- hop cohorts: spec rows 6.7-6.10 ------------------------------------------
312
+
313
+ def hop_bucket
314
+ { count: 0, failed: 0, spans: [], intents: [] }
315
+ end
316
+ private_class_method :hop_bucket
317
+
318
+ # A ledger that never once writes `hop=` contributes to NEITHER arm (spec
319
+ # row 6.8): it is unavailable evidence, not a hop-off data point. Every
320
+ # CLOSED attempt (any terminal state) in a ledger that does track hop=
321
+ # goes to the "on" arm when ITS OWN running line carried `hop=` (spec row
322
+ # 6.7: split per attempt, never per node) and to "off" otherwise.
323
+ def accumulate_hop_cohorts!(name, content, nodes, hop_acc, has_done)
324
+ return unless has_done
325
+ return unless content.match?(HOP_FIELD_RE)
326
+
327
+ nodes.each_value do |node|
328
+ Array(node[:attempts]).each do |attempt|
329
+ next unless attempt[:terminal_state]
330
+
331
+ arm = present?(attempt[:fields]["hop"]) ? :on : :off
332
+ bucket = hop_acc[arm]
333
+ bucket[:count] += 1
334
+ bucket[:failed] += 1 if attempt[:terminal_state] == "failed_verification"
335
+ bucket[:spans] << attempt[:active_span_seconds] if attempt[:active_span_seconds]
336
+ bucket[:intents] << name
337
+ end
338
+ end
339
+ end
340
+ private_class_method :accumulate_hop_cohorts!
341
+
342
+ def finalize_hop(hop_acc)
343
+ {
344
+ on: arm_summary(hop_acc[:on]),
345
+ off: arm_summary(hop_acc[:off]),
346
+ confound: confound_note(hop_acc[:on][:intents], hop_acc[:off][:intents]),
347
+ }
348
+ end
349
+ private_class_method :finalize_hop
350
+
351
+ def arm_summary(bucket)
352
+ n = bucket[:count]
353
+ return { n: 0, failed_verification_rate: :unavailable, median_active_span_seconds: :unavailable } if n.zero?
354
+
355
+ { n: n, failed_verification_rate: bucket[:failed].to_f / n, median_active_span_seconds: median(bucket[:spans]) }
356
+ end
357
+ private_class_method :arm_summary
358
+
359
+ # Named only when both arms are non-empty and drawn from exactly the same
360
+ # single intent (spec row 6.10): the hop split then coincides with that
361
+ # one intent's own delivery phases, not an independent variable.
362
+ def confound_note(on_intents, off_intents)
363
+ return nil if on_intents.empty? || off_intents.empty?
364
+
365
+ all = (on_intents + off_intents).uniq
366
+ return nil unless all.length == 1
367
+
368
+ "single intent #{all.first} supplies both arms; the hop split follows that intent's delivery phases, " \
369
+ "not an independent variable"
370
+ end
371
+ private_class_method :confound_note
372
+
373
+ # --- delivery latency: spec rows 6.11-6.13 ------------------------------------
374
+
375
+ # Reuses GraphMeasure's own clock (D20's Why-anchor, falling back to the
376
+ # first ledger line, never presented as Why-anchored when it is not) -
377
+ # this module computes no anchor of its own. An intent with no `Done`
378
+ # line at all has no clock end and is excluded and named (row 6.12), never
379
+ # left to grow with the wall clock.
380
+ def accumulate_latency!(name, clock, scaffold, rows, excluded)
381
+ unless clock[:start] && clock[:end]
382
+ excluded << name
383
+ return
384
+ end
385
+
386
+ rows << {
387
+ intent: name,
388
+ anchor: clock[:anchor],
389
+ wall_clock_seconds: clock[:wall_clock_seconds],
390
+ scaffold_gap_seconds: scaffold && scaffold[:gap_seconds],
391
+ }
392
+ end
393
+ private_class_method :accumulate_latency!
394
+
395
+ def finalize_latency(rows, excluded)
396
+ seconds = rows.map { |r| r[:wall_clock_seconds] }
397
+ {
398
+ count: rows.length,
399
+ median_seconds: median(seconds),
400
+ min_seconds: seconds.min || :unavailable,
401
+ max_seconds: seconds.max || :unavailable,
402
+ rows: rows,
403
+ excluded_no_done: excluded,
404
+ }
405
+ end
406
+ private_class_method :finalize_latency
407
+
408
+ # --- the evidence bar: spec rows 6.14-6.17 ------------------------------------
409
+
410
+ # Never a recommendation, at any qualified count (spec D9, rows 6.16,
411
+ # 6.17): the record carries only qualified/bar/shortfall/met, nothing this
412
+ # module or its renderers could ever read as "raise the ceiling".
413
+ def bar_section(qualified, bar)
414
+ BAR_METRICS.each_with_object({}) do |metric, memo|
415
+ n = qualified[metric.to_sym].to_i
416
+ memo[metric] = { qualified: n, bar: bar, shortfall: [bar - n, 0].max, met: n >= bar }
417
+ end
418
+ end
419
+ private_class_method :bar_section
420
+
421
+ # --- concurrency: spec rows 6.18-6.19 -----------------------------------------
422
+
423
+ # Every attempt that ever opened a `running` line, in this one intent,
424
+ # regardless of kind or completion. An attempt still open at `now` runs
425
+ # through `now` (the same convention GraphMeasure's own interval merge
426
+ # uses) so a live dispatch still counts toward the overlap.
427
+ def accumulate_concurrency!(name, nodes, now, rows)
428
+ intervals = nodes.values.flat_map { |n| Array(n[:attempts]) }.filter_map do |a|
429
+ next nil unless a[:running_at]
430
+
431
+ { start: a[:running_at], end: a[:terminal_at] || now }
432
+ end
433
+ rows << { intent: name, max_simultaneous_running: intervals.empty? ? :unavailable : max_overlap(intervals) }
434
+ end
435
+ private_class_method :accumulate_concurrency!
436
+
437
+ # A sweep over interval start (+1) and end (-1) events, sorted by time and
438
+ # then by delta ascending, so an end processed at the exact same instant
439
+ # as another interval's start closes first: two attempts that merely hand
440
+ # off at one timestamp are never counted as briefly simultaneous.
441
+ def max_overlap(intervals)
442
+ events = intervals.flat_map { |iv| [[iv[:start], 1], [iv[:end], -1]] }
443
+ events.sort_by! { |(t, delta)| [t, delta] }
444
+ current = 0
445
+ peak = 0
446
+ events.each do |(_t, delta)|
447
+ current += delta
448
+ peak = current if current > peak
449
+ end
450
+ peak
451
+ end
452
+ private_class_method :max_overlap
453
+
454
+ def finalize_concurrency(rows)
455
+ { dispatch_ceiling: RunnerDispatch::DEFAULT_LIMIT, background_teams_ceiling: BACKGROUND_TEAMS_CEILING, observed: rows }
456
+ end
457
+ private_class_method :finalize_concurrency
458
+
459
+ # --- shared helpers ------------------------------------------------------------
460
+
461
+ def median(values)
462
+ return :unavailable if values.empty?
463
+
464
+ sorted = values.sort
465
+ mid = sorted.length / 2
466
+ sorted.length.odd? ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2.0
467
+ end
468
+ private_class_method :median
469
+
470
+ # NodeLedger.present? is private_class_method (carried from n1): local
471
+ # equivalent, not a reopen.
472
+ def present?(value)
473
+ !(value.nil? || value.to_s.strip.empty?)
474
+ end
475
+ private_class_method :present?
476
+
477
+ def deep_freeze(obj)
478
+ case obj
479
+ when Hash
480
+ obj.each { |k, v| deep_freeze(k); deep_freeze(v) }
481
+ when Array
482
+ obj.each { |v| deep_freeze(v) }
483
+ end
484
+ obj.freeze
485
+ end
486
+ private_class_method :deep_freeze
487
+ end