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.
@@ -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