lib/tracy/src/summary.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

   1 const std = @import("std");
   2 const pretty_json = @import("pretty").json;
   3 const sys = @import("sys");
   4 const capture_mod = @import("capture.zig");
   5 const context_mod = @import("context.zig");
   6 const event = @import("event.zig");
   7 const record_mod = @import("record.zig");
   8 const fiber_mod = @import("fiber.zig");
   9 const gpu_mod = @import("gpu.zig");
  10 const lock_mod = @import("lock.zig");
  11 const memory_mod = @import("memory.zig");
  12 const plot_mod = @import("plot.zig");
  13 const sample_mod = @import("sample.zig");
  14 const system_mod = @import("system.zig");
  15 const transport = @import("transport.zig");
  16 
  17 pub const Options = struct {
  18     top: usize = 20,
  19 };
  20 
  21 pub const Counters = struct {
  22     started_zones: u64 = 0,
  23     completed_zones: u64 = 0,
  24     zone_duration_samples: u64 = 0,
  25     zone_timestamp_regressions: u64 = 0,
  26     unmatched_zone_ends: u64 = 0,
  27     messages: u64 = 0,
  28     frames: u64 = 0,
  29     plots: u64 = 0,
  30     plot_configurations: u64 = 0,
  31     plot_configuration_conflicts: u64 = 0,
  32     app_infos: u64 = 0,
  33     thread_names: u64 = 0,
  34     samples: u64 = 0,
  35     sample_frames: u64 = 0,
  36     fiber_enters: u64 = 0,
  37     fiber_leaves: u64 = 0,
  38     fiber_names: u64 = 0,
  39     fiber_unmatched_leaves: u64 = 0,
  40     gpu_contexts: u64 = 0,
  41     gpu_zones: u64 = 0,
  42     gpu_completed_zones: u64 = 0,
  43     gpu_times: u64 = 0,
  44     gpu_annotations: u64 = 0,
  45     gpu_unmatched_ends: u64 = 0,
  46     gpu_unmatched_times: u64 = 0,
  47     gpu_duplicate_queries: u64 = 0,
  48     gpu_duplicate_times: u64 = 0,
  49     gpu_duration_samples: u64 = 0,
  50     gpu_cpu_duration_samples: u64 = 0,
  51     gpu_timestamp_regressions: u64 = 0,
  52     gpu_cpu_timestamp_regressions: u64 = 0,
  53     system_thread_contexts: u64 = 0,
  54     system_thread_pid_maps: u64 = 0,
  55     system_cpu_topology: u64 = 0,
  56     system_sys_time: u64 = 0,
  57     system_sys_power: u64 = 0,
  58     system_computed_power: u64 = 0,
  59     system_hw_samples: u64 = 0,
  60     context_switches: u64 = 0,
  61     thread_wakeups: u64 = 0,
  62     lock_events: u64 = 0,
  63 };
  64 
  65 pub const CaptureIntegrity = capture_mod.Integrity;
  66 
  67 const ActiveZone = struct {
  68     name: []u8,
  69     file: ?[]u8 = null,
  70     function: ?[]u8 = null,
  71     line: u32 = 0,
  72     thread: u64 = 0,
  73     start_ns: u64 = 0,
  74     value: ?u64 = null,
  75     color: ?u32 = null,
  76 
  77     fn deinit(self: *ActiveZone, allocator: std.mem.Allocator) void {
  78         allocator.free(self.name);
  79         if (self.file) |file| allocator.free(file);
  80         if (self.function) |function| allocator.free(function);
  81         self.* = undefined;
  82     }
  83 };
  84 
  85 pub const ZoneSummary = struct {
  86     name: []const u8,
  87     file: ?[]const u8 = null,
  88     function: ?[]const u8 = null,
  89     line: u32 = 0,
  90     count: u64 = 0,
  91     duration_samples: u64 = 0,
  92     total_ns: u64 = 0,
  93     min_ns: u64 = std.math.maxInt(u64),
  94     max_ns: u64 = 0,
  95     durations_ns: std.ArrayListUnmanaged(u64) = .empty,
  96 
  97     pub fn meanNs(self: ZoneSummary) u64 {
  98         if (self.duration_samples == 0) return 0;
  99         return self.total_ns / self.duration_samples;
 100     }
 101 
 102     pub fn percentileNs(self: ZoneSummary, percentile: u8) u64 {
 103         if (self.durations_ns.items.len == 0) return 0;
 104         const capped = @min(percentile, 100);
 105         const top_index = self.durations_ns.items.len - 1;
 106         const index = (top_index * capped) / 100;
 107         return self.durations_ns.items[index];
 108     }
 109 };
 110 
 111 pub const ZonePrefixSummary = struct {
 112     name: []const u8,
 113     count: u64 = 0,
 114     duration_samples: u64 = 0,
 115     total_ns: u64 = 0,
 116     min_ns: u64 = std.math.maxInt(u64),
 117     max_ns: u64 = 0,
 118 
 119     pub fn meanNs(self: ZonePrefixSummary) u64 {
 120         if (self.duration_samples == 0) return 0;
 121         return self.total_ns / self.duration_samples;
 122     }
 123 };
 124 
 125 pub const ZoneThreadSummary = struct {
 126     thread: u64,
 127     name: ?[]const u8 = null,
 128     count: u64 = 0,
 129     duration_samples: u64 = 0,
 130     total_ns: u64 = 0,
 131     min_ns: u64 = std.math.maxInt(u64),
 132     max_ns: u64 = 0,
 133 
 134     pub fn meanNs(self: ZoneThreadSummary) u64 {
 135         if (self.duration_samples == 0) return 0;
 136         return self.total_ns / self.duration_samples;
 137     }
 138 };
 139 
 140 pub const PlotSummary = plot_mod.Summary;
 141 pub const MemorySummary = memory_mod.Summary;
 142 pub const SampleSummary = sample_mod.Summary;
 143 pub const LockSummary = lock_mod.Summary;
 144 pub const ContextSummary = context_mod.Summary;
 145 pub const FiberSummary = fiber_mod.Summary;
 146 pub const GpuSummary = gpu_mod.Summary;
 147 pub const SystemSummary = system_mod.Summary;
 148 
 149 pub const Analyzer = struct {
 150     allocator: std.mem.Allocator,
 151     capture: capture_mod.Tracker = .{},
 152     active_zones: std.AutoHashMapUnmanaged(u64, ActiveZone) = .{},
 153     zone_summaries: std.StringHashMapUnmanaged(ZoneSummary) = .{},
 154     zone_prefix_summaries: std.StringHashMapUnmanaged(ZonePrefixSummary) = .{},
 155     zone_thread_summaries: std.AutoHashMapUnmanaged(u64, ZoneThreadSummary) = .{},
 156     plots: plot_mod.Analyzer,
 157     memory: memory_mod.Analyzer,
 158     samples: sample_mod.Analyzer,
 159     fibers: fiber_mod.Analyzer,
 160     gpu: gpu_mod.Analyzer,
 161     system: system_mod.Analyzer,
 162     context: context_mod.Analyzer,
 163     locks: lock_mod.Analyzer,
 164     counters: Counters = .{},
 165     start_ns: ?u64 = null,
 166     end_ns: ?u64 = null,
 167 
 168     pub fn init(allocator: std.mem.Allocator) Analyzer {
 169         return .{
 170             .allocator = allocator,
 171             .plots = plot_mod.Analyzer.init(allocator),
 172             .memory = memory_mod.Analyzer.init(allocator),
 173             .samples = sample_mod.Analyzer.init(allocator),
 174             .fibers = fiber_mod.Analyzer.init(allocator),
 175             .gpu = gpu_mod.Analyzer.init(allocator),
 176             .system = system_mod.Analyzer.init(allocator),
 177             .context = context_mod.Analyzer.init(allocator),
 178             .locks = lock_mod.Analyzer.init(allocator),
 179         };
 180     }
 181 
 182     pub fn deinit(self: *Analyzer) void {
 183         var active_iter = self.active_zones.valueIterator();
 184         while (active_iter.next()) |zone| zone.deinit(self.allocator);
 185         self.active_zones.deinit(self.allocator);
 186 
 187         var zone_iter = self.zone_summaries.iterator();
 188         while (zone_iter.next()) |entry| {
 189             self.allocator.free(entry.key_ptr.*);
 190             if (entry.value_ptr.file) |file| self.allocator.free(file);
 191             if (entry.value_ptr.function) |function| self.allocator.free(function);
 192             entry.value_ptr.durations_ns.deinit(self.allocator);
 193         }
 194         self.zone_summaries.deinit(self.allocator);
 195 
 196         var prefix_iter = self.zone_prefix_summaries.iterator();
 197         while (prefix_iter.next()) |entry| self.allocator.free(entry.key_ptr.*);
 198         self.zone_prefix_summaries.deinit(self.allocator);
 199 
 200         var thread_iter = self.zone_thread_summaries.valueIterator();
 201         while (thread_iter.next()) |entry| {
 202             if (entry.name) |name| self.allocator.free(name);
 203         }
 204         self.zone_thread_summaries.deinit(self.allocator);
 205 
 206         self.plots.deinit();
 207         self.memory.deinit();
 208         self.samples.deinit();
 209         self.fibers.deinit();
 210         self.gpu.deinit();
 211         self.system.deinit();
 212         self.context.deinit();
 213         self.locks.deinit();
 214         self.* = undefined;
 215     }
 216 
 217     pub fn ingestJsonlBytes(self: *Analyzer, bytes: []const u8) !void {
 218         var lines = std.mem.splitScalar(u8, bytes, '\n');
 219         while (lines.next()) |line| try self.ingestJsonLine(line);
 220     }
 221 
 222     pub fn ingestJsonLine(self: *Analyzer, line: []const u8) !void {
 223         const text = std.mem.trim(u8, line, " \t\r\n");
 224         if (text.len == 0) return;
 225         var parsed = try record_mod.parseLine(self.allocator, text);
 226         defer parsed.deinit();
 227         switch (parsed) {
 228             .event => |value| try self.ingest(value),
 229             .flight => |report_value| {
 230                 self.capture.recordFlightReport(report_value);
 231                 self.plots.recordFlightReport(report_value);
 232                 self.memory.recordFlightReport(report_value);
 233                 self.context.recordFlightReport(report_value);
 234                 self.locks.recordFlightReport(report_value);
 235             },
 236         }
 237     }
 238 
 239     pub fn ingest(self: *Analyzer, parsed: event.Parsed) !void {
 240         self.capture.record(parsed);
 241         try self.context.ingest(parsed);
 242         try self.plots.ingest(parsed);
 243         try self.memory.ingest(parsed);
 244         try self.locks.ingest(parsed);
 245         self.counters.plots = self.plots.counters.samples;
 246         self.counters.plot_configurations = self.plots.counters.configurations;
 247         self.counters.plot_configuration_conflicts =
 248             self.plots.counters.configuration_conflicts;
 249         if (self.start_ns == null and parsed.time_ns != 0) self.start_ns = parsed.time_ns;
 250         if (parsed.time_ns != 0) self.end_ns = parsed.time_ns;
 251         switch (parsed.kind) {
 252             .start => {
 253                 if (parsed.time_ns != 0) self.start_ns = parsed.time_ns;
 254             },
 255             .stop => {
 256                 if (parsed.time_ns != 0) self.end_ns = parsed.time_ns;
 257             },
 258             .zone_begin => try self.recordZoneBegin(parsed),
 259             .zone_end => try self.recordZoneEnd(parsed),
 260             .zone_name => try self.recordZoneName(parsed),
 261             .zone_color => self.recordZoneColor(parsed),
 262             .zone_value => self.recordZoneValue(parsed),
 263             .message => self.counters.messages += 1,
 264             .frame => self.counters.frames += 1,
 265             .plot, .plot_config => {},
 266             .app_info => self.counters.app_infos += 1,
 267             .thread_name => {
 268                 self.counters.thread_names += 1;
 269                 try self.recordThreadName(parsed);
 270                 try self.fibers.ingest(parsed);
 271             },
 272             .fiber_name, .fiber_enter, .fiber_leave => {
 273                 try self.fibers.ingest(parsed);
 274                 self.counters.fiber_enters = self.fibers.counters.enters;
 275                 self.counters.fiber_leaves = self.fibers.counters.leaves;
 276                 self.counters.fiber_names = self.fibers.counters.names;
 277                 self.counters.fiber_unmatched_leaves = self.fibers.counters.unmatched_leaves;
 278             },
 279             .gpu_context,
 280             .gpu_context_name,
 281             .gpu_zone_begin,
 282             .gpu_zone_end,
 283             .gpu_time,
 284             .gpu_calibration,
 285             .gpu_time_sync,
 286             .gpu_annotation,
 287             .gpu_annotation_name,
 288             => {
 289                 try self.gpu.ingest(parsed);
 290                 self.counters.gpu_contexts = @intCast(self.gpu.contexts.count());
 291                 self.counters.gpu_zones = self.gpu.counters.zone_begins;
 292                 self.counters.gpu_completed_zones = self.gpu.counters.completed_zones;
 293                 self.counters.gpu_times = self.gpu.counters.gpu_times;
 294                 self.counters.gpu_annotations = self.gpu.counters.annotations;
 295                 self.counters.gpu_unmatched_ends = self.gpu.counters.unmatched_ends;
 296                 self.counters.gpu_unmatched_times = self.gpu.counters.unmatched_times;
 297                 self.counters.gpu_duplicate_queries = self.gpu.counters.duplicate_queries;
 298                 self.counters.gpu_duplicate_times = self.gpu.counters.duplicate_times;
 299                 self.counters.gpu_duration_samples = self.gpu.counters.gpu_duration_samples;
 300                 self.counters.gpu_cpu_duration_samples = self.gpu.counters.cpu_duration_samples;
 301                 self.counters.gpu_timestamp_regressions =
 302                     self.gpu.counters.gpu_timestamp_regressions;
 303                 self.counters.gpu_cpu_timestamp_regressions =
 304                     self.gpu.counters.cpu_timestamp_regressions;
 305             },
 306             .thread_context,
 307             .thread_pid,
 308             .cpu_topology,
 309             .sys_time,
 310             .sys_power,
 311             .hw_sample,
 312             => {
 313                 try self.system.ingest(parsed);
 314                 self.counters.system_thread_contexts = self.system.counters.thread_contexts;
 315                 self.counters.system_thread_pid_maps = self.system.counters.thread_pid_maps;
 316                 self.counters.system_cpu_topology = self.system.counters.cpu_topology;
 317                 self.counters.system_sys_time = self.system.counters.sys_time;
 318                 self.counters.system_sys_power = self.system.counters.sys_power;
 319                 self.counters.system_computed_power = self.system.counters.computed_power;
 320                 self.counters.system_hw_samples = self.system.counters.hw_samples;
 321             },
 322             .sample, .sample_frame => {
 323                 try self.samples.ingest(parsed);
 324                 self.counters.samples = self.samples.counters.samples;
 325                 self.counters.sample_frames = self.samples.counters.frames;
 326             },
 327             .context_switch, .thread_wakeup => {
 328                 self.counters.context_switches = self.context.counters.switches;
 329                 self.counters.thread_wakeups = self.context.counters.wakeups;
 330             },
 331             .alloc, .free => {},
 332             .lock_announce,
 333             .lock_terminate,
 334             .lock_wait,
 335             .lock_obtain,
 336             .lock_release,
 337             .lock_shared_wait,
 338             .lock_shared_obtain,
 339             .lock_shared_release,
 340             .lock_mark,
 341             .lock_name,
 342             => {
 343                 self.counters.lock_events +|= 1;
 344             },
 345             .zone_text => {},
 346         }
 347     }
 348 
 349     pub fn captureIntegrity(self: *const Analyzer) CaptureIntegrity {
 350         return self.capture.integrity(self.unbalancedEventCount());
 351     }
 352 
 353     pub fn zoneDurationEvidence(self: *const Analyzer) []const u8 {
 354         if (!std.mem.eql(u8, self.captureIntegrity().status, "complete")) return "partial";
 355         if (self.counters.zone_timestamp_regressions != 0) return "partial";
 356         return "complete";
 357     }
 358 
 359     pub fn gpuDurationEvidence(self: *const Analyzer) []const u8 {
 360         if (!std.mem.eql(u8, self.captureIntegrity().status, "complete")) return "partial";
 361         return if (self.gpu.durationPairsComplete()) "complete" else "partial";
 362     }
 363 
 364     fn unbalancedEventCount(self: *const Analyzer) u64 {
 365         var count: u64 = @intCast(self.active_zones.count());
 366         count +|= self.counters.unmatched_zone_ends;
 367         count +|= self.fibers.counters.unmatched_leaves;
 368         count +|= self.fibers.activeCount();
 369         count +|= self.gpu.counters.unmatched_ends;
 370         count +|= self.gpu.counters.unmatched_times;
 371         count +|= self.gpu.incompleteZoneCount();
 372         count +|= self.memory.counters.unmatched_frees;
 373         count +|= self.locks.counters.unmatched_releases;
 374         count +|= @intCast(self.locks.pending_waits.count());
 375         count +|= @intCast(self.locks.pending_holds.count());
 376         return count;
 377     }
 378 
 379     pub fn writeSummary(self: *Analyzer, writer: *std.Io.Writer, options: Options) !void {
 380         var zones = try self.collectZones();
 381         defer zones.deinit(self.allocator);
 382         var zone_prefixes = try self.collectZonePrefixes();
 383         defer zone_prefixes.deinit(self.allocator);
 384         var zone_threads = try self.collectZoneThreads();
 385         defer zone_threads.deinit(self.allocator);
 386         var plots = try self.collectPlots();
 387         defer plots.deinit(self.allocator);
 388         var memory = try self.collectMemory();
 389         defer memory.deinit(self.allocator);
 390         var samples = try self.collectSamples();
 391         defer sample_mod.deinitSummaries(self.allocator, &samples);
 392         var lock_rows = try self.collectLocks();
 393         defer lock_rows.deinit(self.allocator);
 394         var fiber_rows = try self.collectFibers();
 395         defer fiber_mod.deinitSummaries(self.allocator, &fiber_rows);
 396         var gpu_rows = try self.collectGpu();
 397         defer gpu_mod.deinitSummaries(self.allocator, &gpu_rows);
 398         var system_rows = try self.collectSystem();
 399         defer system_mod.deinitSummaries(self.allocator, &system_rows);
 400         var context_rows = try self.collectContext();
 401         defer context_mod.deinitSummaries(self.allocator, &context_rows);
 402 
 403         try writer.print(
 404             "tracy events={d} zones={d} zone_duration_samples={d} " ++
 405                 "zone_timestamp_regressions={d} zone_duration_evidence={s} " ++
 406                 "zone_prefixes={d} zone_threads={d} active_zones={d} " ++
 407                 "messages={d} frames={d} plots={d} " ++
 408                 "plot_configurations={d} plot_configuration_conflicts={d} " ++
 409                 "samples={d} sample_frames={d} fiber_enters={d} fiber_leaves={d} " ++
 410                 "fiber_names={d} context_switches={d} thread_wakeups={d} " ++
 411                 "context_ready_latency_samples={d} context_redundant_wakeups={d} " ++
 412                 "context_ready_time_regressions={d} context_pending_ready_intervals={d} " ++
 413                 "context_ready_latency_evidence={s} duration_ns={d}\n",
 414             .{
 415                 self.capture.counters.events,
 416                 self.counters.completed_zones,
 417                 self.counters.zone_duration_samples,
 418                 self.counters.zone_timestamp_regressions,
 419                 self.zoneDurationEvidence(),
 420                 zone_prefixes.items.len,
 421                 zone_threads.items.len,
 422                 self.active_zones.count(),
 423                 self.counters.messages,
 424                 self.counters.frames,
 425                 self.counters.plots,
 426                 self.counters.plot_configurations,
 427                 self.counters.plot_configuration_conflicts,
 428                 self.counters.samples,
 429                 self.counters.sample_frames,
 430                 self.counters.fiber_enters,
 431                 self.counters.fiber_leaves,
 432                 self.counters.fiber_names,
 433                 self.counters.context_switches,
 434                 self.counters.thread_wakeups,
 435                 self.context.counters.ready_latency_samples,
 436                 self.context.counters.redundant_wakeups,
 437                 self.context.counters.ready_time_regressions,
 438                 self.context.pendingReadyIntervals(),
 439                 self.context.readyLatencyEvidence(),
 440                 self.durationNs(),
 441             },
 442         );
 443         try capture_mod.writeText(writer, self.captureIntegrity());
 444         const memory_counters = self.memory.counters;
 445         try writer.print(
 446             "tracy allocations={d} frees={d} completed_lifetimes={d} " ++
 447                 "lifetime_samples={d} right_censored_allocations={d} live_bytes={d} " ++
 448                 "high_water_live_bytes={d} allocated_bytes={d} freed_bytes={d} " ++
 449                 "unmatched_frees={d} duplicate_allocations={d} " ++
 450                 "timestamp_regressions={d} untracked_allocations={d} " ++
 451                 "size_mismatches={d} lifetime_population=completed " ++
 452                 "lifetime_evidence={s}\n",
 453             .{
 454                 memory_counters.allocations,
 455                 memory_counters.frees,
 456                 memory_counters.completed_lifetimes,
 457                 memory_counters.lifetime_samples,
 458                 self.memory.active.count(),
 459                 memory_counters.live_bytes,
 460                 memory_counters.high_water_live_bytes,
 461                 memory_counters.allocated_bytes,
 462                 memory_counters.freed_bytes,
 463                 memory_counters.unmatched_frees,
 464                 memory_counters.duplicate_allocations,
 465                 memory_counters.timestamp_regressions,
 466                 memory_counters.untracked_allocations,
 467                 memory_counters.size_mismatches,
 468                 self.memory.lifetimeEvidence(),
 469             },
 470         );
 471         if (self.counters.unmatched_zone_ends != 0) {
 472             try writer.print("tracy anomalies unmatched_zone_ends={d}\n", .{self.counters.unmatched_zone_ends});
 473         }
 474         if (self.counters.fiber_unmatched_leaves != 0) {
 475             try writer.print("tracy fiber_anomalies unmatched_leaves={d}\n", .{self.counters.fiber_unmatched_leaves});
 476         }
 477         try writer.print(
 478             "tracy locks={d} lock_waits={d} lock_holds={d} lock_wait_samples={d} " ++
 479                 "lock_hold_samples={d} pending_waits={d} pending_holds={d} " ++
 480                 "duplicate_waits={d} duplicate_obtains={d} timestamp_regressions={d} " ++
 481                 "unmatched_releases={d} lock_duration_evidence={s}\n",
 482             .{
 483                 self.locks.locks.count(),
 484                 self.locks.counters.completed_waits,
 485                 self.locks.counters.completed_holds,
 486                 self.locks.counters.wait_samples,
 487                 self.locks.counters.hold_samples,
 488                 self.locks.pending_waits.count(),
 489                 self.locks.pending_holds.count(),
 490                 self.locks.counters.duplicate_waits,
 491                 self.locks.counters.duplicate_obtains,
 492                 self.locks.counters.timestamp_regressions,
 493                 self.locks.counters.unmatched_releases,
 494                 self.locks.durationEvidence(),
 495             },
 496         );
 497         const lock_limit = @min(options.top, lock_rows.items.len);
 498         for (lock_rows.items[0..lock_limit]) |row| {
 499             try writer.print("lock id={d} name=", .{row.id});
 500             try pretty_json.writeString(writer, row.name);
 501             try lock_mod.writeSummaryDurationsText(writer, row);
 502             try writer.print(" threads={d} marks={d} last_event_ns={d}\n", .{
 503                 row.thread_count,
 504                 row.mark_count,
 505                 row.last_event_ns,
 506             });
 507         }
 508         try writeGpuCaptureText(writer, self);
 509         try writer.print(
 510             "tracy system_thread_contexts={d} system_thread_pid_maps={d} system_cpu_topology={d} system_sys_time={d} system_sys_power={d} system_computed_power={d} system_hw_samples={d}\n",
 511             .{
 512                 self.counters.system_thread_contexts,
 513                 self.counters.system_thread_pid_maps,
 514                 self.counters.system_cpu_topology,
 515                 self.counters.system_sys_time,
 516                 self.counters.system_sys_power,
 517                 self.counters.system_computed_power,
 518                 self.counters.system_hw_samples,
 519             },
 520         );
 521         const zone_limit = @min(options.top, zones.items.len);
 522         for (zones.items[0..zone_limit]) |zone| {
 523             try writer.print(
 524                 "zone name=",
 525                 .{},
 526             );
 527             try pretty_json.writeString(writer, zone.name);
 528             try writer.print(
 529                 " count={d} duration_samples={d} total_ns={d} mean_ns={d} " ++
 530                     "min_ns={d} p50_ns={d} p90_ns={d} p99_ns={d} max_ns={d}",
 531                 .{
 532                     zone.count,
 533                     zone.duration_samples,
 534                     zone.total_ns,
 535                     zone.meanNs(),
 536                     if (zone.min_ns == std.math.maxInt(u64)) 0 else zone.min_ns,
 537                     zone.percentileNs(50),
 538                     zone.percentileNs(90),
 539                     zone.percentileNs(99),
 540                     zone.max_ns,
 541                 },
 542             );
 543             if (zone.file) |file| try writer.print(" file={s}:{d}", .{ file, zone.line });
 544             try writer.writeByte('\n');
 545         }
 546         const prefix_limit = @min(options.top, zone_prefixes.items.len);
 547         for (zone_prefixes.items[0..prefix_limit]) |prefix| {
 548             try writer.print("zone_prefix name=", .{});
 549             try pretty_json.writeString(writer, prefix.name);
 550             try writer.print(
 551                 " count={d} duration_samples={d} total_ns={d} " ++
 552                     "mean_ns={d} min_ns={d} max_ns={d}\n",
 553                 .{
 554                     prefix.count,
 555                     prefix.duration_samples,
 556                     prefix.total_ns,
 557                     prefix.meanNs(),
 558                     if (prefix.min_ns == std.math.maxInt(u64)) 0 else prefix.min_ns,
 559                     prefix.max_ns,
 560                 },
 561             );
 562         }
 563         const thread_limit = @min(options.top, zone_threads.items.len);
 564         for (zone_threads.items[0..thread_limit]) |thread| {
 565             try writer.print("zone_thread thread={d}", .{thread.thread});
 566             if (thread.name) |name| {
 567                 try writer.writeAll(" name=");
 568                 try pretty_json.writeString(writer, name);
 569             }
 570             try writer.print(
 571                 " count={d} duration_samples={d} total_ns={d} " ++
 572                     "mean_ns={d} min_ns={d} max_ns={d}\n",
 573                 .{
 574                     thread.count,
 575                     thread.duration_samples,
 576                     thread.total_ns,
 577                     thread.meanNs(),
 578                     if (thread.min_ns == std.math.maxInt(u64)) 0 else thread.min_ns,
 579                     thread.max_ns,
 580                 },
 581             );
 582         }
 583         const plot_limit = @min(options.top, plots.items.len);
 584         for (plots.items[0..plot_limit]) |plot| try writePlotSummaryText(writer, plot);
 585         const memory_limit = @min(options.top, memory.items.len);
 586         for (memory.items[0..memory_limit]) |item| try writeMemorySummaryText(writer, item);
 587         const sample_limit = @min(options.top, samples.items.len);
 588         for (samples.items[0..sample_limit]) |sample| {
 589             try writer.print("sample label=", .{});
 590             try pretty_json.writeString(writer, sample.label);
 591             try writer.print(" count={d} weight={d} first_ns={d} last_ns={d}", .{ sample.count, sample.weight, sample.first_ns, sample.last_ns });
 592             if (sample.kind) |kind| {
 593                 try writer.writeAll(" kind=");
 594                 try pretty_json.writeString(writer, kind);
 595             }
 596             if (sample.file) |file| try writer.print(" file={s}:{d}", .{ file, sample.line });
 597             try writer.writeByte('\n');
 598         }
 599         const fiber_limit = @min(options.top, fiber_rows.items.len);
 600         for (fiber_rows.items[0..fiber_limit]) |row| {
 601             try writer.print("fiber label=", .{});
 602             try pretty_json.writeString(writer, row.label);
 603             try writer.print(" count={d} running_ns={d} enters={d} leaves={d}", .{ row.count, row.running_ns, row.enters, row.leaves });
 604             if (row.fiber) |fiber| try writer.print(" fiber={d}", .{fiber});
 605             if (row.thread) |thread| try writer.print(" thread={d}", .{thread});
 606             if (row.group_hint) |group_hint| try writer.print(" group_hint={d}", .{group_hint});
 607             if (row.migrations != 0) try writer.print(" migrations={d}", .{row.migrations});
 608             if (row.unmatched_leaves != 0) try writer.print(" unmatched_leaves={d}", .{row.unmatched_leaves});
 609             if (row.active) try writer.writeAll(" active=true");
 610             try writer.writeByte('\n');
 611         }
 612         const gpu_limit = @min(options.top, gpu_rows.items.len);
 613         for (gpu_rows.items[0..gpu_limit]) |row| {
 614             try writeGpuSummaryText(writer, row);
 615         }
 616         const system_limit = @min(options.top, system_rows.items.len);
 617         for (system_rows.items[0..system_limit]) |row| {
 618             try writer.print("system label=", .{});
 619             try pretty_json.writeString(writer, row.label);
 620             try writer.print(" count={d}", .{row.count});
 621             try writeSystemSummaryTextFields(writer, row);
 622             try writer.writeByte('\n');
 623         }
 624         const context_limit = @min(options.top, context_rows.items.len);
 625         for (context_rows.items[0..context_limit]) |row| {
 626             try writer.print("context label=", .{});
 627             try pretty_json.writeString(writer, row.label);
 628             try writer.print(
 629                 " count={d} running_ns={d} blocked_ns={d} ready_ns={d} " ++
 630                     "unknown_off_cpu_ns={d}",
 631                 .{
 632                     row.count,
 633                     row.running_ns,
 634                     row.blocked_ns,
 635                     row.ready_ns,
 636                     row.unknown_off_cpu_ns,
 637                 },
 638             );
 639             try writeContextLatencyText(writer, row);
 640             if (row.thread) |thread| try writer.print(" thread={d}", .{thread});
 641             if (row.cpu) |cpu| try writer.print(" cpu={d}", .{cpu});
 642             if (row.migrations != 0) try writer.print(" migrations={d}", .{row.migrations});
 643             try writer.writeByte('\n');
 644         }
 645     }
 646 
 647     pub fn writeSummaryJsonl(self: *Analyzer, writer: *std.Io.Writer, options: Options) !void {
 648         var zones = try self.collectZones();
 649         defer zones.deinit(self.allocator);
 650         var zone_prefixes = try self.collectZonePrefixes();
 651         defer zone_prefixes.deinit(self.allocator);
 652         var zone_threads = try self.collectZoneThreads();
 653         defer zone_threads.deinit(self.allocator);
 654         var plots = try self.collectPlots();
 655         defer plots.deinit(self.allocator);
 656         var memory = try self.collectMemory();
 657         defer memory.deinit(self.allocator);
 658         var samples = try self.collectSamples();
 659         defer sample_mod.deinitSummaries(self.allocator, &samples);
 660         var lock_rows = try self.collectLocks();
 661         defer lock_rows.deinit(self.allocator);
 662         var fiber_rows = try self.collectFibers();
 663         defer fiber_mod.deinitSummaries(self.allocator, &fiber_rows);
 664         var gpu_rows = try self.collectGpu();
 665         defer gpu_mod.deinitSummaries(self.allocator, &gpu_rows);
 666         var system_rows = try self.collectSystem();
 667         defer system_mod.deinitSummaries(self.allocator, &system_rows);
 668         var context_rows = try self.collectContext();
 669         defer context_mod.deinitSummaries(self.allocator, &context_rows);
 670 
 671         var summary_stream = pretty_json.Writer.init(writer, .minified);
 672         const summary = try summary_stream.object();
 673         try summary.field("kind", "summary");
 674         try writeCaptureFields(
 675             summary,
 676             self,
 677             zone_prefixes.items.len,
 678             zone_threads.items.len,
 679         );
 680         try writeGpuCaptureFields(summary, self);
 681         try writeSystemCaptureFields(summary, self);
 682         try writeContextCaptureFields(summary, self);
 683         try summary.field("duration_ns", self.durationNs());
 684         try writeMemoryCaptureFields(summary, self);
 685         try writeLockCaptureFields(summary, self);
 686         try summary.field("memory_lifetime_population", "completed");
 687         try summary.field("memory_lifetime_evidence", self.memory.lifetimeEvidence());
 688         try capture_mod.writeFields(summary, self.captureIntegrity());
 689         try summary.endLine();
 690         const lock_limit = @min(options.top, lock_rows.items.len);
 691         for (lock_rows.items[0..lock_limit]) |row| {
 692             var stream = pretty_json.Writer.init(writer, .minified);
 693             const object = try stream.object();
 694             try object.field("kind", "lock");
 695             try object.field("id", row.id);
 696             try object.field("name", row.name);
 697             try lock_mod.writeSummaryDurationFields(object, row);
 698             try object.field("threads", row.thread_count);
 699             try object.field("marks", row.mark_count);
 700             try object.field("last_event_ns", row.last_event_ns);
 701             try object.endLine();
 702         }
 703         const zone_limit = @min(options.top, zones.items.len);
 704         for (zones.items[0..zone_limit]) |zone| {
 705             var stream = pretty_json.Writer.init(writer, .minified);
 706             const object = try stream.object();
 707             try object.field("kind", "zone");
 708             try object.field("name", zone.name);
 709             try object.field("count", zone.count);
 710             try object.field("duration_samples", zone.duration_samples);
 711             try object.field("total_ns", zone.total_ns);
 712             try object.field("mean_ns", zone.meanNs());
 713             try object.field(
 714                 "min_ns",
 715                 if (zone.min_ns == std.math.maxInt(u64)) 0 else zone.min_ns,
 716             );
 717             try object.field("p50_ns", zone.percentileNs(50));
 718             try object.field("p90_ns", zone.percentileNs(90));
 719             try object.field("p99_ns", zone.percentileNs(99));
 720             try object.field("max_ns", zone.max_ns);
 721             if (zone.file) |file| {
 722                 try object.field("file", file);
 723                 try object.field("line", zone.line);
 724             }
 725             try object.endLine();
 726         }
 727         const prefix_limit = @min(options.top, zone_prefixes.items.len);
 728         for (zone_prefixes.items[0..prefix_limit]) |prefix| {
 729             var stream = pretty_json.Writer.init(writer, .minified);
 730             const object = try stream.object();
 731             try object.field("kind", "zone_prefix");
 732             try object.field("name", prefix.name);
 733             try object.field("count", prefix.count);
 734             try object.field("duration_samples", prefix.duration_samples);
 735             try object.field("total_ns", prefix.total_ns);
 736             try object.field("mean_ns", prefix.meanNs());
 737             try object.field(
 738                 "min_ns",
 739                 if (prefix.min_ns == std.math.maxInt(u64)) 0 else prefix.min_ns,
 740             );
 741             try object.field("max_ns", prefix.max_ns);
 742             try object.endLine();
 743         }
 744         const thread_limit = @min(options.top, zone_threads.items.len);
 745         for (zone_threads.items[0..thread_limit]) |thread| {
 746             var stream = pretty_json.Writer.init(writer, .minified);
 747             const object = try stream.object();
 748             try object.field("kind", "zone_thread");
 749             try object.field("thread", thread.thread);
 750             if (thread.name) |name| try object.field("name", name);
 751             try object.field("count", thread.count);
 752             try object.field("duration_samples", thread.duration_samples);
 753             try object.field("total_ns", thread.total_ns);
 754             try object.field("mean_ns", thread.meanNs());
 755             try object.field(
 756                 "min_ns",
 757                 if (thread.min_ns == std.math.maxInt(u64)) 0 else thread.min_ns,
 758             );
 759             try object.field("max_ns", thread.max_ns);
 760             try object.endLine();
 761         }
 762         const plot_limit = @min(options.top, plots.items.len);
 763         for (plots.items[0..plot_limit]) |plot| try writePlotSummaryJson(writer, plot);
 764         const memory_limit = @min(options.top, memory.items.len);
 765         for (memory.items[0..memory_limit]) |item| try writeMemorySummaryJson(writer, item);
 766         const sample_limit = @min(options.top, samples.items.len);
 767         for (samples.items[0..sample_limit]) |sample| {
 768             var stream = pretty_json.Writer.init(writer, .minified);
 769             const object = try stream.object();
 770             try object.field("kind", "sample");
 771             try object.field("label", sample.label);
 772             try object.field("count", sample.count);
 773             try object.field("weight", sample.weight);
 774             try object.field("first_ns", sample.first_ns);
 775             try object.field("last_ns", sample.last_ns);
 776             if (sample.kind) |kind| try object.field("sample_kind", kind);
 777             if (sample.file) |file| {
 778                 try object.field("file", file);
 779                 try object.field("line", sample.line);
 780             }
 781             try object.endLine();
 782         }
 783         const fiber_limit = @min(options.top, fiber_rows.items.len);
 784         for (fiber_rows.items[0..fiber_limit]) |row| {
 785             var stream = pretty_json.Writer.init(writer, .minified);
 786             const object = try stream.object();
 787             try object.field("kind", "fiber");
 788             try object.field("label", row.label);
 789             try object.field("count", row.count);
 790             try object.field("running_ns", row.running_ns);
 791             try object.field("enters", row.enters);
 792             try object.field("leaves", row.leaves);
 793             if (row.fiber) |fiber| try object.field("fiber", fiber);
 794             if (row.thread) |thread| try object.field("thread", thread);
 795             if (row.group_hint) |group_hint| try object.field("group_hint", group_hint);
 796             if (row.migrations != 0) try object.field("migrations", row.migrations);
 797             if (row.unmatched_leaves != 0) {
 798                 try object.field("unmatched_leaves", row.unmatched_leaves);
 799             }
 800             if (row.active) try object.field("active", true);
 801             try object.endLine();
 802         }
 803         const gpu_limit = @min(options.top, gpu_rows.items.len);
 804         for (gpu_rows.items[0..gpu_limit]) |row| {
 805             try writeGpuSummaryJson(writer, row);
 806         }
 807         const system_limit = @min(options.top, system_rows.items.len);
 808         for (system_rows.items[0..system_limit]) |row| {
 809             var stream = pretty_json.Writer.init(writer, .minified);
 810             const object = try stream.object();
 811             try object.field("kind", "system");
 812             try object.field("label", row.label);
 813             try object.field("group", systemGroupTag(row.group));
 814             try object.field("count", row.count);
 815             try writeSystemSummaryFields(object, row);
 816             try object.endLine();
 817         }
 818         const context_limit = @min(options.top, context_rows.items.len);
 819         for (context_rows.items[0..context_limit]) |row| {
 820             var stream = pretty_json.Writer.init(writer, .minified);
 821             const object = try stream.object();
 822             try object.field("kind", "context");
 823             try object.field("label", row.label);
 824             try object.field("count", row.count);
 825             try object.field("running_ns", row.running_ns);
 826             try object.field("blocked_ns", row.blocked_ns);
 827             try object.field("ready_ns", row.ready_ns);
 828             try object.field("unknown_off_cpu_ns", row.unknown_off_cpu_ns);
 829             try writeContextLatencyFields(object, row);
 830             if (row.thread) |thread| try object.field("thread", thread);
 831             if (row.cpu) |cpu| try object.field("cpu", cpu);
 832             if (row.migrations != 0) try object.field("migrations", row.migrations);
 833             try object.endLine();
 834         }
 835     }
 836 
 837     fn recordZoneBegin(self: *Analyzer, parsed: event.Parsed) !void {
 838         if (parsed.id == 0) return;
 839         if (self.active_zones.fetchRemove(parsed.id)) |removed| {
 840             var old = removed.value;
 841             old.deinit(self.allocator);
 842         }
 843         const name = parsed.name orelse "<zone>";
 844         try self.active_zones.put(self.allocator, parsed.id, .{
 845             .name = try self.allocator.dupe(u8, name),
 846             .file = try dupeOptional(self.allocator, parsed.file),
 847             .function = try dupeOptional(self.allocator, parsed.function),
 848             .line = parsed.line,
 849             .thread = parsed.thread,
 850             .start_ns = parsed.time_ns,
 851             .color = parsed.color,
 852         });
 853         self.counters.started_zones += 1;
 854     }
 855 
 856     fn recordZoneEnd(self: *Analyzer, parsed: event.Parsed) !void {
 857         const removed = self.active_zones.fetchRemove(parsed.id) orelse {
 858             self.counters.unmatched_zone_ends += 1;
 859             return;
 860         };
 861         var zone = removed.value;
 862         defer zone.deinit(self.allocator);
 863         const duration = if (parsed.time_ns >= zone.start_ns)
 864             parsed.time_ns - zone.start_ns
 865         else
 866             null;
 867         const summary = try self.zoneSummary(zone.name, zone.file, zone.function, zone.line);
 868         summary.count += 1;
 869         if (duration) |valid| {
 870             try summary.durations_ns.append(self.allocator, valid);
 871             summary.duration_samples += 1;
 872             summary.total_ns +|= valid;
 873             summary.min_ns = @min(summary.min_ns, valid);
 874             summary.max_ns = @max(summary.max_ns, valid);
 875             self.counters.zone_duration_samples += 1;
 876         } else {
 877             self.counters.zone_timestamp_regressions += 1;
 878         }
 879         try self.recordZonePrefixes(zone.name, duration);
 880         try self.recordZoneThread(zone.thread, duration);
 881         self.counters.completed_zones += 1;
 882     }
 883 
 884     fn recordZoneName(self: *Analyzer, parsed: event.Parsed) !void {
 885         const name = parsed.name orelse return;
 886         const zone = self.active_zones.getPtr(parsed.id) orelse return;
 887         self.allocator.free(zone.name);
 888         zone.name = try self.allocator.dupe(u8, name);
 889     }
 890 
 891     fn recordZoneColor(self: *Analyzer, parsed: event.Parsed) void {
 892         const color = parsed.color orelse return;
 893         const zone = self.active_zones.getPtr(parsed.id) orelse return;
 894         zone.color = color;
 895     }
 896 
 897     fn recordZoneValue(self: *Analyzer, parsed: event.Parsed) void {
 898         const value = parsed.value_u64 orelse return;
 899         const zone = self.active_zones.getPtr(parsed.id) orelse return;
 900         zone.value = value;
 901     }
 902 
 903     fn recordThreadName(self: *Analyzer, parsed: event.Parsed) !void {
 904         const name = parsed.name orelse return;
 905         const summary = try self.zoneThreadSummary(parsed.thread);
 906         if (summary.name) |old| self.allocator.free(old);
 907         summary.name = try self.allocator.dupe(u8, name);
 908     }
 909 
 910     fn recordZonePrefixes(self: *Analyzer, name: []const u8, duration: ?u64) !void {
 911         var start: usize = 0;
 912         while (std.mem.indexOfScalarPos(u8, name, start, '.')) |dot| {
 913             if (dot != 0) {
 914                 const summary = try self.zonePrefixSummary(name[0..dot]);
 915                 summary.count += 1;
 916                 if (duration) |valid| {
 917                     summary.duration_samples += 1;
 918                     summary.total_ns +|= valid;
 919                     summary.min_ns = @min(summary.min_ns, valid);
 920                     summary.max_ns = @max(summary.max_ns, valid);
 921                 }
 922             }
 923             start = dot + 1;
 924         }
 925     }
 926 
 927     fn recordZoneThread(self: *Analyzer, thread: u64, duration: ?u64) !void {
 928         const summary = try self.zoneThreadSummary(thread);
 929         summary.count += 1;
 930         if (duration) |valid| {
 931             summary.duration_samples += 1;
 932             summary.total_ns +|= valid;
 933             summary.min_ns = @min(summary.min_ns, valid);
 934             summary.max_ns = @max(summary.max_ns, valid);
 935         }
 936     }
 937 
 938     fn zoneSummary(
 939         self: *Analyzer,
 940         name: []const u8,
 941         file: ?[]const u8,
 942         function: ?[]const u8,
 943         line: u32,
 944     ) !*ZoneSummary {
 945         const entry = try self.zone_summaries.getOrPut(self.allocator, name);
 946         if (!entry.found_existing) {
 947             const owned_name = try self.allocator.dupe(u8, name);
 948             entry.key_ptr.* = owned_name;
 949             entry.value_ptr.* = .{
 950                 .name = owned_name,
 951                 .file = try dupeOptional(self.allocator, file),
 952                 .function = try dupeOptional(self.allocator, function),
 953                 .line = line,
 954             };
 955         }
 956         return entry.value_ptr;
 957     }
 958 
 959     fn zonePrefixSummary(self: *Analyzer, name: []const u8) !*ZonePrefixSummary {
 960         const entry = try self.zone_prefix_summaries.getOrPut(self.allocator, name);
 961         if (!entry.found_existing) {
 962             const owned_name = try self.allocator.dupe(u8, name);
 963             entry.key_ptr.* = owned_name;
 964             entry.value_ptr.* = .{ .name = owned_name };
 965         }
 966         return entry.value_ptr;
 967     }
 968 
 969     fn zoneThreadSummary(self: *Analyzer, thread: u64) !*ZoneThreadSummary {
 970         const entry = try self.zone_thread_summaries.getOrPut(self.allocator, thread);
 971         if (!entry.found_existing) entry.value_ptr.* = .{ .thread = thread };
 972         return entry.value_ptr;
 973     }
 974 
 975     pub fn collectZones(self: *Analyzer) !std.ArrayListUnmanaged(ZoneSummary) {
 976         var zones: std.ArrayListUnmanaged(ZoneSummary) = .empty;
 977         var iter = self.zone_summaries.valueIterator();
 978         while (iter.next()) |zone| {
 979             std.mem.sort(u64, zone.durations_ns.items, {}, durationLessThan);
 980             try zones.append(self.allocator, zone.*);
 981         }
 982         std.mem.sort(ZoneSummary, zones.items, {}, zoneGreaterThan);
 983         return zones;
 984     }
 985 
 986     pub fn collectZonePrefixes(self: *Analyzer) !std.ArrayListUnmanaged(ZonePrefixSummary) {
 987         var prefixes: std.ArrayListUnmanaged(ZonePrefixSummary) = .empty;
 988         var iter = self.zone_prefix_summaries.valueIterator();
 989         while (iter.next()) |prefix| try prefixes.append(self.allocator, prefix.*);
 990         std.mem.sort(ZonePrefixSummary, prefixes.items, {}, zonePrefixGreaterThan);
 991         return prefixes;
 992     }
 993 
 994     pub fn collectZoneThreads(self: *Analyzer) !std.ArrayListUnmanaged(ZoneThreadSummary) {
 995         var threads: std.ArrayListUnmanaged(ZoneThreadSummary) = .empty;
 996         var iter = self.zone_thread_summaries.valueIterator();
 997         while (iter.next()) |thread| try threads.append(self.allocator, thread.*);
 998         std.mem.sort(ZoneThreadSummary, threads.items, {}, zoneThreadGreaterThan);
 999         return threads;
1000     }
1001 
1002     pub fn collectPlots(self: *Analyzer) !std.ArrayListUnmanaged(PlotSummary) {
1003         return try self.plots.collectSummaries(self.allocator, .{ .sort = .samples });
1004     }
1005 
1006     pub fn collectMemory(self: *Analyzer) !std.ArrayListUnmanaged(MemorySummary) {
1007         return try self.memory.collectSummaries(self.allocator, .{ .sort = .high_water });
1008     }
1009 
1010     pub fn collectSamples(self: *Analyzer) !std.ArrayListUnmanaged(SampleSummary) {
1011         return try self.samples.collectSummaries(self.allocator, .{ .sort = .samples });
1012     }
1013 
1014     pub fn collectLocks(self: *Analyzer) !std.ArrayListUnmanaged(LockSummary) {
1015         return try self.locks.collectRows(self.allocator, .{ .sort = .wait });
1016     }
1017 
1018     pub fn collectFibers(self: *Analyzer) !std.ArrayListUnmanaged(FiberSummary) {
1019         return try self.fibers.collectSummaries(self.allocator, .{ .sort = .running });
1020     }
1021 
1022     pub fn collectGpu(self: *Analyzer) !std.ArrayListUnmanaged(GpuSummary) {
1023         return try self.gpu.collectSummaries(self.allocator, .{ .sort = .gpu });
1024     }
1025 
1026     pub fn collectSystem(self: *Analyzer) !std.ArrayListUnmanaged(SystemSummary) {
1027         return try self.system.collectSummaries(self.allocator, .{ .sort = .count });
1028     }
1029 
1030     pub fn collectContext(self: *Analyzer) !std.ArrayListUnmanaged(ContextSummary) {
1031         return try self.context.collectSummaries(self.allocator, .{ .sort = .running });
1032     }
1033 
1034     pub fn durationNs(self: Analyzer) u64 {
1035         const start_ns = self.start_ns orelse return 0;
1036         const end_ns = self.end_ns orelse return 0;
1037         if (end_ns <= start_ns) return 0;
1038         return end_ns - start_ns;
1039     }
1040 };
1041 
1042 pub fn writeSummaryFromJsonlPath(
1043     allocator: std.mem.Allocator,
1044     path: []const u8,
1045     writer: *std.Io.Writer,
1046     options: Options,
1047 ) !void {
1048     var analyzer = Analyzer.init(allocator);
1049     defer analyzer.deinit();
1050     try ingestPath(&analyzer, path);
1051     try analyzer.writeSummary(writer, options);
1052 }
1053 
1054 pub fn writeSummaryJsonlFromJsonlPath(
1055     allocator: std.mem.Allocator,
1056     path: []const u8,
1057     writer: *std.Io.Writer,
1058     options: Options,
1059 ) !void {
1060     var analyzer = Analyzer.init(allocator);
1061     defer analyzer.deinit();
1062     try ingestPath(&analyzer, path);
1063     try analyzer.writeSummaryJsonl(writer, options);
1064 }
1065 
1066 pub fn ingestPath(analyzer: *Analyzer, path: []const u8) !void {
1067     var file = try sys.fs.cwd().openFile(sys.fs.debugIo(), path, .{});
1068     defer file.close(sys.fs.debugIo());
1069 
1070     var buffer: [64 * 1024]u8 = undefined;
1071     var reader = file.reader(sys.fs.debugIo(), &buffer);
1072     while (true) {
1073         const line = reader.interface.takeDelimiter('\n') catch |err| switch (err) {
1074             error.ReadFailed => return reader.err.?,
1075             else => return err,
1076         };
1077         const actual = line orelse break;
1078         try analyzer.ingestJsonLine(actual);
1079     }
1080 }
1081 
1082 fn dupeOptional(allocator: std.mem.Allocator, text: ?[]const u8) !?[]u8 {
1083     const actual = text orelse return null;
1084     return try allocator.dupe(u8, actual);
1085 }
1086 
1087 fn writeCaptureFields(
1088     object: pretty_json.Object,
1089     analyzer: *const Analyzer,
1090     zone_prefixes: usize,
1091     zone_threads: usize,
1092 ) !void {
1093     try object.field("events", analyzer.capture.counters.events);
1094     try object.field("zones", analyzer.counters.completed_zones);
1095     try object.field("zone_duration_samples", analyzer.counters.zone_duration_samples);
1096     try object.field(
1097         "zone_timestamp_regressions",
1098         analyzer.counters.zone_timestamp_regressions,
1099     );
1100     try object.field("zone_duration_evidence", analyzer.zoneDurationEvidence());
1101     try object.field("zone_prefixes", zone_prefixes);
1102     try object.field("zone_threads", zone_threads);
1103     try object.field("active_zones", analyzer.active_zones.count());
1104     try object.field("messages", analyzer.counters.messages);
1105     try object.field("frames", analyzer.counters.frames);
1106     try object.field("plots", analyzer.counters.plots);
1107     try object.field("plot_configurations", analyzer.counters.plot_configurations);
1108     try object.field(
1109         "plot_configuration_conflicts",
1110         analyzer.counters.plot_configuration_conflicts,
1111     );
1112     try object.field("samples", analyzer.counters.samples);
1113     try object.field("sample_frames", analyzer.counters.sample_frames);
1114     try object.field("fiber_enters", analyzer.fibers.counters.enters);
1115     try object.field("fiber_leaves", analyzer.fibers.counters.leaves);
1116     try object.field("fiber_names", analyzer.fibers.counters.names);
1117     try object.field("fiber_running_ns", analyzer.fibers.runningNs());
1118     try object.field("fiber_unmatched_leaves", analyzer.fibers.counters.unmatched_leaves);
1119 }
1120 
1121 fn writeGpuCaptureFields(object: pretty_json.Object, analyzer: *const Analyzer) !void {
1122     const counters = analyzer.gpu.counters;
1123     try object.field("gpu_contexts", analyzer.gpu.contexts.count());
1124     try object.field("gpu_zones", counters.zone_begins);
1125     try object.field("gpu_completed_zones", counters.completed_zones);
1126     try object.field("gpu_duration_samples", counters.gpu_duration_samples);
1127     try object.field("gpu_cpu_duration_samples", counters.cpu_duration_samples);
1128     try object.field("gpu_times", counters.gpu_times);
1129     try object.field("gpu_annotations", counters.annotations);
1130     try object.field("gpu_ns", analyzer.gpu.gpuNs());
1131     try object.field("gpu_cpu_ns", analyzer.gpu.cpuNs());
1132     try object.field("gpu_unmatched_ends", counters.unmatched_ends);
1133     try object.field("gpu_unmatched_times", counters.unmatched_times);
1134     try object.field("gpu_duplicate_queries", counters.duplicate_queries);
1135     try object.field("gpu_duplicate_times", counters.duplicate_times);
1136     try object.field("gpu_timestamp_regressions", counters.gpu_timestamp_regressions);
1137     try object.field(
1138         "gpu_cpu_timestamp_regressions",
1139         counters.cpu_timestamp_regressions,
1140     );
1141     try object.field("gpu_duration_population", "completed_valid_pairs");
1142     try object.field("gpu_duration_evidence", analyzer.gpuDurationEvidence());
1143 }
1144 
1145 fn writeSystemCaptureFields(object: pretty_json.Object, analyzer: *const Analyzer) !void {
1146     try object.field("system_thread_contexts", analyzer.counters.system_thread_contexts);
1147     try object.field("system_thread_pid_maps", analyzer.counters.system_thread_pid_maps);
1148     try object.field("system_cpu_topology", analyzer.counters.system_cpu_topology);
1149     try object.field("system_sys_time", analyzer.counters.system_sys_time);
1150     try object.field("system_sys_power", analyzer.counters.system_sys_power);
1151     try object.field("system_computed_power", analyzer.counters.system_computed_power);
1152     try object.field("system_hw_samples", analyzer.counters.system_hw_samples);
1153 }
1154 
1155 fn writeContextCaptureFields(object: pretty_json.Object, analyzer: *const Analyzer) !void {
1156     const counters = analyzer.context.counters;
1157     try object.field("context_switches", counters.switches);
1158     try object.field("thread_wakeups", counters.wakeups);
1159     try object.field("context_running_ns", analyzer.context.runningNs());
1160     try object.field("context_blocked_ns", analyzer.context.blockedNs());
1161     try object.field("context_ready_ns", analyzer.context.readyNs());
1162     try object.field("context_unknown_off_cpu_ns", analyzer.context.unknownOffCpuNs());
1163     try object.field("context_ready_latency_samples", counters.ready_latency_samples);
1164     try object.field("context_redundant_wakeups", counters.redundant_wakeups);
1165     try object.field("context_ready_time_regressions", counters.ready_time_regressions);
1166     try object.field(
1167         "context_pending_ready_intervals",
1168         analyzer.context.pendingReadyIntervals(),
1169     );
1170     try object.field("context_ready_latency_evidence", analyzer.context.readyLatencyEvidence());
1171 }
1172 
1173 fn writeMemoryCaptureFields(object: pretty_json.Object, analyzer: *const Analyzer) !void {
1174     const counters = analyzer.memory.counters;
1175     try object.field("allocations", counters.allocations);
1176     try object.field("frees", counters.frees);
1177     try object.field("live_bytes", counters.live_bytes);
1178     try object.field("high_water_live_bytes", counters.high_water_live_bytes);
1179     try object.field("allocated_bytes", counters.allocated_bytes);
1180     try object.field("freed_bytes", counters.freed_bytes);
1181     try object.field("completed_lifetimes", counters.completed_lifetimes);
1182     try object.field("lifetime_samples", counters.lifetime_samples);
1183     try object.field("right_censored_allocations", analyzer.memory.active.count());
1184     try object.field("unmatched_frees", counters.unmatched_frees);
1185     try object.field("duplicate_allocations", counters.duplicate_allocations);
1186     try object.field("memory_timestamp_regressions", counters.timestamp_regressions);
1187     try object.field("untracked_allocations", counters.untracked_allocations);
1188     try object.field("size_mismatches", counters.size_mismatches);
1189     try object.field("unmatched_zone_ends", analyzer.counters.unmatched_zone_ends);
1190 }
1191 
1192 fn writeLockCaptureFields(object: pretty_json.Object, analyzer: *const Analyzer) !void {
1193     const counters = analyzer.locks.counters;
1194     try object.field("locks", analyzer.locks.locks.count());
1195     try object.field("lock_waits", counters.completed_waits);
1196     try object.field("lock_holds", counters.completed_holds);
1197     try object.field("lock_wait_samples", counters.wait_samples);
1198     try object.field("lock_hold_samples", counters.hold_samples);
1199     try object.field("lock_pending_waits", analyzer.locks.pending_waits.count());
1200     try object.field("lock_pending_holds", analyzer.locks.pending_holds.count());
1201     try object.field("lock_duplicate_waits", counters.duplicate_waits);
1202     try object.field("lock_duplicate_obtains", counters.duplicate_obtains);
1203     try object.field("lock_timestamp_regressions", counters.timestamp_regressions);
1204     try object.field("lock_unmatched_releases", counters.unmatched_releases);
1205     try object.field("lock_duration_evidence", analyzer.locks.durationEvidence());
1206 }
1207 
1208 fn writePlotSummaryText(writer: *std.Io.Writer, plot: PlotSummary) !void {
1209     try writer.writeAll("plot name=");
1210     try pretty_json.writeString(writer, plot.name);
1211     try writer.print(
1212         " count={d} min={d} max={d} last={d} mean={d} configured={} " ++
1213             "configurations={d} configuration_conflicts={d}",
1214         .{
1215             plot.count,
1216             plot.min,
1217             plot.max,
1218             plot.last,
1219             plot.mean,
1220             plot.configured,
1221             plot.configurations,
1222             plot.configuration_conflicts,
1223         },
1224     );
1225     if (plot.kind) |kind| {
1226         try writer.writeAll(" kind=");
1227         try pretty_json.writeString(writer, kind);
1228     }
1229     if (plot.unit) |unit| {
1230         try writer.writeAll(" unit=");
1231         try pretty_json.writeString(writer, unit);
1232     }
1233     try writer.print(" step={} fill={}", .{ plot.step, plot.fill });
1234     if (plot.color) |color| try writer.print(" color={d}", .{color});
1235     try writer.writeByte('\n');
1236 }
1237 
1238 fn writePlotSummaryJson(writer: *std.Io.Writer, plot: PlotSummary) !void {
1239     var stream = pretty_json.Writer.init(writer, .minified);
1240     const object = try stream.object();
1241     try object.field("kind", "plot");
1242     try object.field("name", plot.name);
1243     if (plot.kind) |kind| try object.field("plot_kind", kind);
1244     try object.field("unit", plot.unit);
1245     try object.field("configured", plot.configured);
1246     try object.field("step", plot.step);
1247     try object.field("fill", plot.fill);
1248     try object.field("color", plot.color);
1249     try object.field("configurations", plot.configurations);
1250     try object.field("configuration_conflicts", plot.configuration_conflicts);
1251     try object.field("count", plot.count);
1252     try object.field("min", plot.min);
1253     try object.field("max", plot.max);
1254     try object.field("last", plot.last);
1255     try object.field("mean", plot.mean);
1256     try object.endLine();
1257 }
1258 
1259 fn writeGpuCaptureText(writer: *std.Io.Writer, analyzer: *const Analyzer) !void {
1260     const counters = analyzer.gpu.counters;
1261     try writer.print(
1262         "tracy gpu_contexts={d} gpu_zones={d} gpu_completed_zones={d} " ++
1263             "gpu_duration_samples={d} gpu_cpu_duration_samples={d} gpu_times={d} " ++
1264             "gpu_annotations={d} gpu_ns={d} gpu_cpu_ns={d} gpu_unmatched_ends={d} " ++
1265             "gpu_unmatched_times={d} gpu_duplicate_queries={d} gpu_duplicate_times={d} " ++
1266             "gpu_timestamp_regressions={d} gpu_cpu_timestamp_regressions={d} " ++
1267             "gpu_duration_population=completed_valid_pairs gpu_duration_evidence={s}\n",
1268         .{
1269             analyzer.gpu.contexts.count(),      counters.zone_begins,
1270             counters.completed_zones,           counters.gpu_duration_samples,
1271             counters.cpu_duration_samples,      counters.gpu_times,
1272             counters.annotations,               analyzer.gpu.gpuNs(),
1273             analyzer.gpu.cpuNs(),               counters.unmatched_ends,
1274             counters.unmatched_times,           counters.duplicate_queries,
1275             counters.duplicate_times,           counters.gpu_timestamp_regressions,
1276             counters.cpu_timestamp_regressions, analyzer.gpuDurationEvidence(),
1277         },
1278     );
1279 }
1280 
1281 fn writeGpuSummaryText(writer: *std.Io.Writer, row: GpuSummary) !void {
1282     try writer.writeAll("gpu label=");
1283     try pretty_json.writeString(writer, row.label);
1284     try writer.print(
1285         " count={d} gpu_ns={d} cpu_ns={d} zones={d} completed_zones={d}",
1286         .{ row.count, row.gpu_ns, row.cpu_ns, row.zones, row.completed_zones },
1287     );
1288     try gpu_mod.writeDurationFieldsText(writer, row);
1289     if (row.context) |context| try writer.print(" context={d}", .{context});
1290     if (row.thread) |thread| try writer.print(" thread={d}", .{thread});
1291     if (row.annotations != 0) try writer.print(" annotations={d}", .{row.annotations});
1292     try writer.writeByte('\n');
1293 }
1294 
1295 fn writeGpuSummaryJson(writer: *std.Io.Writer, row: GpuSummary) !void {
1296     var stream = pretty_json.Writer.init(writer, .minified);
1297     const object = try stream.object();
1298     try object.field("kind", "gpu");
1299     try object.field("label", row.label);
1300     try object.field("count", row.count);
1301     try object.field("gpu_ns", row.gpu_ns);
1302     try object.field("cpu_ns", row.cpu_ns);
1303     try object.field("zones", row.zones);
1304     try object.field("completed_zones", row.completed_zones);
1305     try gpu_mod.writeDurationFields(object, row);
1306     if (row.context) |context| try object.field("context", context);
1307     if (row.thread) |thread| try object.field("thread", thread);
1308     if (row.annotations != 0) try object.field("annotations", row.annotations);
1309     try object.endLine();
1310 }
1311 
1312 fn writeMemorySummaryText(writer: *std.Io.Writer, item: MemorySummary) !void {
1313     try writer.writeAll("memory name=");
1314     try pretty_json.writeString(writer, item.name);
1315     try writer.print(
1316         " live_bytes={d} high_water_live_bytes={d} allocated_bytes={d} " ++
1317             "freed_bytes={d} allocations={d} frees={d} " ++
1318             "right_censored_allocations={d} completed_lifetimes={d} " ++
1319             "lifetime_samples={d} lifetime_mean_ns={d} lifetime_min_ns={d} " ++
1320             "lifetime_p50_ns={d} lifetime_p90_ns={d} lifetime_p99_ns={d} " ++
1321             "lifetime_max_ns={d}\n",
1322         .{
1323             item.live_bytes,         item.high_water_live_bytes,
1324             item.allocated_bytes,    item.freed_bytes,
1325             item.allocations,        item.frees,
1326             item.active_allocations, item.completed_lifetimes,
1327             item.lifetime_samples,   item.meanLifetimeNs(),
1328             item.min_lifetime_ns,    item.p50_lifetime_ns,
1329             item.p90_lifetime_ns,    item.p99_lifetime_ns,
1330             item.max_lifetime_ns,
1331         },
1332     );
1333 }
1334 
1335 fn writeMemorySummaryJson(writer: *std.Io.Writer, item: MemorySummary) !void {
1336     var stream = pretty_json.Writer.init(writer, .minified);
1337     const object = try stream.object();
1338     try object.field("kind", "memory");
1339     try object.field("name", item.name);
1340     try object.field("live_bytes", item.live_bytes);
1341     try object.field("high_water_live_bytes", item.high_water_live_bytes);
1342     try object.field("allocated_bytes", item.allocated_bytes);
1343     try object.field("freed_bytes", item.freed_bytes);
1344     try object.field("allocations", item.allocations);
1345     try object.field("frees", item.frees);
1346     try object.field("right_censored_allocations", item.active_allocations);
1347     try object.field("completed_lifetimes", item.completed_lifetimes);
1348     try object.field("lifetime_samples", item.lifetime_samples);
1349     try object.field("lifetime_mean_ns", item.meanLifetimeNs());
1350     try object.field("lifetime_min_ns", item.min_lifetime_ns);
1351     try object.field("lifetime_p50_ns", item.p50_lifetime_ns);
1352     try object.field("lifetime_p90_ns", item.p90_lifetime_ns);
1353     try object.field("lifetime_p99_ns", item.p99_lifetime_ns);
1354     try object.field("lifetime_max_ns", item.max_lifetime_ns);
1355     try object.endLine();
1356 }
1357 
1358 fn writeContextLatencyText(writer: *std.Io.Writer, row: context_mod.Summary) !void {
1359     if (row.ready_latency_samples == 0) return;
1360     try writer.print(
1361         " ready_latency_samples={d} ready_latency_mean_ns={d} " ++
1362             "ready_latency_min_ns={d} ready_latency_p50_ns={d} " ++
1363             "ready_latency_p90_ns={d} ready_latency_p99_ns={d} " ++
1364             "ready_latency_max_ns={d}",
1365         .{
1366             row.ready_latency_samples,
1367             row.ready_latency_mean_ns,
1368             row.ready_latency_min_ns,
1369             row.ready_latency_p50_ns,
1370             row.ready_latency_p90_ns,
1371             row.ready_latency_p99_ns,
1372             row.ready_latency_max_ns,
1373         },
1374     );
1375 }
1376 
1377 fn writeContextLatencyFields(object: pretty_json.Object, row: context_mod.Summary) !void {
1378     if (row.ready_latency_samples == 0) return;
1379     try object.field("ready_latency_samples", row.ready_latency_samples);
1380     try object.field("ready_latency_mean_ns", row.ready_latency_mean_ns);
1381     try object.field("ready_latency_min_ns", row.ready_latency_min_ns);
1382     try object.field("ready_latency_p50_ns", row.ready_latency_p50_ns);
1383     try object.field("ready_latency_p90_ns", row.ready_latency_p90_ns);
1384     try object.field("ready_latency_p99_ns", row.ready_latency_p99_ns);
1385     try object.field("ready_latency_max_ns", row.ready_latency_max_ns);
1386 }
1387 
1388 fn systemGroupTag(group: system_mod.Group) []const u8 {
1389     return switch (group) {
1390         .kind => "kind",
1391         .thread => "thread",
1392         .cpu => "cpu",
1393         .address => "address",
1394         .topology => "topology",
1395         .name => "name",
1396         .none => "none",
1397     };
1398 }
1399 
1400 fn writeSystemSummaryTextFields(writer: *std.Io.Writer, row: SystemSummary) !void {
1401     if (row.kind) |kind| {
1402         try writer.writeAll(" kind=");
1403         try pretty_json.writeString(writer, kind);
1404     }
1405     if (row.thread) |thread| try writer.print(" thread={d}", .{thread});
1406     if (row.pid) |pid| try writer.print(" pid={d}", .{pid});
1407     if (row.cpu) |cpu| try writer.print(" cpu={d}", .{cpu});
1408     if (row.cpu_package) |package| try writer.print(" cpu_package={d}", .{package});
1409     if (row.cpu_die) |die| try writer.print(" cpu_die={d}", .{die});
1410     if (row.cpu_core) |core| try writer.print(" cpu_core={d}", .{core});
1411     if (row.address) |address| try writer.print(" address=0x{x}", .{address});
1412     if (row.value_count != 0) {
1413         try writer.print(
1414             " values={d} value_min={d} value_max={d} value_mean={d}",
1415             .{ row.value_count, row.value_min, row.value_max, row.valueMean() },
1416         );
1417     }
1418     if (row.value_last) |value| try writer.print(" value_last={d}", .{value});
1419     if (row.power_delta_uj != 0) try writer.print(" power_delta_uj={d}", .{row.power_delta_uj});
1420     if (row.first_ns != 0) try writer.print(" first_ns={d}", .{row.first_ns});
1421     if (row.last_ns != 0) try writer.print(" last_ns={d}", .{row.last_ns});
1422 }
1423 
1424 fn writeSystemSummaryFields(object: pretty_json.Object, row: SystemSummary) !void {
1425     if (row.kind) |kind| try object.field("system_kind", kind);
1426     if (row.thread) |thread| try object.field("thread", thread);
1427     if (row.pid) |pid| try object.field("pid", pid);
1428     if (row.cpu) |cpu| try object.field("cpu", cpu);
1429     if (row.cpu_package) |package| try object.field("cpu_package", package);
1430     if (row.cpu_die) |die| try object.field("cpu_die", die);
1431     if (row.cpu_core) |core| try object.field("cpu_core", core);
1432     if (row.address) |address| try object.field("address", address);
1433     if (row.value_count != 0) {
1434         try object.field("values", row.value_count);
1435         try object.field("value_min", row.value_min);
1436         try object.field("value_max", row.value_max);
1437         try object.field("value_mean", row.valueMean());
1438     }
1439     if (row.value_last) |value| try object.field("value_last", value);
1440     if (row.power_delta_uj != 0) try object.field("power_delta_uj", row.power_delta_uj);
1441     if (row.first_ns != 0) try object.field("first_ns", row.first_ns);
1442     if (row.last_ns != 0) try object.field("last_ns", row.last_ns);
1443 }
1444 
1445 fn zoneGreaterThan(_: void, left: ZoneSummary, right: ZoneSummary) bool {
1446     if (left.total_ns != right.total_ns) return left.total_ns > right.total_ns;
1447     if (left.count != right.count) return left.count > right.count;
1448     return std.mem.lessThan(u8, left.name, right.name);
1449 }
1450 
1451 fn zonePrefixGreaterThan(_: void, left: ZonePrefixSummary, right: ZonePrefixSummary) bool {
1452     if (left.total_ns != right.total_ns) return left.total_ns > right.total_ns;
1453     if (left.count != right.count) return left.count > right.count;
1454     return std.mem.lessThan(u8, left.name, right.name);
1455 }
1456 
1457 fn zoneThreadGreaterThan(_: void, left: ZoneThreadSummary, right: ZoneThreadSummary) bool {
1458     if (left.total_ns != right.total_ns) return left.total_ns > right.total_ns;
1459     if (left.count != right.count) return left.count > right.count;
1460     return left.thread < right.thread;
1461 }
1462 
1463 fn durationLessThan(_: void, left: u64, right: u64) bool {
1464     return left < right;
1465 }
1466 
1467 test "capture integrity accepts one contiguous trace lifecycle" {
1468     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
1469     defer trace.deinit();
1470     try (event.TraceEvent{ .seq = 1, .kind = .start }).writeJsonLine(&trace.writer);
1471     try (event.TraceEvent{ .seq = 2, .kind = .message }).writeJsonLine(&trace.writer);
1472     try (event.TraceEvent{ .seq = 3, .kind = .stop }).writeJsonLine(&trace.writer);
1473 
1474     var analyzer = Analyzer.init(std.testing.allocator);
1475     defer analyzer.deinit();
1476     try analyzer.ingestJsonlBytes(trace.written());
1477     const integrity = analyzer.captureIntegrity();
1478     try std.testing.expectEqualStrings("complete", integrity.status);
1479     try std.testing.expect(integrity.message == null);
1480     try std.testing.expectEqual(@as(u64, 3), integrity.sequenced_event_count);
1481     try std.testing.expectEqual(@as(?u64, 1), integrity.first_sequence);
1482     try std.testing.expectEqual(@as(?u64, 3), integrity.last_sequence);
1483     try std.testing.expectEqualStrings("complete", analyzer.context.captureIntegrity().status);
1484 }
1485 
1486 test "summary preserves context ready latency evidence" {
1487     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
1488     defer trace.deinit();
1489     try (event.TraceEvent{ .seq = 1, .kind = .start, .time_ns = 10 })
1490         .writeJsonLine(&trace.writer);
1491     try (event.TraceEvent{
1492         .seq = 2,
1493         .kind = .thread_wakeup,
1494         .time_ns = 20,
1495         .target_thread = 7,
1496     }).writeJsonLine(&trace.writer);
1497     try (event.TraceEvent{
1498         .seq = 3,
1499         .kind = .context_switch,
1500         .time_ns = 70,
1501         .prev_thread = 8,
1502         .next_thread = 7,
1503     }).writeJsonLine(&trace.writer);
1504     try (event.TraceEvent{ .seq = 4, .kind = .stop, .time_ns = 80 })
1505         .writeJsonLine(&trace.writer);
1506 
1507     var analyzer = Analyzer.init(std.testing.allocator);
1508     defer analyzer.deinit();
1509     try analyzer.ingestJsonlBytes(trace.written());
1510     try std.testing.expectEqualStrings("complete", analyzer.context.readyLatencyEvidence());
1511     try std.testing.expectEqual(@as(u64, 1), analyzer.context.counters.ready_latency_samples);
1512 
1513     var text = std.Io.Writer.Allocating.init(std.testing.allocator);
1514     defer text.deinit();
1515     try analyzer.writeSummary(&text.writer, .{});
1516     try std.testing.expect(std.mem.indexOf(
1517         u8,
1518         text.written(),
1519         "context_ready_latency_evidence=complete",
1520     ) != null);
1521     try std.testing.expect(std.mem.indexOf(
1522         u8,
1523         text.written(),
1524         "ready_latency_p99_ns=50",
1525     ) != null);
1526 
1527     var jsonl = std.Io.Writer.Allocating.init(std.testing.allocator);
1528     defer jsonl.deinit();
1529     try analyzer.writeSummaryJsonl(&jsonl.writer, .{});
1530     try std.testing.expect(std.mem.indexOf(
1531         u8,
1532         jsonl.written(),
1533         "\"context_ready_latency_evidence\":\"complete\"",
1534     ) != null);
1535 }
1536 
1537 test "summary preserves lock duration evidence" {
1538     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
1539     defer trace.deinit();
1540     try (event.TraceEvent{ .seq = 1, .kind = .start, .time_ns = 10 })
1541         .writeJsonLine(&trace.writer);
1542     try (event.TraceEvent{
1543         .seq = 2,
1544         .kind = .lock_announce,
1545         .time_ns = 15,
1546         .thread = 7,
1547         .id = 9,
1548         .name = "queue",
1549     }).writeJsonLine(&trace.writer);
1550     try (event.TraceEvent{ .seq = 3, .kind = .lock_wait, .time_ns = 20, .thread = 7, .id = 9 })
1551         .writeJsonLine(&trace.writer);
1552     try (event.TraceEvent{ .seq = 4, .kind = .lock_obtain, .time_ns = 70, .thread = 7, .id = 9 })
1553         .writeJsonLine(&trace.writer);
1554     try (event.TraceEvent{ .seq = 5, .kind = .lock_release, .time_ns = 100, .thread = 7, .id = 9 })
1555         .writeJsonLine(&trace.writer);
1556     try (event.TraceEvent{ .seq = 6, .kind = .stop, .time_ns = 110 })
1557         .writeJsonLine(&trace.writer);
1558     const flight_report = summaryLockFlightReport();
1559     try flight_report.writeJsonl(&trace.writer);
1560 
1561     var analyzer = Analyzer.init(std.testing.allocator);
1562     defer analyzer.deinit();
1563     try analyzer.ingestJsonlBytes(trace.written());
1564     try std.testing.expectEqualStrings("complete", analyzer.locks.durationEvidence());
1565     try std.testing.expectEqualDeep(
1566         flight_report,
1567         analyzer.locks.captureIntegrity().flight_report.?,
1568     );
1569     try std.testing.expectEqualDeep(
1570         flight_report,
1571         analyzer.memory.captureIntegrity().flight_report.?,
1572     );
1573     try std.testing.expectEqual(@as(u64, 1), analyzer.locks.counters.wait_samples);
1574     try std.testing.expectEqual(@as(u64, 1), analyzer.locks.counters.hold_samples);
1575     var rows = try analyzer.collectLocks();
1576     defer rows.deinit(std.testing.allocator);
1577     try std.testing.expectEqual(@as(u64, 50), rows.items[0].wait_p99_ns);
1578     try std.testing.expectEqual(@as(u64, 30), rows.items[0].hold_p99_ns);
1579 
1580     var text = std.Io.Writer.Allocating.init(std.testing.allocator);
1581     defer text.deinit();
1582     try analyzer.writeSummary(&text.writer, .{});
1583     try std.testing.expect(std.mem.indexOf(
1584         u8,
1585         text.written(),
1586         "lock_duration_evidence=complete",
1587     ) != null);
1588     try std.testing.expect(std.mem.indexOf(u8, text.written(), "wait_p99_ns=50") != null);
1589 
1590     var jsonl = std.Io.Writer.Allocating.init(std.testing.allocator);
1591     defer jsonl.deinit();
1592     try analyzer.writeSummaryJsonl(&jsonl.writer, .{});
1593     try std.testing.expect(std.mem.indexOf(
1594         u8,
1595         jsonl.written(),
1596         "\"lock_duration_evidence\":\"complete\"",
1597     ) != null);
1598     try std.testing.expect(std.mem.indexOf(u8, jsonl.written(), "\"hold_p99_ns\":30") != null);
1599 }
1600 
1601 fn summaryLockFlightReport() transport.Report {
1602     return .{
1603         .policy = .overwrite_oldest,
1604         .state = .accepting,
1605         .capacity_bytes = 64,
1606         .retained_bytes = 32,
1607         .event_capacity_bytes = 16,
1608         .writer_capacity_bytes = 8,
1609         .observed_events = 6,
1610         .stored_events = 6,
1611         .retained_events = 6,
1612         .overwritten_events = 0,
1613         .dropped_events = 0,
1614         .oversized_events = 0,
1615         .partial_event_bytes = 0,
1616         .discarding_oversized_event = false,
1617     };
1618 }
1619 
1620 test "capture integrity counts sequence gaps as lower bound missing rows" {
1621     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
1622     defer trace.deinit();
1623     try (event.TraceEvent{ .seq = 1, .kind = .start }).writeJsonLine(&trace.writer);
1624     try (event.TraceEvent{ .seq = 4, .kind = .message }).writeJsonLine(&trace.writer);
1625     try (event.TraceEvent{ .seq = 7, .kind = .stop }).writeJsonLine(&trace.writer);
1626 
1627     var analyzer = Analyzer.init(std.testing.allocator);
1628     defer analyzer.deinit();
1629     try analyzer.ingestJsonlBytes(trace.written());
1630     const integrity = analyzer.captureIntegrity();
1631     try std.testing.expectEqualStrings("sequence_gaps", integrity.status);
1632     try std.testing.expectEqual(@as(u64, 2), integrity.sequence_gap_count);
1633     try std.testing.expectEqual(@as(u64, 4), integrity.missing_sequence_event_count);
1634 
1635     var jsonl = std.Io.Writer.Allocating.init(std.testing.allocator);
1636     defer jsonl.deinit();
1637     try analyzer.writeSummaryJsonl(&jsonl.writer, .{});
1638     var lines = std.mem.splitScalar(u8, jsonl.written(), '\n');
1639     const summary_line = lines.next().?;
1640     var parsed = try std.json.parseFromSlice(
1641         std.json.Value,
1642         std.testing.allocator,
1643         summary_line,
1644         .{},
1645     );
1646     defer parsed.deinit();
1647     try std.testing.expect(std.mem.indexOf(
1648         u8,
1649         jsonl.written(),
1650         "\"capture_integrity\":{\"method\":\"tracy_event_sequence_and_lifecycle_v1\"",
1651     ) != null);
1652     try std.testing.expect(std.mem.indexOf(
1653         u8,
1654         jsonl.written(),
1655         "\"missing_sequence_event_count\":4",
1656     ) != null);
1657 
1658     var text = std.Io.Writer.Allocating.init(std.testing.allocator);
1659     defer text.deinit();
1660     try analyzer.writeSummary(&text.writer, .{});
1661     try std.testing.expect(std.mem.indexOf(
1662         u8,
1663         text.written(),
1664         "capture_integrity=sequence_gaps",
1665     ) != null);
1666     try std.testing.expect(std.mem.indexOf(u8, text.written(), "first_sequence=1") != null);
1667 }
1668 
1669 test "capture integrity classifies metadata lifecycle and balance failures" {
1670     var analyzer = Analyzer.init(std.testing.allocator);
1671     defer analyzer.deinit();
1672     try std.testing.expectEqualStrings("no_events", analyzer.captureIntegrity().status);
1673 
1674     analyzer.capture.counters.events = 2;
1675     analyzer.capture.counters.unsequenced_events = 2;
1676     try std.testing.expectEqualStrings(
1677         "missing_sequence_metadata",
1678         analyzer.captureIntegrity().status,
1679     );
1680 
1681     analyzer.capture.counters.unsequenced_events = 0;
1682     analyzer.capture.counters.sequenced_events = 2;
1683     analyzer.capture.first_sequence = 1;
1684     analyzer.capture.last_sequence = 2;
1685     analyzer.capture.counters.start_events = 1;
1686     analyzer.capture.start_sequence = 1;
1687     try std.testing.expectEqualStrings("missing_stop_event", analyzer.captureIntegrity().status);
1688 
1689     analyzer.capture.counters.stop_events = 1;
1690     analyzer.capture.stop_sequence = 2;
1691     analyzer.counters.unmatched_zone_ends = 1;
1692     try std.testing.expectEqualStrings("unbalanced_events", analyzer.captureIntegrity().status);
1693 
1694     analyzer.capture.stop_sequence = 1;
1695     try std.testing.expectEqualStrings("lifecycle_not_bounded", analyzer.captureIntegrity().status);
1696 
1697     analyzer.capture.counters.sequence_regressions = 1;
1698     try std.testing.expectEqualStrings(
1699         "non_monotonic_sequence",
1700         analyzer.captureIntegrity().status,
1701     );
1702 }
1703 
1704 test "capture integrity rejects active fiber and gpu lifecycles" {
1705     var fiber_trace = std.Io.Writer.Allocating.init(std.testing.allocator);
1706     defer fiber_trace.deinit();
1707     try (event.TraceEvent{ .seq = 1, .kind = .start }).writeJsonLine(&fiber_trace.writer);
1708     try (event.TraceEvent{
1709         .seq = 2,
1710         .kind = .fiber_enter,
1711         .thread = 1,
1712         .id = 7,
1713     }).writeJsonLine(&fiber_trace.writer);
1714     try (event.TraceEvent{ .seq = 3, .kind = .stop }).writeJsonLine(&fiber_trace.writer);
1715     var fiber_analyzer = Analyzer.init(std.testing.allocator);
1716     defer fiber_analyzer.deinit();
1717     try fiber_analyzer.ingestJsonlBytes(fiber_trace.written());
1718     try std.testing.expectEqualStrings(
1719         "unbalanced_events",
1720         fiber_analyzer.captureIntegrity().status,
1721     );
1722 
1723     var gpu_trace = std.Io.Writer.Allocating.init(std.testing.allocator);
1724     defer gpu_trace.deinit();
1725     try (event.TraceEvent{ .seq = 1, .kind = .start }).writeJsonLine(&gpu_trace.writer);
1726     try (event.TraceEvent{
1727         .seq = 2,
1728         .kind = .gpu_zone_begin,
1729         .thread = 1,
1730         .gpu_context = 1,
1731         .gpu_query = 2,
1732     }).writeJsonLine(&gpu_trace.writer);
1733     try (event.TraceEvent{ .seq = 3, .kind = .stop }).writeJsonLine(&gpu_trace.writer);
1734     var gpu_analyzer = Analyzer.init(std.testing.allocator);
1735     defer gpu_analyzer.deinit();
1736     try gpu_analyzer.ingestJsonlBytes(gpu_trace.written());
1737     try std.testing.expectEqualStrings(
1738         "unbalanced_events",
1739         gpu_analyzer.captureIntegrity().status,
1740     );
1741 }
1742 
1743 test "summary excludes regressed zone timestamps from duration samples" {
1744     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
1745     defer trace.deinit();
1746     const rows = [_]event.TraceEvent{
1747         .{ .seq = 1, .kind = .start, .time_ns = 10 },
1748         .{
1749             .seq = 2,
1750             .kind = .zone_begin,
1751             .time_ns = 100,
1752             .thread = 7,
1753             .id = 1,
1754             .name = "agent.phase",
1755         },
1756         .{ .seq = 3, .kind = .zone_end, .time_ns = 90, .thread = 7, .id = 1 },
1757         .{ .seq = 4, .kind = .stop, .time_ns = 110 },
1758     };
1759     for (rows) |row| try row.writeJsonLine(&trace.writer);
1760 
1761     var analyzer = Analyzer.init(std.testing.allocator);
1762     defer analyzer.deinit();
1763     try analyzer.ingestJsonlBytes(trace.written());
1764     const zone = analyzer.zone_summaries.get("agent.phase").?;
1765     try std.testing.expectEqual(@as(u64, 1), zone.count);
1766     try std.testing.expectEqual(@as(u64, 0), zone.duration_samples);
1767     try std.testing.expectEqual(@as(u64, 0), zone.total_ns);
1768     try std.testing.expectEqual(@as(u64, 1), analyzer.counters.zone_timestamp_regressions);
1769     try std.testing.expectEqualStrings("complete", analyzer.captureIntegrity().status);
1770     try std.testing.expectEqualStrings("partial", analyzer.zoneDurationEvidence());
1771 
1772     var output = std.Io.Writer.Allocating.init(std.testing.allocator);
1773     defer output.deinit();
1774     try analyzer.writeSummaryJsonl(&output.writer, .{});
1775     try std.testing.expect(
1776         std.mem.indexOf(u8, output.written(), "\"zone_duration_evidence\":\"partial\"") != null,
1777     );
1778     try std.testing.expect(
1779         std.mem.indexOf(u8, output.written(), "\"count\":1,\"duration_samples\":0") != null,
1780     );
1781 }
1782 
1783 test "summary aggregates zones, plots, and allocation pressure" {
1784     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
1785     defer out.deinit();
1786     try (event.TraceEvent{ .seq = 1, .kind = .start, .time_ns = 100, .thread = 1, .name = "test" }).writeJsonLine(&out.writer);
1787     try (event.TraceEvent{ .seq = 2, .kind = .thread_name, .time_ns = 105, .thread = 1, .name = "main" }).writeJsonLine(&out.writer);
1788     try (event.TraceEvent{ .seq = 3, .kind = .zone_begin, .time_ns = 110, .thread = 1, .id = 7, .name = "agent.phase", .file = "phase.zig", .line = 9 }).writeJsonLine(&out.writer);
1789     try (event.TraceEvent{ .seq = 3, .kind = .zone_end, .time_ns = 160, .thread = 1, .id = 7 }).writeJsonLine(&out.writer);
1790     try (event.TraceEvent{ .seq = 4, .kind = .plot, .time_ns = 170, .thread = 1, .name = "sample.ns", .value_i64 = 12, .plot_kind = "int" }).writeJsonLine(&out.writer);
1791     try (event.TraceEvent{ .seq = 5, .kind = .alloc, .time_ns = 180, .thread = 1, .name = "arena", .address = 4096, .size = 64 }).writeJsonLine(&out.writer);
1792     try (event.TraceEvent{ .seq = 6, .kind = .free, .time_ns = 190, .thread = 1, .name = "arena", .address = 4096 }).writeJsonLine(&out.writer);
1793     try (event.TraceEvent{ .seq = 7, .kind = .sample, .time_ns = 195, .thread = 1, .id = 3, .name = "tick", .file = "phase.zig", .line = 9, .sample_kind = "cpu" }).writeJsonLine(&out.writer);
1794     try (event.TraceEvent{ .seq = 8, .kind = .sample_frame, .time_ns = 195, .thread = 1, .id = 3, .name = "tick", .file = "phase.zig", .line = 9, .sample_kind = "cpu" }).writeJsonLine(&out.writer);
1795     try (event.TraceEvent{ .seq = 9, .kind = .context_switch, .time_ns = 196, .thread = 1, .cpu = 0, .next_thread = 10 }).writeJsonLine(&out.writer);
1796     try (event.TraceEvent{ .seq = 10, .kind = .thread_wakeup, .time_ns = 197, .thread = 1, .target_thread = 10, .cpu = 0 }).writeJsonLine(&out.writer);
1797     try (event.TraceEvent{ .seq = 11, .kind = .gpu_context, .time_ns = 198, .thread = 1, .gpu_context = 1, .gpu_period = 1.0 }).writeJsonLine(&out.writer);
1798     try (event.TraceEvent{ .seq = 12, .kind = .gpu_zone_begin, .time_ns = 199, .thread = 1, .gpu_context = 1, .gpu_query = 10, .name = "draw" }).writeJsonLine(&out.writer);
1799     try (event.TraceEvent{ .seq = 13, .kind = .gpu_zone_end, .time_ns = 210, .thread = 1, .gpu_context = 1, .gpu_query = 11 }).writeJsonLine(&out.writer);
1800     try (event.TraceEvent{ .seq = 14, .kind = .gpu_time, .time_ns = 211, .thread = 1, .gpu_context = 1, .gpu_query = 10, .gpu_time = 100 }).writeJsonLine(&out.writer);
1801     try (event.TraceEvent{ .seq = 15, .kind = .gpu_time, .time_ns = 212, .thread = 1, .gpu_context = 1, .gpu_query = 11, .gpu_time = 140 }).writeJsonLine(&out.writer);
1802     try (event.TraceEvent{ .seq = 16, .kind = .thread_pid, .time_ns = 213, .thread = 10, .pid = 900 }).writeJsonLine(&out.writer);
1803     try (event.TraceEvent{ .seq = 17, .kind = .cpu_topology, .time_ns = 214, .thread = 1, .cpu = 0, .cpu_package = 1, .cpu_die = 0, .cpu_core = 2 }).writeJsonLine(&out.writer);
1804     try (event.TraceEvent{ .seq = 18, .kind = .sys_time, .time_ns = 215, .thread = 1, .value_f64 = 0.25 }).writeJsonLine(&out.writer);
1805     try (event.TraceEvent{ .seq = 19, .kind = .sys_power, .time_ns = 216, .thread = 1, .name = "package", .power_delta_uj = 100 }).writeJsonLine(&out.writer);
1806     try (event.TraceEvent{ .seq = 20, .kind = .hw_sample, .time_ns = 217, .thread = 10, .cpu = 0, .address = 0x1234, .hw_sample_kind = "cache-miss" }).writeJsonLine(&out.writer);
1807     try (event.TraceEvent{ .seq = 21, .kind = .lock_announce, .time_ns = 218, .thread = 1, .id = 44, .name = "scheduler-lock", .lock_kind = "lock" }).writeJsonLine(&out.writer);
1808     try (event.TraceEvent{ .seq = 22, .kind = .lock_wait, .time_ns = 219, .thread = 1, .id = 44 }).writeJsonLine(&out.writer);
1809     try (event.TraceEvent{ .seq = 23, .kind = .lock_obtain, .time_ns = 229, .thread = 1, .id = 44 }).writeJsonLine(&out.writer);
1810     try (event.TraceEvent{ .seq = 24, .kind = .lock_release, .time_ns = 245, .thread = 1, .id = 44 }).writeJsonLine(&out.writer);
1811     try (event.TraceEvent{ .seq = 25, .kind = .stop, .time_ns = 250, .thread = 1 }).writeJsonLine(&out.writer);
1812 
1813     var analyzer = Analyzer.init(std.testing.allocator);
1814     defer analyzer.deinit();
1815     try analyzer.ingestJsonlBytes(out.written());
1816     try std.testing.expectEqual(@as(u64, 1), analyzer.counters.completed_zones);
1817     try std.testing.expectEqual(@as(u64, 50), analyzer.zone_summaries.get("agent.phase").?.total_ns);
1818     try std.testing.expectEqual(@as(u64, 50), analyzer.zone_summaries.get("agent.phase").?.percentileNs(50));
1819     try std.testing.expectEqual(@as(u64, 50), analyzer.zone_prefix_summaries.get("agent").?.total_ns);
1820     try std.testing.expectEqual(@as(u64, 50), analyzer.zone_thread_summaries.get(1).?.total_ns);
1821     try std.testing.expectEqualStrings("main", analyzer.zone_thread_summaries.get(1).?.name.?);
1822     try std.testing.expectEqual(@as(u64, 64), analyzer.memory.counters.allocated_bytes);
1823     try std.testing.expectEqual(@as(u64, 64), analyzer.memory.counters.freed_bytes);
1824     try std.testing.expectEqual(@as(u64, 0), analyzer.memory.counters.live_bytes);
1825     try std.testing.expectEqual(@as(u64, 64), analyzer.memory.summaries.get("arena").?.high_water_live_bytes);
1826     try std.testing.expectEqual(@as(u64, 1), analyzer.samples.counters.samples);
1827     try std.testing.expectEqual(@as(u64, 1), analyzer.samples.counters.frames);
1828     try std.testing.expectEqual(@as(u64, 1), analyzer.context.counters.switches);
1829     try std.testing.expectEqual(@as(u64, 1), analyzer.context.counters.wakeups);
1830     try std.testing.expectEqual(@as(u64, 1), analyzer.gpu.counters.completed_zones);
1831     try std.testing.expectEqual(@as(u64, 40), analyzer.gpu.gpuNs());
1832     try std.testing.expectEqual(@as(u64, 1), analyzer.system.counters.thread_pid_maps);
1833     try std.testing.expectEqual(@as(u64, 1), analyzer.system.counters.cpu_topology);
1834     try std.testing.expectEqual(@as(u64, 1), analyzer.system.counters.sys_time);
1835     try std.testing.expectEqual(@as(u64, 1), analyzer.system.counters.sys_power);
1836     try std.testing.expectEqual(@as(u64, 1), analyzer.system.counters.hw_samples);
1837     try std.testing.expectEqual(@as(u64, 1), analyzer.locks.counters.completed_waits);
1838     try std.testing.expectEqual(@as(u64, 1), analyzer.locks.counters.completed_holds);
1839 
1840     var jsonl = std.Io.Writer.Allocating.init(std.testing.allocator);
1841     defer jsonl.deinit();
1842     try analyzer.writeSummaryJsonl(&jsonl.writer, .{ .top = 4 });
1843     const text = jsonl.written();
1844     try std.testing.expect(std.mem.indexOf(u8, text, "\"kind\":\"summary\"") != null);
1845     try std.testing.expect(std.mem.indexOf(u8, text, "\"kind\":\"zone\"") != null);
1846     try std.testing.expect(std.mem.indexOf(u8, text, "\"p50_ns\":50") != null);
1847     try std.testing.expect(std.mem.indexOf(u8, text, "\"kind\":\"zone_prefix\"") != null);
1848     try std.testing.expect(std.mem.indexOf(u8, text, "\"kind\":\"zone_thread\"") != null);
1849     try std.testing.expect(std.mem.indexOf(u8, text, "\"kind\":\"sample\"") != null);
1850     try std.testing.expect(std.mem.indexOf(u8, text, "\"kind\":\"lock\"") != null);
1851     try std.testing.expect(std.mem.indexOf(u8, text, "\"kind\":\"context\"") != null);
1852     try std.testing.expect(std.mem.indexOf(u8, text, "\"lock_waits\":1") != null);
1853     try std.testing.expect(std.mem.indexOf(u8, text, "\"context_switches\":1") != null);
1854 }
1855 
1856 test "summary preserves completed GPU duration tails" {
1857     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
1858     defer trace.deinit();
1859     try (event.TraceEvent{ .seq = 1, .kind = .start, .time_ns = 1 })
1860         .writeJsonLine(&trace.writer);
1861     try (event.TraceEvent{
1862         .seq = 2,
1863         .kind = .gpu_zone_begin,
1864         .time_ns = 100,
1865         .thread = 1,
1866         .name = "draw",
1867         .gpu_context = 1,
1868         .gpu_query = 10,
1869     }).writeJsonLine(&trace.writer);
1870     try (event.TraceEvent{
1871         .seq = 3,
1872         .kind = .gpu_zone_end,
1873         .time_ns = 120,
1874         .thread = 1,
1875         .gpu_context = 1,
1876         .gpu_query = 11,
1877     }).writeJsonLine(&trace.writer);
1878     try (event.TraceEvent{
1879         .seq = 4,
1880         .kind = .gpu_time,
1881         .time_ns = 121,
1882         .gpu_context = 1,
1883         .gpu_query = 10,
1884         .gpu_time = 100,
1885     }).writeJsonLine(&trace.writer);
1886     try (event.TraceEvent{
1887         .seq = 5,
1888         .kind = .gpu_time,
1889         .time_ns = 122,
1890         .gpu_context = 1,
1891         .gpu_query = 11,
1892         .gpu_time = 140,
1893     }).writeJsonLine(&trace.writer);
1894     try (event.TraceEvent{ .seq = 6, .kind = .stop, .time_ns = 130 })
1895         .writeJsonLine(&trace.writer);
1896     var analyzer = Analyzer.init(std.testing.allocator);
1897     defer analyzer.deinit();
1898     try analyzer.ingestJsonlBytes(trace.written());
1899     try std.testing.expectEqualStrings("complete", analyzer.gpuDurationEvidence());
1900 
1901     var text = std.Io.Writer.Allocating.init(std.testing.allocator);
1902     defer text.deinit();
1903     try analyzer.writeSummary(&text.writer, .{});
1904     try expectSummaryContains(text.written(), "gpu_duration_evidence=complete");
1905     try expectSummaryContains(text.written(), "gpu_p99_ns=40");
1906     var jsonl = std.Io.Writer.Allocating.init(std.testing.allocator);
1907     defer jsonl.deinit();
1908     try analyzer.writeSummaryJsonl(&jsonl.writer, .{});
1909     try expectSummaryContains(jsonl.written(), "\"gpu_duration_evidence\":\"complete\"");
1910     try expectSummaryContains(jsonl.written(), "\"gpu_p99_ns\":40");
1911 }
1912 
1913 test "summary marks sized frees without prior allocation unmatched" {
1914     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
1915     defer out.deinit();
1916     try (event.TraceEvent{ .seq = 1, .kind = .free, .time_ns = 100, .thread = 1, .name = "external", .address = 4096, .size = 96 }).writeJsonLine(&out.writer);
1917 
1918     var analyzer = Analyzer.init(std.testing.allocator);
1919     defer analyzer.deinit();
1920     try analyzer.ingestJsonlBytes(out.written());
1921     try std.testing.expectEqual(@as(u64, 1), analyzer.memory.counters.frees);
1922     try std.testing.expectEqual(@as(u64, 96), analyzer.memory.counters.freed_bytes);
1923     try std.testing.expectEqual(@as(u64, 1), analyzer.memory.counters.unmatched_frees);
1924     try std.testing.expectEqual(@as(u64, 96), analyzer.memory.summaries.get("external").?.freed_bytes);
1925 }
1926 
1927 test "summary preserves completed memory lifetime evidence" {
1928     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
1929     defer trace.deinit();
1930     try (event.TraceEvent{ .seq = 1, .kind = .start, .time_ns = 1 })
1931         .writeJsonLine(&trace.writer);
1932     try (event.TraceEvent{ .seq = 2, .kind = .message, .time_ns = 10, .text = "load" })
1933         .writeJsonLine(&trace.writer);
1934     try (event.TraceEvent{
1935         .seq = 3,
1936         .kind = .alloc,
1937         .time_ns = 100,
1938         .thread = 1,
1939         .name = "arena",
1940         .address = 4096,
1941         .size = 64,
1942     }).writeJsonLine(&trace.writer);
1943     try (event.TraceEvent{
1944         .seq = 4,
1945         .kind = .free,
1946         .time_ns = 180,
1947         .thread = 2,
1948         .name = "arena",
1949         .address = 4096,
1950         .size = 64,
1951     }).writeJsonLine(&trace.writer);
1952     try (event.TraceEvent{ .seq = 5, .kind = .stop, .time_ns = 200 })
1953         .writeJsonLine(&trace.writer);
1954 
1955     var analyzer = Analyzer.init(std.testing.allocator);
1956     defer analyzer.deinit();
1957     try analyzer.ingestJsonlBytes(trace.written());
1958     try std.testing.expectEqual(@as(u64, 5), analyzer.memory.capture.counters.events);
1959     try std.testing.expectEqualStrings("complete", analyzer.memory.lifetimeEvidence());
1960     var rows = try analyzer.collectMemory();
1961     defer rows.deinit(std.testing.allocator);
1962     try std.testing.expectEqual(@as(u64, 1), rows.items[0].lifetime_samples);
1963     try std.testing.expectEqual(@as(u64, 80), rows.items[0].p99_lifetime_ns);
1964 
1965     var jsonl = std.Io.Writer.Allocating.init(std.testing.allocator);
1966     defer jsonl.deinit();
1967     try analyzer.writeSummaryJsonl(&jsonl.writer, .{});
1968     try std.testing.expect(std.mem.indexOf(
1969         u8,
1970         jsonl.written(),
1971         "\"memory_lifetime_evidence\":\"complete\"",
1972     ) != null);
1973     try std.testing.expect(std.mem.indexOf(
1974         u8,
1975         jsonl.written(),
1976         "\"lifetime_p99_ns\":80",
1977     ) != null);
1978 }
1979 
1980 test "summary retains plot configuration metadata" {
1981     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
1982     defer trace.deinit();
1983     try (event.TraceEvent{ .seq = 1, .kind = .start }).writeJsonLine(&trace.writer);
1984     try (event.TraceEvent{
1985         .seq = 2,
1986         .kind = .plot_config,
1987         .name = "queue.depth",
1988         .color = 12,
1989         .plot_unit = "count",
1990         .plot_step = true,
1991     }).writeJsonLine(&trace.writer);
1992     try (event.TraceEvent{
1993         .seq = 3,
1994         .kind = .plot,
1995         .name = "queue.depth",
1996         .value_i64 = 4,
1997         .plot_kind = "int",
1998     }).writeJsonLine(&trace.writer);
1999     try (event.TraceEvent{ .seq = 4, .kind = .stop }).writeJsonLine(&trace.writer);
2000 
2001     var analyzer = Analyzer.init(std.testing.allocator);
2002     defer analyzer.deinit();
2003     try analyzer.ingestJsonlBytes(trace.written());
2004     var jsonl = std.Io.Writer.Allocating.init(std.testing.allocator);
2005     defer jsonl.deinit();
2006     try analyzer.writeSummaryJsonl(&jsonl.writer, .{});
2007     const text = jsonl.written();
2008     try std.testing.expect(std.mem.indexOf(u8, text, "\"plot_configurations\":1") != null);
2009     try std.testing.expect(std.mem.indexOf(u8, text, "\"name\":\"queue.depth\"") != null);
2010     try std.testing.expect(std.mem.indexOf(u8, text, "\"unit\":\"count\"") != null);
2011     try std.testing.expect(std.mem.indexOf(u8, text, "\"configured\":true") != null);
2012     try std.testing.expect(std.mem.indexOf(u8, text, "\"step\":true") != null);
2013     try std.testing.expect(std.mem.indexOf(u8, text, "\"color\":12") != null);
2014 }
2015 
2016 fn expectSummaryContains(haystack: []const u8, needle: []const u8) !void {
2017     try std.testing.expect(std.mem.indexOf(u8, haystack, needle) != null);
2018 }