@zalom/plastic 2.0.0-alpha.22 → 2.0.0-alpha.23
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.
- package/agents/plastic-enforcer.md +6 -3
- package/agents/plastic-executor.md +4 -0
- package/agents/plastic-node-research.md +28 -0
- package/agents/plastic-node-verify.md +27 -0
- package/agents/plastic-node-work.md +32 -0
- package/bin/lib/context_budget.rb +1 -1
- package/hooks/hooks.json +12 -0
- package/hooks/statusline +28 -0
- package/hooks/stop +5 -0
- package/package.json +1 -1
- package/scripts/doctor.rb +80 -6
- package/scripts/end-intent +3 -3
- package/scripts/graph-measure +249 -0
- package/scripts/hook-capture +1 -0
- package/scripts/hook-savepoint +24 -2
- package/scripts/hook-session-start +40 -0
- package/scripts/hook-stop +57 -0
- package/scripts/lib/active_delivery.rb +61 -0
- package/scripts/lib/agent_models.rb +10 -1
- package/scripts/lib/codex_adapter.rb +197 -0
- package/scripts/lib/doctor_core.rb +8 -3
- package/scripts/lib/engine_permissions.rb +88 -0
- package/scripts/lib/graph_edges.rb +4 -4
- package/scripts/lib/graph_file.rb +4 -4
- package/scripts/lib/graph_measure.rb +645 -0
- package/scripts/lib/graph_measure_budget.rb +408 -0
- package/scripts/lib/graph_measure_cohorts.rb +487 -0
- package/scripts/lib/graph_measure_models.rb +411 -0
- package/scripts/lib/graph_measure_report.rb +532 -0
- package/scripts/lib/graph_tree.rb +2 -2
- package/scripts/lib/handoff.rb +36 -5
- package/scripts/lib/harness_adapter.rb +184 -0
- package/scripts/lib/hook_registry.rb +13 -1
- package/scripts/lib/hook_replay.rb +23 -5
- package/scripts/lib/index_projection.rb +1 -1
- package/scripts/lib/installer_core.rb +109 -3
- package/scripts/lib/intent_screen.rb +1 -1
- package/scripts/lib/intent_validator.rb +2 -2
- package/scripts/lib/meter_watch.rb +15 -9
- package/scripts/lib/node_file.rb +3 -3
- package/scripts/lib/node_ledger.rb +8 -1
- package/scripts/lib/node_progress.rb +153 -0
- package/scripts/lib/outcome_report.rb +1 -1
- package/scripts/lib/report_screen.rb +10 -6
- package/scripts/lib/roadmap_graph.rb +1 -1
- package/scripts/lib/roadmap_queue.rb +1 -1
- package/scripts/lib/roadmap_render.rb +1 -1
- package/scripts/lib/runner_absorb.rb +31 -5
- package/scripts/lib/runner_dispatch.rb +26 -11
- package/scripts/lib/runner_until_empty.rb +252 -0
- package/scripts/lib/runner_watch.rb +389 -0
- package/scripts/lib/savepoint.rb +3 -3
- package/scripts/lib/session_git.rb +2 -2
- package/scripts/lib/stop_gate.rb +95 -0
- package/scripts/lib/verify_intent.rb +2 -2
- package/scripts/lib/work_graph_validator.rb +6 -6
- package/scripts/new-intent +1 -1
- package/scripts/node-run +224 -0
- package/scripts/read-config +6 -0
- package/scripts/runner +203 -19
- package/scripts/verify-intent +1 -1
- package/skills/auto/SKILL.md +1 -1
- package/skills/conventions/references/knowledge-graph.md +9 -0
- package/skills/doctor/SKILL.md +3 -3
- package/skills/intent-creating/evals/evals.json +1 -1
- package/skills/intent-executing/SKILL.md +6 -0
- package/skills/tutorial/references/track-2-auto.md +1 -1
|
@@ -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
|