harnex 0.7.13 → 0.7.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +53 -11
- data/TECHNICAL.md +20 -1
- data/guides/01_dispatch.md +29 -9
- data/guides/04_monitoring.md +11 -5
- data/lib/harnex/artifact_report.rb +507 -35
- data/lib/harnex/cli.rb +17 -0
- data/lib/harnex/commands/artifact_report.rb +94 -0
- data/lib/harnex/commands/orchestration.rb +170 -0
- data/lib/harnex/commands/run.rb +49 -5
- data/lib/harnex/commands/wait.rb +6 -1
- data/lib/harnex/commands/watch.rb +2 -0
- data/lib/harnex/orchestration.rb +458 -0
- data/lib/harnex/runtime/session.rb +278 -13
- data/lib/harnex/terminal_status.rb +7 -0
- data/lib/harnex/version.rb +1 -1
- data/lib/harnex.rb +3 -0
- metadata +4 -1
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "json"
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Harnex
|
|
6
|
+
module Orchestration
|
|
7
|
+
SAMPLE_SCHEMA = "harnex.orchestrator_sample.v1"
|
|
8
|
+
REPORT_SCHEMA = "harnex.orchestration_tax.v1"
|
|
9
|
+
ROLES = %w[primary worker].freeze
|
|
10
|
+
SAMPLE_EVENTS = %w[
|
|
11
|
+
sample generation_started generation_finished rotation recovery compaction
|
|
12
|
+
].freeze
|
|
13
|
+
USAGE_FIELDS = %w[
|
|
14
|
+
input_tokens output_tokens cached_input_tokens reasoning_tokens total_tokens cost_usd
|
|
15
|
+
].freeze
|
|
16
|
+
CONTEXT_FIELDS = %w[
|
|
17
|
+
terminal_tokens window_tokens terminal_percent peak_tokens peak_percent
|
|
18
|
+
].freeze
|
|
19
|
+
STATUSES = %w[observed estimated unsupported missing zero].freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def normalize_metadata(meta)
|
|
24
|
+
values = stringify_keys(meta || {})
|
|
25
|
+
run_id = string_value(values["orchestration_run_id"])
|
|
26
|
+
generation_id = string_value(values["orchestration_generation_id"])
|
|
27
|
+
role = string_value(values["orchestration_role"])
|
|
28
|
+
role = nil unless ROLES.include?(role)
|
|
29
|
+
return nil unless run_id || generation_id || role
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
"run_id" => run_id,
|
|
33
|
+
"generation_id" => generation_id,
|
|
34
|
+
"role" => role,
|
|
35
|
+
"project_id" => string_value(values["project_id"]),
|
|
36
|
+
"queue_id" => string_value(values["queue_id"]),
|
|
37
|
+
"session_id" => string_value(values["orchestration_session_id"]),
|
|
38
|
+
"rotation_reason" => string_value(values["orchestration_rotation_reason"])
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def append_sample(path, attrs)
|
|
43
|
+
sample = normalize_sample(attrs)
|
|
44
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
45
|
+
File.open(path, File::WRONLY | File::APPEND | File::CREAT, 0o644) do |file|
|
|
46
|
+
file.write(JSON.generate(sample))
|
|
47
|
+
file.write("\n")
|
|
48
|
+
end
|
|
49
|
+
sample
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def normalize_sample(attrs)
|
|
53
|
+
values = stringify_keys(attrs || {})
|
|
54
|
+
run_id = required_string(values, "orchestration_run_id")
|
|
55
|
+
generation_id = required_string(values, "generation_id")
|
|
56
|
+
event = string_value(values["event"]) || "sample"
|
|
57
|
+
event = "sample" unless SAMPLE_EVENTS.include?(event)
|
|
58
|
+
sample = {
|
|
59
|
+
"schema" => SAMPLE_SCHEMA,
|
|
60
|
+
"orchestration_run_id" => run_id,
|
|
61
|
+
"generation_id" => generation_id,
|
|
62
|
+
"project_id" => string_value(values["project_id"]),
|
|
63
|
+
"queue_id" => string_value(values["queue_id"]),
|
|
64
|
+
"session_id" => string_value(values["session_id"]),
|
|
65
|
+
"event" => event,
|
|
66
|
+
"ts" => iso8601_time(values["ts"] || Time.now),
|
|
67
|
+
"context" => normalize_context(values["context"]),
|
|
68
|
+
"usage" => normalize_usage(values["usage"]),
|
|
69
|
+
"tool_calls" => non_negative_integer(values["tool_calls"]),
|
|
70
|
+
"compactions" => non_negative_integer(values["compactions"]),
|
|
71
|
+
"rotation_reason" => string_value(values["rotation_reason"])
|
|
72
|
+
}
|
|
73
|
+
sample
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def report(dispatch_path:, samples_path: nil, run_id:)
|
|
77
|
+
build_report(
|
|
78
|
+
dispatch_rows: load_jsonl(dispatch_path),
|
|
79
|
+
sample_rows: samples_path ? load_jsonl(samples_path) : [],
|
|
80
|
+
run_id: run_id
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def build_report(dispatch_rows:, sample_rows:, run_id:)
|
|
85
|
+
selected_dispatches = dispatch_rows.select { |row| dispatch_run_id(row) == run_id }
|
|
86
|
+
samples = sample_rows.select do |row|
|
|
87
|
+
row.is_a?(Hash) &&
|
|
88
|
+
row["schema"].to_s == SAMPLE_SCHEMA &&
|
|
89
|
+
row["orchestration_run_id"].to_s == run_id.to_s
|
|
90
|
+
end
|
|
91
|
+
primary_dispatches, worker_dispatches = selected_dispatches.partition do |row|
|
|
92
|
+
row.dig("orchestration", "role").to_s == "primary"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
primary_sources = primary_dispatch_usage_sources(primary_dispatches) + sample_usage_sources(samples)
|
|
96
|
+
primary_contexts = primary_dispatch_context_sources(primary_dispatches) + sample_context_sources(samples)
|
|
97
|
+
generations = build_generations(primary_dispatches, samples)
|
|
98
|
+
accepted_work = accepted_work_keys(worker_dispatches)
|
|
99
|
+
outcome_counts = deduped_outcome_counts(worker_dispatches)
|
|
100
|
+
primary_usage = summarize_usage(primary_sources)
|
|
101
|
+
worker_usage = summarize_usage(worker_dispatches.map { |row| row["usage"] }.compact)
|
|
102
|
+
primary_tool_calls = sum_numeric(primary_dispatches.map { |row| row.dig("actual", "tool_calls") }) +
|
|
103
|
+
latest_sample_sum(samples, "tool_calls")
|
|
104
|
+
primary_compactions = sum_numeric(primary_dispatches.map { |row| row.dig("reliability", "compactions") }) +
|
|
105
|
+
latest_sample_sum(samples, "compactions")
|
|
106
|
+
accepted_count = accepted_work.length
|
|
107
|
+
|
|
108
|
+
{
|
|
109
|
+
"schema" => REPORT_SCHEMA,
|
|
110
|
+
"orchestration_run_id" => run_id,
|
|
111
|
+
"generated_at" => Time.now.iso8601,
|
|
112
|
+
"project_id" => first_known_value(selected_dispatches, samples, "project_id"),
|
|
113
|
+
"queue_id" => first_known_value(selected_dispatches, samples, "queue_id"),
|
|
114
|
+
"primary" => {
|
|
115
|
+
"generation_count" => generations.length,
|
|
116
|
+
"generations" => generations,
|
|
117
|
+
"usage" => primary_usage,
|
|
118
|
+
"context" => summarize_context(primary_contexts),
|
|
119
|
+
"tool_calls" => primary_tool_calls,
|
|
120
|
+
"compactions" => primary_compactions,
|
|
121
|
+
"wall_s" => primary_wall_seconds(primary_dispatches, samples),
|
|
122
|
+
"coverage" => {
|
|
123
|
+
"usage_status" => primary_usage["status"],
|
|
124
|
+
"context_status" => summarize_context(primary_contexts)["status"],
|
|
125
|
+
"dispatch_rows" => primary_dispatches.length,
|
|
126
|
+
"external_samples" => samples.length
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
"workers" => {
|
|
130
|
+
"dispatches" => worker_dispatches.length,
|
|
131
|
+
"usage" => worker_usage,
|
|
132
|
+
"active_s" => sum_numeric(worker_dispatches.map { |row| row.dig("actual", "duration_s") }),
|
|
133
|
+
"outcomes" => outcome_counts,
|
|
134
|
+
"accepted_work_ids" => accepted_work.sort
|
|
135
|
+
},
|
|
136
|
+
"ratios" => {
|
|
137
|
+
"primary_total_tokens_per_accepted_entry" => ratio(primary_usage["total_tokens"], accepted_count),
|
|
138
|
+
"primary_cost_usd_per_accepted_entry" => ratio(primary_usage["cost_usd"], accepted_count),
|
|
139
|
+
"primary_tool_calls_per_accepted_entry" => ratio(primary_tool_calls, accepted_count)
|
|
140
|
+
},
|
|
141
|
+
"coverage" => {
|
|
142
|
+
"primary_usage_status" => primary_usage["status"],
|
|
143
|
+
"worker_usage_status" => worker_usage["status"],
|
|
144
|
+
"primary_context_status" => summarize_context(primary_contexts)["status"]
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def load_jsonl(path)
|
|
150
|
+
return [] unless path && File.file?(path)
|
|
151
|
+
|
|
152
|
+
File.readlines(path, chomp: true).filter_map do |line|
|
|
153
|
+
next if line.strip.empty?
|
|
154
|
+
|
|
155
|
+
JSON.parse(line)
|
|
156
|
+
rescue JSON::ParserError
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def normalize_usage(value)
|
|
162
|
+
usage = stringify_keys(value || {})
|
|
163
|
+
status = normalize_status(usage["status"])
|
|
164
|
+
has_measurement = USAGE_FIELDS.any? { |field| !numeric_or_nil(usage[field]).nil? }
|
|
165
|
+
status ||= has_measurement ? "observed" : "missing"
|
|
166
|
+
{
|
|
167
|
+
"status" => status,
|
|
168
|
+
"cost_usd" => numeric_or_nil(usage["cost_usd"]),
|
|
169
|
+
"cost_source" => string_value(usage["cost_source"]),
|
|
170
|
+
"input_tokens" => numeric_or_nil(usage["input_tokens"]),
|
|
171
|
+
"output_tokens" => numeric_or_nil(usage["output_tokens"]),
|
|
172
|
+
"cached_input_tokens" => numeric_or_nil(usage["cached_input_tokens"] || usage["cached_tokens"]),
|
|
173
|
+
"reasoning_tokens" => numeric_or_nil(usage["reasoning_tokens"]),
|
|
174
|
+
"total_tokens" => numeric_or_nil(usage["total_tokens"])
|
|
175
|
+
}
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def normalize_context(value)
|
|
179
|
+
context = stringify_keys(value || {})
|
|
180
|
+
status = normalize_status(context["status"])
|
|
181
|
+
has_measurement = CONTEXT_FIELDS.any? { |field| !numeric_or_nil(context[field]).nil? }
|
|
182
|
+
status ||= has_measurement ? "observed" : "missing"
|
|
183
|
+
samples = non_negative_integer(context["samples"])
|
|
184
|
+
samples = has_measurement ? 1 : 0 if samples.nil?
|
|
185
|
+
{
|
|
186
|
+
"status" => status,
|
|
187
|
+
"source" => string_value(context["source"]),
|
|
188
|
+
"terminal_tokens" => numeric_or_nil(context["terminal_tokens"] || context["tokens"]),
|
|
189
|
+
"window_tokens" => numeric_or_nil(context["window_tokens"] || context["context_window"]),
|
|
190
|
+
"terminal_percent" => numeric_or_nil(context["terminal_percent"] || context["percent"]),
|
|
191
|
+
"peak_tokens" => numeric_or_nil(context["peak_tokens"] || context["terminal_tokens"] || context["tokens"]),
|
|
192
|
+
"peak_percent" => numeric_or_nil(context["peak_percent"] || context["terminal_percent"] || context["percent"]),
|
|
193
|
+
"samples" => samples,
|
|
194
|
+
"missing_samples" => non_negative_integer(context["missing_samples"]) || 0,
|
|
195
|
+
"latest_sample_status" => string_value(context["latest_sample_status"] || status)
|
|
196
|
+
}
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def dispatch_run_id(row)
|
|
200
|
+
row.dig("orchestration", "run_id") || row.dig("meta", "orchestration_run_id")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def primary_dispatch_usage_sources(rows)
|
|
204
|
+
rows.map { |row| row["usage"] }.compact
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def primary_dispatch_context_sources(rows)
|
|
208
|
+
rows.map { |row| row["context"] }.compact
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def sample_usage_sources(samples)
|
|
212
|
+
latest_by_generation_session(samples, "usage").map { |sample| sample["usage"] }.compact
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def sample_context_sources(samples)
|
|
216
|
+
samples.map { |sample| sample["context"] }.compact
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def build_generations(primary_dispatches, samples)
|
|
220
|
+
generation_ids = []
|
|
221
|
+
generation_ids.concat(primary_dispatches.map { |row| row.dig("orchestration", "generation_id") })
|
|
222
|
+
generation_ids.concat(samples.map { |row| row["generation_id"] })
|
|
223
|
+
generation_ids = generation_ids.compact.map(&:to_s).reject(&:empty?).uniq
|
|
224
|
+
|
|
225
|
+
generation_ids.map do |generation_id|
|
|
226
|
+
dispatches = primary_dispatches.select { |row| row.dig("orchestration", "generation_id").to_s == generation_id }
|
|
227
|
+
generation_samples = samples.select { |row| row["generation_id"].to_s == generation_id }
|
|
228
|
+
contexts = dispatches.map { |row| row["context"] }.compact + generation_samples.map { |row| row["context"] }.compact
|
|
229
|
+
{
|
|
230
|
+
"id" => generation_id,
|
|
231
|
+
"sessions" => generation_sessions(dispatches, generation_samples),
|
|
232
|
+
"rotation_reason" => generation_rotation_reason(dispatches, generation_samples),
|
|
233
|
+
"usage" => summarize_usage(dispatches.map { |row| row["usage"] }.compact + sample_usage_sources(generation_samples)),
|
|
234
|
+
"context" => summarize_context(contexts),
|
|
235
|
+
"tool_calls" => sum_numeric(dispatches.map { |row| row.dig("actual", "tool_calls") }) +
|
|
236
|
+
latest_sample_sum(generation_samples, "tool_calls"),
|
|
237
|
+
"compactions" => sum_numeric(dispatches.map { |row| row.dig("reliability", "compactions") }) +
|
|
238
|
+
latest_sample_sum(generation_samples, "compactions")
|
|
239
|
+
}
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def generation_sessions(dispatches, samples)
|
|
244
|
+
values = dispatches.map { |row| row.dig("orchestration", "session_id") || row.dig("meta", "id") }
|
|
245
|
+
values.concat(samples.map { |row| row["session_id"] })
|
|
246
|
+
values.compact.map(&:to_s).reject(&:empty?).uniq.sort
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def generation_rotation_reason(dispatches, samples)
|
|
250
|
+
dispatches.map { |row| row.dig("orchestration", "rotation_reason") }
|
|
251
|
+
.concat(samples.map { |row| row["rotation_reason"] })
|
|
252
|
+
.compact
|
|
253
|
+
.map(&:to_s)
|
|
254
|
+
.reject(&:empty?)
|
|
255
|
+
.first
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def summarize_usage(usages)
|
|
259
|
+
usages = usages.select { |usage| usage.is_a?(Hash) }
|
|
260
|
+
statuses = usages.map { |usage| normalize_status(usage["status"]) || "missing" }
|
|
261
|
+
summary = {
|
|
262
|
+
"status" => combined_status(statuses),
|
|
263
|
+
"cost_usd" => sum_or_nil(usages.map { |usage| usage["cost_usd"] }),
|
|
264
|
+
"input_tokens" => sum_or_nil(usages.map { |usage| usage["input_tokens"] }),
|
|
265
|
+
"output_tokens" => sum_or_nil(usages.map { |usage| usage["output_tokens"] }),
|
|
266
|
+
"cached_input_tokens" => sum_or_nil(usages.map { |usage| usage["cached_input_tokens"] || usage["cached_tokens"] }),
|
|
267
|
+
"reasoning_tokens" => sum_or_nil(usages.map { |usage| usage["reasoning_tokens"] }),
|
|
268
|
+
"total_tokens" => sum_or_nil(usages.map { |usage| usage["total_tokens"] })
|
|
269
|
+
}
|
|
270
|
+
summary
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def summarize_context(contexts)
|
|
274
|
+
contexts = contexts.select { |context| context.is_a?(Hash) }
|
|
275
|
+
statuses = contexts.map { |context| normalize_status(context["status"]) || "missing" }
|
|
276
|
+
terminal = latest_context_with_value(contexts, "terminal_tokens")
|
|
277
|
+
peak = contexts.max_by { |context| numeric_or_nil(context["peak_tokens"] || context["terminal_tokens"]) || -1 }
|
|
278
|
+
peak_value = peak && (numeric_or_nil(peak["peak_tokens"]) || numeric_or_nil(peak["terminal_tokens"]))
|
|
279
|
+
peak_percent = peak && (numeric_or_nil(peak["peak_percent"]) || numeric_or_nil(peak["terminal_percent"]))
|
|
280
|
+
{
|
|
281
|
+
"status" => combined_status(statuses),
|
|
282
|
+
"source" => contexts.map { |context| string_value(context["source"]) }.compact.first,
|
|
283
|
+
"terminal_tokens" => terminal && numeric_or_nil(terminal["terminal_tokens"]),
|
|
284
|
+
"window_tokens" => terminal && numeric_or_nil(terminal["window_tokens"]),
|
|
285
|
+
"terminal_percent" => terminal && numeric_or_nil(terminal["terminal_percent"]),
|
|
286
|
+
"peak_tokens" => peak_value,
|
|
287
|
+
"peak_percent" => peak_percent,
|
|
288
|
+
"samples" => sum_numeric(contexts.map { |context| context["samples"] }),
|
|
289
|
+
"missing_samples" => sum_numeric(contexts.map { |context| context["missing_samples"] })
|
|
290
|
+
}
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def latest_context_with_value(contexts, field)
|
|
294
|
+
contexts.reverse.find { |context| !numeric_or_nil(context[field]).nil? }
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def accepted_work_keys(rows)
|
|
298
|
+
rows.select { |row| row.dig("outcome", "status").to_s == "accepted" }
|
|
299
|
+
.map { |row| work_key(row) }
|
|
300
|
+
.compact
|
|
301
|
+
.uniq
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def deduped_outcome_counts(rows)
|
|
305
|
+
by_work = {}
|
|
306
|
+
rows.each do |row|
|
|
307
|
+
key = work_key(row) || row.dig("attempt", "run_id") || row.dig("meta", "id")
|
|
308
|
+
next unless key
|
|
309
|
+
|
|
310
|
+
outcome = classify_worker_outcome(row)
|
|
311
|
+
by_work[key] = stronger_outcome(by_work[key], outcome)
|
|
312
|
+
end
|
|
313
|
+
%w[accepted rejected blocked unknown].to_h { |status| [status, by_work.values.count(status)] }
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def classify_worker_outcome(row)
|
|
317
|
+
status = row.dig("outcome", "status").to_s
|
|
318
|
+
return status if %w[accepted rejected].include?(status)
|
|
319
|
+
return "blocked" if row.dig("attempt", "status").to_s == "failed"
|
|
320
|
+
return "blocked" if %w[failure timeout boot_failure].include?(row.dig("actual", "exit").to_s)
|
|
321
|
+
|
|
322
|
+
"unknown"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def stronger_outcome(current, candidate)
|
|
326
|
+
order = { "unknown" => 0, "blocked" => 1, "rejected" => 2, "accepted" => 3 }
|
|
327
|
+
return candidate unless current
|
|
328
|
+
|
|
329
|
+
order.fetch(candidate, 0) > order.fetch(current, 0) ? candidate : current
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def work_key(row)
|
|
333
|
+
attribution = row["attribution"]
|
|
334
|
+
if attribution.is_a?(Hash) && string_value(attribution["work_type"]) && string_value(attribution["work_id"])
|
|
335
|
+
return "#{attribution['work_type']}:#{attribution['work_id']}"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
queue = row["queue"]
|
|
339
|
+
return nil unless queue.is_a?(Hash)
|
|
340
|
+
|
|
341
|
+
%w[entry_id issue plan queue_id].each do |field|
|
|
342
|
+
value = string_value(queue[field])
|
|
343
|
+
return "#{field}:#{value}" if value
|
|
344
|
+
end
|
|
345
|
+
nil
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def primary_wall_seconds(dispatches, samples)
|
|
349
|
+
dispatch_wall = sum_numeric(dispatches.map { |row| row.dig("actual", "duration_s") })
|
|
350
|
+
sample_wall = samples_by_generation(samples).values.sum do |generation_samples|
|
|
351
|
+
times = generation_samples.filter_map { |sample| parse_time(sample["ts"]) }.sort
|
|
352
|
+
times.length > 1 ? (times.last - times.first).to_i : 0
|
|
353
|
+
end
|
|
354
|
+
dispatch_wall + sample_wall
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def first_known_value(dispatches, samples, field)
|
|
358
|
+
dispatches.map { |row| row.dig("queue", field) || row.dig("orchestration", field) }
|
|
359
|
+
.concat(samples.map { |row| row[field] })
|
|
360
|
+
.compact
|
|
361
|
+
.map(&:to_s)
|
|
362
|
+
.reject(&:empty?)
|
|
363
|
+
.first
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def latest_sample_sum(samples, field)
|
|
367
|
+
sum_numeric(latest_by_generation_session(samples, field).map { |sample| sample[field] })
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def latest_by_generation_session(samples, field)
|
|
371
|
+
samples.select { |sample| !sample[field].nil? }
|
|
372
|
+
.group_by { |sample| [sample["generation_id"], sample["session_id"]] }
|
|
373
|
+
.values
|
|
374
|
+
.map { |group| group.max_by { |sample| parse_time(sample["ts"]) || Time.at(0) } }
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def samples_by_generation(samples)
|
|
378
|
+
samples.group_by { |sample| sample["generation_id"].to_s }
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def combined_status(statuses)
|
|
382
|
+
statuses = statuses.compact
|
|
383
|
+
return "missing" if statuses.empty?
|
|
384
|
+
return statuses.first if statuses.uniq.length == 1
|
|
385
|
+
|
|
386
|
+
useful = statuses - %w[missing unsupported]
|
|
387
|
+
return useful.uniq.length == 1 ? useful.first : "mixed" unless useful.empty?
|
|
388
|
+
|
|
389
|
+
statuses.include?("missing") ? "missing" : "unsupported"
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def ratio(value, count)
|
|
393
|
+
return nil unless value.is_a?(Numeric) && count.to_i.positive?
|
|
394
|
+
|
|
395
|
+
value.to_f / count
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def sum_or_nil(values)
|
|
399
|
+
numeric = values.filter_map { |value| numeric_or_nil(value) }
|
|
400
|
+
return nil if numeric.empty?
|
|
401
|
+
|
|
402
|
+
numeric.sum
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def sum_numeric(values)
|
|
406
|
+
values.filter_map { |value| numeric_or_nil(value) }.sum
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def stringify_keys(value)
|
|
410
|
+
return {} unless value.is_a?(Hash)
|
|
411
|
+
|
|
412
|
+
value.each_with_object({}) { |(key, item), memo| memo[key.to_s] = item }
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def required_string(values, key)
|
|
416
|
+
string_value(values[key]) || raise(ArgumentError, "#{key} is required")
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def string_value(value)
|
|
420
|
+
text = value.to_s.strip
|
|
421
|
+
text.empty? ? nil : text
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def numeric_or_nil(value)
|
|
425
|
+
return value if value.is_a?(Numeric) && value.finite?
|
|
426
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
427
|
+
|
|
428
|
+
Float(value)
|
|
429
|
+
rescue ArgumentError
|
|
430
|
+
nil
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def non_negative_integer(value)
|
|
434
|
+
numeric = numeric_or_nil(value)
|
|
435
|
+
return nil unless numeric
|
|
436
|
+
|
|
437
|
+
integer = numeric.to_i
|
|
438
|
+
integer.negative? ? nil : integer
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def normalize_status(value)
|
|
442
|
+
status = string_value(value)
|
|
443
|
+
STATUSES.include?(status) ? status : nil
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def iso8601_time(value)
|
|
447
|
+
return value.iso8601 if value.respond_to?(:iso8601)
|
|
448
|
+
|
|
449
|
+
parse_time(value)&.iso8601 || Time.now.iso8601
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def parse_time(value)
|
|
453
|
+
Time.iso8601(value.to_s)
|
|
454
|
+
rescue ArgumentError
|
|
455
|
+
nil
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
end
|