lib/tracy/src/compare.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

   1 const std = @import("std");
   2 const pretty_json = @import("pretty").json;
   3 const event = @import("event.zig");
   4 const frame_mod = @import("frame.zig");
   5 const gpu_mod = @import("gpu.zig");
   6 const memory_mod = @import("memory.zig");
   7 const plot_mod = @import("plot.zig");
   8 const report = @import("report.zig");
   9 const summary = @import("summary.zig");
  10 
  11 pub const schema = "tracy.compare/v0";
  12 
  13 pub const Options = struct {
  14     top: usize = 20,
  15     frame_budget_ns: ?u64 = null,
  16     min_frame_delta_ns: u64 = 0,
  17     min_zone_delta_ns: u64 = 0,
  18     min_gpu_delta_ns: u64 = 0,
  19     min_memory_delta_bytes: u64 = 0,
  20     min_memory_lifetime_delta_ns: u64 = 0,
  21     include_stable: bool = false,
  22 };
  23 
  24 pub const Analyzer = struct {
  25     summary: summary.Analyzer,
  26     frames: frame_mod.Analyzer,
  27 
  28     pub fn init(allocator: std.mem.Allocator) Analyzer {
  29         return .{
  30             .summary = summary.Analyzer.init(allocator),
  31             .frames = frame_mod.Analyzer.init(allocator),
  32         };
  33     }
  34 
  35     pub fn deinit(self: *Analyzer) void {
  36         self.frames.deinit();
  37         self.summary.deinit();
  38         self.* = undefined;
  39     }
  40 
  41     pub fn ingestJsonlBytes(self: *Analyzer, bytes: []const u8) !void {
  42         var lines = std.mem.splitScalar(u8, bytes, '\n');
  43         while (lines.next()) |line| try self.ingestJsonLine(line);
  44     }
  45 
  46     pub fn ingestJsonLine(self: *Analyzer, line: []const u8) !void {
  47         try self.summary.ingestJsonLine(line);
  48         try self.frames.ingestJsonLine(line);
  49     }
  50 };
  51 
  52 const Status = enum {
  53     changed,
  54     new,
  55     removed,
  56     stable,
  57 
  58     fn tag(self: Status) []const u8 {
  59         return switch (self) {
  60             .changed => "changed",
  61             .new => "new",
  62             .removed => "removed",
  63             .stable => "stable",
  64         };
  65     }
  66 };
  67 
  68 const Counts = struct {
  69     shared: usize = 0,
  70     changed: usize = 0,
  71     stable: usize = 0,
  72     new: usize = 0,
  73     removed: usize = 0,
  74 
  75     fn observe(self: *Counts, status: Status) void {
  76         switch (status) {
  77             .changed => {
  78                 self.shared += 1;
  79                 self.changed += 1;
  80             },
  81             .stable => {
  82                 self.shared += 1;
  83                 self.stable += 1;
  84             },
  85             .new => self.new += 1,
  86             .removed => self.removed += 1,
  87         }
  88     }
  89 };
  90 
  91 const ZoneMetric = enum {
  92     total,
  93     mean,
  94     min,
  95     p50,
  96     p90,
  97     p99,
  98     max,
  99 
 100     fn tag(self: ZoneMetric) []const u8 {
 101         return @tagName(self);
 102     }
 103 };
 104 
 105 const ZoneValues = struct {
 106     count: u64 = 0,
 107     duration_samples: u64 = 0,
 108     total_ns: u64 = 0,
 109     mean_ns: u64 = 0,
 110     min_ns: u64 = 0,
 111     p50_ns: u64 = 0,
 112     p90_ns: u64 = 0,
 113     p99_ns: u64 = 0,
 114     max_ns: u64 = 0,
 115 
 116     fn from(summary_value: ?summary.ZoneSummary) ZoneValues {
 117         const row = summary_value orelse return .{};
 118         return .{
 119             .count = row.count,
 120             .duration_samples = row.duration_samples,
 121             .total_ns = row.total_ns,
 122             .mean_ns = row.meanNs(),
 123             .min_ns = if (row.duration_samples == 0) 0 else row.min_ns,
 124             .p50_ns = row.percentileNs(50),
 125             .p90_ns = row.percentileNs(90),
 126             .p99_ns = row.percentileNs(99),
 127             .max_ns = row.max_ns,
 128         };
 129     }
 130 
 131     fn value(self: ZoneValues, metric: ZoneMetric) u64 {
 132         return switch (metric) {
 133             .total => self.total_ns,
 134             .mean => self.mean_ns,
 135             .min => self.min_ns,
 136             .p50 => self.p50_ns,
 137             .p90 => self.p90_ns,
 138             .p99 => self.p99_ns,
 139             .max => self.max_ns,
 140         };
 141     }
 142 };
 143 
 144 const ZoneDelta = struct {
 145     name: []const u8,
 146     status: Status,
 147     baseline: ZoneValues,
 148     run: ZoneValues,
 149     baseline_evidence: []const u8,
 150     run_evidence: []const u8,
 151 
 152     fn evidence(self: ZoneDelta) []const u8 {
 153         if (!std.mem.eql(u8, self.baseline_evidence, "complete")) return "partial";
 154         if (!std.mem.eql(u8, self.run_evidence, "complete")) return "partial";
 155         if (self.baseline.count != self.baseline.duration_samples) return "partial";
 156         if (self.run.count != self.run.duration_samples) return "partial";
 157         return "complete";
 158     }
 159 
 160     fn populationChanged(self: ZoneDelta) bool {
 161         if (self.baseline.count != self.run.count) return true;
 162         return self.baseline.duration_samples != self.run.duration_samples;
 163     }
 164 
 165     fn delta(self: ZoneDelta, metric: ZoneMetric) i128 {
 166         return signedDelta(self.baseline.value(metric), self.run.value(metric));
 167     }
 168 
 169     fn magnitude(self: ZoneDelta) u128 {
 170         var result: u128 = 0;
 171         inline for (std.meta.tags(ZoneMetric)) |metric| {
 172             result = @max(result, absI128(self.delta(metric)));
 173         }
 174         return result;
 175     }
 176 };
 177 
 178 const MemoryValues = struct {
 179     allocations: u64 = 0,
 180     frees: u64 = 0,
 181     unmatched_frees: u64 = 0,
 182     duplicate_allocations: u64 = 0,
 183     right_censored_allocations: u64 = 0,
 184     completed_lifetimes: u64 = 0,
 185     lifetime_samples: u64 = 0,
 186     timestamp_regressions: u64 = 0,
 187     untracked_allocations: u64 = 0,
 188     size_mismatches: u64 = 0,
 189     allocated_bytes: u64 = 0,
 190     freed_bytes: u64 = 0,
 191     live_bytes: u64 = 0,
 192     high_water_live_bytes: u64 = 0,
 193     total_lifetime_ns: u64 = 0,
 194     mean_lifetime_ns: u64 = 0,
 195     min_lifetime_ns: u64 = 0,
 196     p50_lifetime_ns: u64 = 0,
 197     p90_lifetime_ns: u64 = 0,
 198     p99_lifetime_ns: u64 = 0,
 199     max_lifetime_ns: u64 = 0,
 200 
 201     fn from(summary_value: ?memory_mod.Summary) MemoryValues {
 202         const value = summary_value orelse return .{};
 203         return .{
 204             .allocations = value.allocations,
 205             .frees = value.frees,
 206             .unmatched_frees = value.unmatched_frees,
 207             .duplicate_allocations = value.duplicate_allocations,
 208             .right_censored_allocations = value.active_allocations,
 209             .completed_lifetimes = value.completed_lifetimes,
 210             .lifetime_samples = value.lifetime_samples,
 211             .timestamp_regressions = value.timestamp_regressions,
 212             .untracked_allocations = value.untracked_allocations,
 213             .size_mismatches = value.size_mismatches,
 214             .allocated_bytes = value.allocated_bytes,
 215             .freed_bytes = value.freed_bytes,
 216             .live_bytes = value.live_bytes,
 217             .high_water_live_bytes = value.high_water_live_bytes,
 218             .total_lifetime_ns = value.total_lifetime_ns,
 219             .mean_lifetime_ns = value.meanLifetimeNs(),
 220             .min_lifetime_ns = value.min_lifetime_ns,
 221             .p50_lifetime_ns = value.p50_lifetime_ns,
 222             .p90_lifetime_ns = value.p90_lifetime_ns,
 223             .p99_lifetime_ns = value.p99_lifetime_ns,
 224             .max_lifetime_ns = value.max_lifetime_ns,
 225         };
 226     }
 227 };
 228 
 229 const MemoryDelta = struct {
 230     name: []const u8,
 231     status: Status,
 232     baseline: MemoryValues,
 233     run: MemoryValues,
 234     baseline_lifetime_evidence: []const u8,
 235     run_lifetime_evidence: []const u8,
 236 
 237     fn evidence(self: MemoryDelta) []const u8 {
 238         if (!std.mem.eql(u8, self.baseline_lifetime_evidence, "complete")) return "partial";
 239         if (!std.mem.eql(u8, self.run_lifetime_evidence, "complete")) return "partial";
 240         return "complete";
 241     }
 242 
 243     fn byteMagnitude(self: MemoryDelta) u128 {
 244         var result: u128 = 0;
 245         inline for (.{
 246             signedDelta(self.baseline.high_water_live_bytes, self.run.high_water_live_bytes),
 247             signedDelta(self.baseline.live_bytes, self.run.live_bytes),
 248             signedDelta(self.baseline.allocated_bytes, self.run.allocated_bytes),
 249             signedDelta(self.baseline.freed_bytes, self.run.freed_bytes),
 250         }) |delta| result = @max(result, absI128(delta));
 251         return result;
 252     }
 253 
 254     fn lifetimeMagnitude(self: MemoryDelta) u128 {
 255         var result: u128 = 0;
 256         inline for (.{
 257             signedDelta(self.baseline.total_lifetime_ns, self.run.total_lifetime_ns),
 258             signedDelta(self.baseline.mean_lifetime_ns, self.run.mean_lifetime_ns),
 259             signedDelta(self.baseline.min_lifetime_ns, self.run.min_lifetime_ns),
 260             signedDelta(self.baseline.p50_lifetime_ns, self.run.p50_lifetime_ns),
 261             signedDelta(self.baseline.p90_lifetime_ns, self.run.p90_lifetime_ns),
 262             signedDelta(self.baseline.p99_lifetime_ns, self.run.p99_lifetime_ns),
 263             signedDelta(self.baseline.max_lifetime_ns, self.run.max_lifetime_ns),
 264         }) |delta| result = @max(result, absI128(delta));
 265         return result;
 266     }
 267 
 268     fn populationChanged(self: MemoryDelta) bool {
 269         if (self.baseline.allocations != self.run.allocations) return true;
 270         if (self.baseline.frees != self.run.frees) return true;
 271         if (self.baseline.right_censored_allocations != self.run.right_censored_allocations) {
 272             return true;
 273         }
 274         if (self.baseline.completed_lifetimes != self.run.completed_lifetimes) return true;
 275         return self.baseline.lifetime_samples != self.run.lifetime_samples;
 276     }
 277 
 278     fn anomaliesChanged(self: MemoryDelta) bool {
 279         if (self.baseline.unmatched_frees != self.run.unmatched_frees) return true;
 280         if (self.baseline.duplicate_allocations != self.run.duplicate_allocations) return true;
 281         if (self.baseline.timestamp_regressions != self.run.timestamp_regressions) return true;
 282         if (self.baseline.untracked_allocations != self.run.untracked_allocations) return true;
 283         return self.baseline.size_mismatches != self.run.size_mismatches;
 284     }
 285 };
 286 
 287 const PlotCompatibility = enum {
 288     matching,
 289     mismatch,
 290     unknown,
 291     not_applicable,
 292 
 293     fn tag(self: PlotCompatibility) []const u8 {
 294         return switch (self) {
 295             .matching => "matching",
 296             .mismatch => "mismatch",
 297             .unknown => "unknown",
 298             .not_applicable => "not_applicable",
 299         };
 300     }
 301 };
 302 
 303 const PlotMetric = enum {
 304     min,
 305     p50,
 306     p90,
 307     p99,
 308     max,
 309     mean,
 310     last,
 311     range,
 312 
 313     fn tag(self: PlotMetric) []const u8 {
 314         return @tagName(self);
 315     }
 316 
 317     fn value(self: PlotMetric, plot: plot_mod.Summary) f64 {
 318         return switch (self) {
 319             .min => plot.min,
 320             .p50 => plot.p50,
 321             .p90 => plot.p90,
 322             .p99 => plot.p99,
 323             .max => plot.max,
 324             .mean => plot.mean,
 325             .last => plot.last,
 326             .range => plot.range,
 327         };
 328     }
 329 };
 330 
 331 const PlotDelta = struct {
 332     name: []const u8,
 333     status: Status,
 334     partial: bool,
 335     compatibility: PlotCompatibility,
 336     baseline: ?plot_mod.Summary,
 337     run: ?plot_mod.Summary,
 338 
 339     fn evidence(self: PlotDelta) []const u8 {
 340         return if (self.partial) "partial" else "complete";
 341     }
 342 
 343     fn valuesComparable(self: PlotDelta) bool {
 344         if (self.baseline == null or self.run == null) return false;
 345         return self.compatibility != .mismatch;
 346     }
 347 
 348     fn magnitude(self: PlotDelta) f64 {
 349         if (!self.valuesComparable()) {
 350             const base_extent = if (self.baseline) |plot|
 351                 @max(absF64(plot.min), absF64(plot.max))
 352             else
 353                 0;
 354             const run_extent = if (self.run) |plot|
 355                 @max(absF64(plot.min), absF64(plot.max))
 356             else
 357                 0;
 358             return @max(base_extent, run_extent);
 359         }
 360         var result: f64 = 0;
 361         inline for (std.meta.tags(PlotMetric)) |metric| {
 362             result = @max(result, absF64(self.delta(metric).?));
 363         }
 364         return result;
 365     }
 366 
 367     fn delta(self: PlotDelta, metric: PlotMetric) ?f64 {
 368         if (!self.valuesComparable()) return null;
 369         return metric.value(self.run.?) - metric.value(self.baseline.?);
 370     }
 371 };
 372 
 373 const GpuDelta = struct {
 374     name: []const u8,
 375     status: Status,
 376     base_count: u64 = 0,
 377     run_count: u64 = 0,
 378     base_completed_zones: u64 = 0,
 379     run_completed_zones: u64 = 0,
 380     base_total_gpu_ns: u64 = 0,
 381     run_total_gpu_ns: u64 = 0,
 382     base_mean_gpu_ns: u64 = 0,
 383     run_mean_gpu_ns: u64 = 0,
 384 
 385     fn deltaTotalGpuNs(self: GpuDelta) i128 {
 386         return signedDelta(self.base_total_gpu_ns, self.run_total_gpu_ns);
 387     }
 388 
 389     fn deltaMeanGpuNs(self: GpuDelta) i128 {
 390         return signedDelta(self.base_mean_gpu_ns, self.run_mean_gpu_ns);
 391     }
 392 
 393     fn magnitude(self: GpuDelta) u128 {
 394         return @max(
 395             absI128(self.deltaTotalGpuNs()),
 396             absI128(self.deltaMeanGpuNs()),
 397         );
 398     }
 399 
 400     fn evidence(self: GpuDelta) []const u8 {
 401         if (self.base_count != self.base_completed_zones) return "partial";
 402         if (self.run_count != self.run_completed_zones) return "partial";
 403         return "complete";
 404     }
 405 };
 406 
 407 const FrameDelta = struct {
 408     name: []const u8,
 409     status: Status,
 410     partial: bool,
 411     budget_ns: ?u64,
 412     base_marks: u64 = 0,
 413     run_marks: u64 = 0,
 414     base_frames: u64 = 0,
 415     run_frames: u64 = 0,
 416     base_mean_ns: u64 = 0,
 417     run_mean_ns: u64 = 0,
 418     base_p50_ns: u64 = 0,
 419     run_p50_ns: u64 = 0,
 420     base_p90_ns: u64 = 0,
 421     run_p90_ns: u64 = 0,
 422     base_p99_ns: u64 = 0,
 423     run_p99_ns: u64 = 0,
 424     base_max_ns: u64 = 0,
 425     run_max_ns: u64 = 0,
 426     base_discrepancy_ns: u64 = 0,
 427     run_discrepancy_ns: u64 = 0,
 428     base_over_budget_frames: u64 = 0,
 429     run_over_budget_frames: u64 = 0,
 430     base_over_budget_per_mille: u64 = 0,
 431     run_over_budget_per_mille: u64 = 0,
 432     base_missed_intervals: u64 = 0,
 433     run_missed_intervals: u64 = 0,
 434     base_missed_per_1000_frames: u64 = 0,
 435     run_missed_per_1000_frames: u64 = 0,
 436     base_total_overrun_ns: u64 = 0,
 437     run_total_overrun_ns: u64 = 0,
 438     base_max_overrun_ns: u64 = 0,
 439     run_max_overrun_ns: u64 = 0,
 440     base_longest_overrun_streak: u64 = 0,
 441     run_longest_overrun_streak: u64 = 0,
 442 
 443     fn evidence(self: FrameDelta) []const u8 {
 444         return if (self.partial) "partial" else "complete";
 445     }
 446 
 447     fn magnitude(self: FrameDelta) u128 {
 448         var result: u128 = 0;
 449         inline for (.{
 450             signedDelta(self.base_mean_ns, self.run_mean_ns),
 451             signedDelta(self.base_p50_ns, self.run_p50_ns),
 452             signedDelta(self.base_p90_ns, self.run_p90_ns),
 453             signedDelta(self.base_p99_ns, self.run_p99_ns),
 454             signedDelta(self.base_max_ns, self.run_max_ns),
 455             signedDelta(self.base_discrepancy_ns, self.run_discrepancy_ns),
 456         }) |delta| result = @max(result, absI128(delta));
 457         return result;
 458     }
 459 
 460     fn populationChanged(self: FrameDelta) bool {
 461         return self.base_marks != self.run_marks or self.base_frames != self.run_frames;
 462     }
 463 
 464     fn budgetChanged(self: FrameDelta) bool {
 465         if (self.budget_ns == null) return false;
 466         if (self.base_over_budget_frames != self.run_over_budget_frames) return true;
 467         if (self.base_over_budget_per_mille != self.run_over_budget_per_mille) return true;
 468         if (self.base_missed_intervals != self.run_missed_intervals) return true;
 469         if (self.base_missed_per_1000_frames != self.run_missed_per_1000_frames) return true;
 470         if (self.base_total_overrun_ns != self.run_total_overrun_ns) return true;
 471         if (self.base_max_overrun_ns != self.run_max_overrun_ns) return true;
 472         return self.base_longest_overrun_streak != self.run_longest_overrun_streak;
 473     }
 474 };
 475 
 476 const FrameEvidence = struct {
 477     baseline: std.ArrayListUnmanaged(frame_mod.Summary) = .empty,
 478     run: std.ArrayListUnmanaged(frame_mod.Summary) = .empty,
 479     deltas: std.ArrayListUnmanaged(FrameDelta) = .empty,
 480 
 481     fn init(
 482         allocator: std.mem.Allocator,
 483         baseline: *frame_mod.Analyzer,
 484         run: *frame_mod.Analyzer,
 485         options: Options,
 486     ) !FrameEvidence {
 487         if (options.frame_budget_ns == 0) return error.InvalidBudget;
 488         var result: FrameEvidence = .{};
 489         errdefer result.deinit(allocator);
 490         const frame_options = frame_mod.Options{
 491             .top = options.top,
 492             .sort = .name,
 493             .budget_ns = options.frame_budget_ns,
 494         };
 495         result.baseline = try frame_mod.collectSummaries(allocator, baseline, frame_options);
 496         result.run = try frame_mod.collectSummaries(allocator, run, frame_options);
 497         const partial = frameCapturePartial(baseline) or frameCapturePartial(run);
 498         result.deltas = try collectFrameDeltas(
 499             allocator,
 500             result.baseline.items,
 501             result.run.items,
 502             partial,
 503             options,
 504         );
 505         return result;
 506     }
 507 
 508     fn deinit(self: *FrameEvidence, allocator: std.mem.Allocator) void {
 509         self.deltas.deinit(allocator);
 510         self.run.deinit(allocator);
 511         self.baseline.deinit(allocator);
 512         self.* = undefined;
 513     }
 514 };
 515 
 516 const PlotEvidence = struct {
 517     baseline: std.ArrayListUnmanaged(plot_mod.Summary) = .empty,
 518     run: std.ArrayListUnmanaged(plot_mod.Summary) = .empty,
 519     deltas: std.ArrayListUnmanaged(PlotDelta) = .empty,
 520 
 521     fn init(
 522         allocator: std.mem.Allocator,
 523         baseline: *summary.Analyzer,
 524         run: *summary.Analyzer,
 525         options: Options,
 526     ) !PlotEvidence {
 527         var result: PlotEvidence = .{};
 528         errdefer result.deinit(allocator);
 529         result.baseline = try baseline.plots.collectSummaries(allocator, .{ .sort = .name });
 530         result.run = try run.plots.collectSummaries(allocator, .{ .sort = .name });
 531         const partial = plotCapturePartial(baseline) or plotCapturePartial(run);
 532         result.deltas = try collectPlotDeltas(
 533             allocator,
 534             result.baseline.items,
 535             result.run.items,
 536             partial,
 537             options,
 538         );
 539         return result;
 540     }
 541 
 542     fn deinit(self: *PlotEvidence, allocator: std.mem.Allocator) void {
 543         self.deltas.deinit(allocator);
 544         self.run.deinit(allocator);
 545         self.baseline.deinit(allocator);
 546         self.* = undefined;
 547     }
 548 };
 549 
 550 const MemoryEvidence = struct {
 551     baseline: std.ArrayListUnmanaged(memory_mod.Summary) = .empty,
 552     run: std.ArrayListUnmanaged(memory_mod.Summary) = .empty,
 553     deltas: std.ArrayListUnmanaged(MemoryDelta) = .empty,
 554     baseline_allocator: ?std.mem.Allocator = null,
 555     run_allocator: ?std.mem.Allocator = null,
 556 
 557     fn init(
 558         allocator: std.mem.Allocator,
 559         baseline: *summary.Analyzer,
 560         run: *summary.Analyzer,
 561         options: Options,
 562     ) !MemoryEvidence {
 563         var result: MemoryEvidence = .{};
 564         errdefer result.deinit(allocator);
 565         result.baseline_allocator = baseline.allocator;
 566         result.baseline = try baseline.collectMemory();
 567         result.run_allocator = run.allocator;
 568         result.run = try run.collectMemory();
 569         result.deltas = try collectMemoryDeltas(
 570             allocator,
 571             result.baseline.items,
 572             result.run.items,
 573             baseline.memory.lifetimeEvidence(),
 574             run.memory.lifetimeEvidence(),
 575             options,
 576         );
 577         return result;
 578     }
 579 
 580     fn deinit(self: *MemoryEvidence, allocator: std.mem.Allocator) void {
 581         self.deltas.deinit(allocator);
 582         if (self.run_allocator) |actual| self.run.deinit(actual);
 583         if (self.baseline_allocator) |actual| self.baseline.deinit(actual);
 584         self.* = undefined;
 585     }
 586 };
 587 
 588 const GpuEvidence = struct {
 589     baseline: std.ArrayListUnmanaged(gpu_mod.Summary) = .empty,
 590     run: std.ArrayListUnmanaged(gpu_mod.Summary) = .empty,
 591     deltas: std.ArrayListUnmanaged(GpuDelta) = .empty,
 592     baseline_allocator: ?std.mem.Allocator = null,
 593     run_allocator: ?std.mem.Allocator = null,
 594 
 595     fn init(
 596         allocator: std.mem.Allocator,
 597         baseline: *summary.Analyzer,
 598         run: *summary.Analyzer,
 599         options: Options,
 600     ) !GpuEvidence {
 601         var result: GpuEvidence = .{};
 602         errdefer result.deinit(allocator);
 603         result.baseline_allocator = baseline.allocator;
 604         result.baseline = try baseline.collectGpu();
 605         result.run_allocator = run.allocator;
 606         result.run = try run.collectGpu();
 607         result.deltas = try collectGpuDeltas(
 608             allocator,
 609             result.baseline.items,
 610             result.run.items,
 611             options,
 612         );
 613         return result;
 614     }
 615 
 616     fn deinit(self: *GpuEvidence, allocator: std.mem.Allocator) void {
 617         self.deltas.deinit(allocator);
 618         if (self.run_allocator) |actual| gpu_mod.deinitSummaries(actual, &self.run);
 619         if (self.baseline_allocator) |actual| gpu_mod.deinitSummaries(actual, &self.baseline);
 620         self.* = undefined;
 621     }
 622 };
 623 
 624 const Evidence = struct {
 625     frames: FrameEvidence = .{},
 626     plots: PlotEvidence = .{},
 627     zones: std.ArrayListUnmanaged(ZoneDelta) = .empty,
 628     memory: MemoryEvidence = .{},
 629     gpu: GpuEvidence = .{},
 630 
 631     fn init(
 632         allocator: std.mem.Allocator,
 633         baseline: *Analyzer,
 634         run: *Analyzer,
 635         options: Options,
 636     ) !Evidence {
 637         var result: Evidence = .{};
 638         errdefer result.deinit(allocator);
 639         result.frames = try FrameEvidence.init(
 640             allocator,
 641             &baseline.frames,
 642             &run.frames,
 643             options,
 644         );
 645         result.plots = try PlotEvidence.init(
 646             allocator,
 647             &baseline.summary,
 648             &run.summary,
 649             options,
 650         );
 651         result.zones = try collectZoneDeltas(
 652             allocator,
 653             &baseline.summary,
 654             &run.summary,
 655             options,
 656         );
 657         result.memory = try MemoryEvidence.init(
 658             allocator,
 659             &baseline.summary,
 660             &run.summary,
 661             options,
 662         );
 663         result.gpu = try GpuEvidence.init(
 664             allocator,
 665             &baseline.summary,
 666             &run.summary,
 667             options,
 668         );
 669         return result;
 670     }
 671 
 672     fn deinit(self: *Evidence, allocator: std.mem.Allocator) void {
 673         self.gpu.deinit(allocator);
 674         self.memory.deinit(allocator);
 675         self.zones.deinit(allocator);
 676         self.plots.deinit(allocator);
 677         self.frames.deinit(allocator);
 678         self.* = undefined;
 679     }
 680 };
 681 
 682 pub fn writeTextFromJsonlPaths(
 683     allocator: std.mem.Allocator,
 684     baseline_path: []const u8,
 685     run_path: []const u8,
 686     writer: *std.Io.Writer,
 687     options: Options,
 688 ) !void {
 689     var baseline = Analyzer.init(allocator);
 690     defer baseline.deinit();
 691     try ingestPath(&baseline, baseline_path);
 692 
 693     var run = Analyzer.init(allocator);
 694     defer run.deinit();
 695     try ingestPath(&run, run_path);
 696 
 697     try writeText(allocator, &baseline, &run, writer, options);
 698 }
 699 
 700 pub fn writeJsonlFromJsonlPaths(
 701     allocator: std.mem.Allocator,
 702     baseline_path: []const u8,
 703     run_path: []const u8,
 704     writer: *std.Io.Writer,
 705     options: Options,
 706 ) !void {
 707     var baseline = Analyzer.init(allocator);
 708     defer baseline.deinit();
 709     try ingestPath(&baseline, baseline_path);
 710 
 711     var run = Analyzer.init(allocator);
 712     defer run.deinit();
 713     try ingestPath(&run, run_path);
 714 
 715     try writeJsonl(allocator, &baseline, &run, writer, options);
 716 }
 717 
 718 pub fn ingestPath(analyzer: *Analyzer, path: []const u8) !void {
 719     return report.ingestJsonlPath(analyzer, path);
 720 }
 721 
 722 pub fn writeText(
 723     allocator: std.mem.Allocator,
 724     baseline: *Analyzer,
 725     run: *Analyzer,
 726     writer: *std.Io.Writer,
 727     options: Options,
 728 ) !void {
 729     var evidence = try Evidence.init(allocator, baseline, run, options);
 730     defer evidence.deinit(allocator);
 731     try writeCaptureText(&baseline.summary, &run.summary, writer);
 732     try writeFrameText(baseline, run, writer, evidence.frames.deltas.items, options);
 733     try writePlotText(
 734         &baseline.summary,
 735         &run.summary,
 736         writer,
 737         evidence.plots.deltas.items,
 738         options.top,
 739     );
 740     try writeZoneText(
 741         &baseline.summary,
 742         &run.summary,
 743         writer,
 744         evidence.zones.items,
 745         options.top,
 746     );
 747     try writeMemoryText(
 748         &baseline.summary,
 749         &run.summary,
 750         writer,
 751         evidence.memory.deltas.items,
 752         options.top,
 753     );
 754     try writeGpuText(
 755         &baseline.summary,
 756         &run.summary,
 757         writer,
 758         evidence.gpu.deltas.items,
 759         options.top,
 760     );
 761 }
 762 
 763 fn writeCaptureText(
 764     baseline: *summary.Analyzer,
 765     run: *summary.Analyzer,
 766     writer: *std.Io.Writer,
 767 ) !void {
 768     const base_integrity = baseline.captureIntegrity();
 769     const run_integrity = run.captureIntegrity();
 770     try writer.print(
 771         "tracy compare capture baseline_integrity={s} run_integrity={s} " ++
 772             "baseline_unbalanced_events={d} run_unbalanced_events={d}\n",
 773         .{
 774             base_integrity.status,
 775             run_integrity.status,
 776             base_integrity.unbalanced_event_count,
 777             run_integrity.unbalanced_event_count,
 778         },
 779     );
 780 }
 781 
 782 fn writeFrameText(
 783     baseline: *Analyzer,
 784     run: *Analyzer,
 785     writer: *std.Io.Writer,
 786     rows: []const FrameDelta,
 787     options: Options,
 788 ) !void {
 789     const counts = countStatuses(rows);
 790     try writer.print(
 791         "tracy compare frames shared={d} changed={d} stable={d} new={d} removed={d} " ++
 792             "partial={d} baseline_marks={d} run_marks={d} baseline_frames={d} " ++
 793             "run_frames={d} baseline_out_of_order_marks={d} run_out_of_order_marks={d}",
 794         .{
 795             counts.shared,
 796             counts.changed,
 797             counts.stable,
 798             counts.new,
 799             counts.removed,
 800             framePartialCount(rows),
 801             baseline.frames.counters.marks,
 802             run.frames.counters.marks,
 803             baseline.frames.counters.frames,
 804             run.frames.counters.frames,
 805             baseline.frames.counters.out_of_order_marks,
 806             run.frames.counters.out_of_order_marks,
 807         },
 808     );
 809     try writer.writeAll(" budget_ns=");
 810     try writeOptionalU64Text(writer, options.frame_budget_ns);
 811     try writer.writeByte('\n');
 812     for (rows[0..@min(options.top, rows.len)]) |row| {
 813         try writeFrameTextRow(writer, row);
 814         if (row.budget_ns != null) try writeFrameBudgetTextRow(writer, row);
 815     }
 816 }
 817 
 818 fn writeFrameTextRow(writer: *std.Io.Writer, row: FrameDelta) !void {
 819     try writer.print("frame status={s} evidence={s} name=", .{
 820         row.status.tag(),
 821         row.evidence(),
 822     });
 823     try pretty_json.writeString(writer, row.name);
 824     try writer.print(
 825         " base_marks={d} run_marks={d} delta_marks={d} base_frames={d} " ++
 826             "run_frames={d} delta_frames={d} base_mean_ns={d} run_mean_ns={d} " ++
 827             "delta_mean_ns={d} base_p50_ns={d} run_p50_ns={d} delta_p50_ns={d} " ++
 828             "base_p90_ns={d} run_p90_ns={d} delta_p90_ns={d} base_p99_ns={d} " ++
 829             "run_p99_ns={d} delta_p99_ns={d} base_max_ns={d} run_max_ns={d} " ++
 830             "delta_max_ns={d} base_discrepancy_ns={d} run_discrepancy_ns={d} " ++
 831             "delta_discrepancy_ns={d}\n",
 832         .{
 833             row.base_marks,
 834             row.run_marks,
 835             signedDelta(row.base_marks, row.run_marks),
 836             row.base_frames,
 837             row.run_frames,
 838             signedDelta(row.base_frames, row.run_frames),
 839             row.base_mean_ns,
 840             row.run_mean_ns,
 841             signedDelta(row.base_mean_ns, row.run_mean_ns),
 842             row.base_p50_ns,
 843             row.run_p50_ns,
 844             signedDelta(row.base_p50_ns, row.run_p50_ns),
 845             row.base_p90_ns,
 846             row.run_p90_ns,
 847             signedDelta(row.base_p90_ns, row.run_p90_ns),
 848             row.base_p99_ns,
 849             row.run_p99_ns,
 850             signedDelta(row.base_p99_ns, row.run_p99_ns),
 851             row.base_max_ns,
 852             row.run_max_ns,
 853             signedDelta(row.base_max_ns, row.run_max_ns),
 854             row.base_discrepancy_ns,
 855             row.run_discrepancy_ns,
 856             signedDelta(row.base_discrepancy_ns, row.run_discrepancy_ns),
 857         },
 858     );
 859 }
 860 
 861 fn writeFrameBudgetTextRow(writer: *std.Io.Writer, row: FrameDelta) !void {
 862     try writer.print("frame-budget status={s} evidence={s} name=", .{
 863         row.status.tag(),
 864         row.evidence(),
 865     });
 866     try pretty_json.writeString(writer, row.name);
 867     try writer.print(
 868         " budget_ns={d} base_over_budget_frames={d} run_over_budget_frames={d} " ++
 869             "delta_over_budget_frames={d} base_over_budget_per_mille={d} " ++
 870             "run_over_budget_per_mille={d} delta_over_budget_per_mille={d} " ++
 871             "base_missed_intervals={d} run_missed_intervals={d} " ++
 872             "delta_missed_intervals={d} base_missed_per_1000_frames={d} " ++
 873             "run_missed_per_1000_frames={d} delta_missed_per_1000_frames={d} " ++
 874             "base_total_overrun_ns={d} run_total_overrun_ns={d} " ++
 875             "delta_total_overrun_ns={d} base_max_overrun_ns={d} " ++
 876             "run_max_overrun_ns={d} delta_max_overrun_ns={d} " ++
 877             "base_longest_overrun_streak={d} run_longest_overrun_streak={d} " ++
 878             "delta_longest_overrun_streak={d}\n",
 879         .{
 880             row.budget_ns.?,
 881             row.base_over_budget_frames,
 882             row.run_over_budget_frames,
 883             signedDelta(row.base_over_budget_frames, row.run_over_budget_frames),
 884             row.base_over_budget_per_mille,
 885             row.run_over_budget_per_mille,
 886             signedDelta(row.base_over_budget_per_mille, row.run_over_budget_per_mille),
 887             row.base_missed_intervals,
 888             row.run_missed_intervals,
 889             signedDelta(row.base_missed_intervals, row.run_missed_intervals),
 890             row.base_missed_per_1000_frames,
 891             row.run_missed_per_1000_frames,
 892             signedDelta(row.base_missed_per_1000_frames, row.run_missed_per_1000_frames),
 893             row.base_total_overrun_ns,
 894             row.run_total_overrun_ns,
 895             signedDelta(row.base_total_overrun_ns, row.run_total_overrun_ns),
 896             row.base_max_overrun_ns,
 897             row.run_max_overrun_ns,
 898             signedDelta(row.base_max_overrun_ns, row.run_max_overrun_ns),
 899             row.base_longest_overrun_streak,
 900             row.run_longest_overrun_streak,
 901             signedDelta(
 902                 row.base_longest_overrun_streak,
 903                 row.run_longest_overrun_streak,
 904             ),
 905         },
 906     );
 907 }
 908 
 909 fn writePlotText(
 910     baseline: *summary.Analyzer,
 911     run: *summary.Analyzer,
 912     writer: *std.Io.Writer,
 913     rows: []const PlotDelta,
 914     top: usize,
 915 ) !void {
 916     const counts = countStatuses(rows);
 917     try writer.print(
 918         "tracy compare plots shared={d} changed={d} stable={d} new={d} removed={d} " ++
 919             "partial={d} unit_mismatch={d} unit_unknown={d} " ++
 920             "baseline_samples={d} run_samples={d} baseline_configurations={d} " ++
 921             "run_configurations={d} baseline_configuration_conflicts={d} " ++
 922             "run_configuration_conflicts={d} baseline_ignored_missing_values={d} " ++
 923             "run_ignored_missing_values={d} baseline_ignored_nonfinite={d} " ++
 924             "run_ignored_nonfinite={d}\n",
 925         .{
 926             counts.shared,
 927             counts.changed,
 928             counts.stable,
 929             counts.new,
 930             counts.removed,
 931             plotPartialCount(rows),
 932             plotCompatibilityCount(rows, .mismatch),
 933             plotCompatibilityCount(rows, .unknown),
 934             baseline.plots.counters.samples,
 935             run.plots.counters.samples,
 936             baseline.plots.counters.configurations,
 937             run.plots.counters.configurations,
 938             baseline.plots.counters.configuration_conflicts,
 939             run.plots.counters.configuration_conflicts,
 940             baseline.plots.counters.ignored_missing_values,
 941             run.plots.counters.ignored_missing_values,
 942             baseline.plots.counters.ignored_nonfinite,
 943             run.plots.counters.ignored_nonfinite,
 944         },
 945     );
 946     for (rows[0..@min(top, rows.len)]) |row| try writePlotTextRow(writer, row);
 947 }
 948 
 949 fn writePlotTextRow(writer: *std.Io.Writer, row: PlotDelta) !void {
 950     const baseline = plotOrEmpty(row.baseline, row.name);
 951     const run = plotOrEmpty(row.run, row.name);
 952     try writer.print(
 953         "plot status={s} evidence={s} unit_compatibility={s} name=",
 954         .{ row.status.tag(), row.evidence(), row.compatibility.tag() },
 955     );
 956     try pretty_json.writeString(writer, row.name);
 957     try writePlotDefinitionText(writer, row);
 958     try writer.print(
 959         " base_samples={d} run_samples={d} delta_samples={d} " ++
 960             "base_threads={d} run_threads={d} delta_threads={d} " ++
 961             "base_duration_ns={d} run_duration_ns={d} delta_duration_ns={d}",
 962         .{
 963             baseline.count,
 964             run.count,
 965             signedDelta(baseline.count, run.count),
 966             baseline.threads,
 967             run.threads,
 968             signedDelta(baseline.threads, run.threads),
 969             baseline.duration_ns,
 970             run.duration_ns,
 971             signedDelta(baseline.duration_ns, run.duration_ns),
 972         },
 973     );
 974     try writer.writeByte('\n');
 975     try writePlotStyleTextRow(writer, row);
 976     try writePlotDistributionTextRow(writer, row);
 977 }
 978 
 979 fn writePlotDefinitionText(writer: *std.Io.Writer, row: PlotDelta) !void {
 980     const baseline = plotOrEmpty(row.baseline, row.name);
 981     const run = plotOrEmpty(row.run, row.name);
 982     try writer.writeAll(" base_kind=");
 983     try writeOptionalStringText(writer, baseline.kind);
 984     try writer.writeAll(" run_kind=");
 985     try writeOptionalStringText(writer, run.kind);
 986     try writer.writeAll(" base_unit=");
 987     try writeOptionalStringText(writer, baseline.unit);
 988     try writer.writeAll(" run_unit=");
 989     try writeOptionalStringText(writer, run.unit);
 990 }
 991 
 992 fn writePlotStyleTextRow(writer: *std.Io.Writer, row: PlotDelta) !void {
 993     const baseline = plotOrEmpty(row.baseline, row.name);
 994     const run = plotOrEmpty(row.run, row.name);
 995     try writer.print("plot-style status={s} evidence={s} name=", .{
 996         row.status.tag(),
 997         row.evidence(),
 998     });
 999     try pretty_json.writeString(writer, row.name);
1000     try writer.print(
1001         " base_configured={} run_configured={} base_step={} run_step={} " ++
1002             "base_fill={} run_fill={} base_color=",
1003         .{
1004             baseline.configured,
1005             run.configured,
1006             baseline.step,
1007             run.step,
1008             baseline.fill,
1009             run.fill,
1010         },
1011     );
1012     try writeOptionalU32Text(writer, baseline.color);
1013     try writer.writeAll(" run_color=");
1014     try writeOptionalU32Text(writer, run.color);
1015     try writer.print(
1016         " base_configurations={d} run_configurations={d} " ++
1017             "base_configuration_conflicts={d} run_configuration_conflicts={d}",
1018         .{
1019             baseline.configurations,
1020             run.configurations,
1021             baseline.configuration_conflicts,
1022             run.configuration_conflicts,
1023         },
1024     );
1025     try writer.writeByte('\n');
1026 }
1027 
1028 fn writePlotDistributionTextRow(writer: *std.Io.Writer, row: PlotDelta) !void {
1029     const baseline = plotOrEmpty(row.baseline, row.name);
1030     const run = plotOrEmpty(row.run, row.name);
1031     try writer.print(
1032         "plot-distribution status={s} evidence={s} unit_compatibility={s} name=",
1033         .{ row.status.tag(), row.evidence(), row.compatibility.tag() },
1034     );
1035     try pretty_json.writeString(writer, row.name);
1036     inline for (std.meta.tags(PlotMetric)) |metric| {
1037         try writePlotMetricText(
1038             writer,
1039             metric.tag(),
1040             metric.value(baseline),
1041             metric.value(run),
1042             row.delta(metric),
1043         );
1044     }
1045     try writer.writeByte('\n');
1046 }
1047 
1048 fn writePlotMetricText(
1049     writer: *std.Io.Writer,
1050     label: []const u8,
1051     baseline: f64,
1052     run: f64,
1053     delta: ?f64,
1054 ) !void {
1055     try writer.print(" base_{s}={d} run_{s}={d} delta_{s}=", .{
1056         label,
1057         baseline,
1058         label,
1059         run,
1060         label,
1061     });
1062     try writeOptionalF64Text(writer, delta);
1063 }
1064 
1065 fn writeZoneText(
1066     baseline: *summary.Analyzer,
1067     run: *summary.Analyzer,
1068     writer: *std.Io.Writer,
1069     rows: []const ZoneDelta,
1070     top: usize,
1071 ) !void {
1072     const counts = countStatuses(rows);
1073     try writer.print(
1074         "tracy compare zones shared={d} changed={d} stable={d} new={d} removed={d} " ++
1075             "partial={d} baseline_duration_evidence={s} run_duration_evidence={s} " ++
1076             "baseline_duration_samples={d} run_duration_samples={d} " ++
1077             "baseline_timestamp_regressions={d} run_timestamp_regressions={d} " ++
1078             "baseline_duration_ns={d} run_duration_ns={d} delta_duration_ns={d}\n",
1079         .{
1080             counts.shared,
1081             counts.changed,
1082             counts.stable,
1083             counts.new,
1084             counts.removed,
1085             zonePartialCount(rows),
1086             baseline.zoneDurationEvidence(),
1087             run.zoneDurationEvidence(),
1088             baseline.counters.zone_duration_samples,
1089             run.counters.zone_duration_samples,
1090             baseline.counters.zone_timestamp_regressions,
1091             run.counters.zone_timestamp_regressions,
1092             baseline.durationNs(),
1093             run.durationNs(),
1094             signedDelta(baseline.durationNs(), run.durationNs()),
1095         },
1096     );
1097     for (rows[0..@min(top, rows.len)]) |zone| try writeZoneTextRow(writer, zone);
1098 }
1099 
1100 fn writeZoneTextRow(writer: *std.Io.Writer, zone: ZoneDelta) !void {
1101     try writer.print("zone status={s} evidence={s} name=", .{
1102         zone.status.tag(),
1103         zone.evidence(),
1104     });
1105     try pretty_json.writeString(writer, zone.name);
1106     try writer.print(
1107         " base_count={d} run_count={d} " ++
1108             "base_duration_samples={d} run_duration_samples={d}",
1109         .{
1110             zone.baseline.count,
1111             zone.run.count,
1112             zone.baseline.duration_samples,
1113             zone.run.duration_samples,
1114         },
1115     );
1116     inline for (std.meta.tags(ZoneMetric)) |metric| {
1117         try writeZoneMetricText(writer, metric, zone);
1118     }
1119     try writer.writeByte('\n');
1120 }
1121 
1122 fn writeZoneMetricText(
1123     writer: *std.Io.Writer,
1124     metric: ZoneMetric,
1125     zone: ZoneDelta,
1126 ) !void {
1127     try writer.print(" base_{s}_ns={d} run_{s}_ns={d} delta_{s}_ns={d}", .{
1128         metric.tag(),
1129         zone.baseline.value(metric),
1130         metric.tag(),
1131         zone.run.value(metric),
1132         metric.tag(),
1133         zone.delta(metric),
1134     });
1135 }
1136 
1137 fn writeMemoryText(
1138     baseline: *summary.Analyzer,
1139     run: *summary.Analyzer,
1140     writer: *std.Io.Writer,
1141     rows: []const MemoryDelta,
1142     top: usize,
1143 ) !void {
1144     const counts = countStatuses(rows);
1145     try writer.print(
1146         "tracy compare memory shared={d} changed={d} stable={d} new={d} removed={d} " ++
1147             "partial={d} baseline_lifetime_evidence={s} run_lifetime_evidence={s}\n",
1148         .{
1149             counts.shared,
1150             counts.changed,
1151             counts.stable,
1152             counts.new,
1153             counts.removed,
1154             memoryPartialCount(rows),
1155             baseline.memory.lifetimeEvidence(),
1156             run.memory.lifetimeEvidence(),
1157         },
1158     );
1159     for (rows[0..@min(top, rows.len)]) |item| try writeMemoryTextRow(writer, item);
1160 }
1161 
1162 fn writeMemoryTextRow(writer: *std.Io.Writer, item: MemoryDelta) !void {
1163     try writer.print("memory status={s} name=", .{item.status.tag()});
1164     try pretty_json.writeString(writer, item.name);
1165     try writer.print(" evidence={s}\n", .{item.evidence()});
1166     try writeMemoryBytesText(writer, item);
1167     try writeMemoryLifetimesText(writer, item);
1168     try writeMemoryIntegrityText(writer, item);
1169 }
1170 
1171 fn writeMemoryBytesText(writer: *std.Io.Writer, item: MemoryDelta) !void {
1172     try writer.print(
1173         "  bytes base_high_water_live_bytes={d} run_high_water_live_bytes={d} " ++
1174             "delta_high_water_live_bytes={d} base_live_bytes={d} run_live_bytes={d} " ++
1175             "delta_live_bytes={d} base_allocated_bytes={d} run_allocated_bytes={d} " ++
1176             "delta_allocated_bytes={d} base_freed_bytes={d} run_freed_bytes={d} " ++
1177             "delta_freed_bytes={d}\n",
1178         .{
1179             item.baseline.high_water_live_bytes,
1180             item.run.high_water_live_bytes,
1181             signedDelta(item.baseline.high_water_live_bytes, item.run.high_water_live_bytes),
1182             item.baseline.live_bytes,
1183             item.run.live_bytes,
1184             signedDelta(item.baseline.live_bytes, item.run.live_bytes),
1185             item.baseline.allocated_bytes,
1186             item.run.allocated_bytes,
1187             signedDelta(item.baseline.allocated_bytes, item.run.allocated_bytes),
1188             item.baseline.freed_bytes,
1189             item.run.freed_bytes,
1190             signedDelta(item.baseline.freed_bytes, item.run.freed_bytes),
1191         },
1192     );
1193 }
1194 
1195 fn writeMemoryLifetimesText(writer: *std.Io.Writer, item: MemoryDelta) !void {
1196     try writer.print(
1197         "  lifetimes base_completed={d} run_completed={d} base_samples={d} run_samples={d} " ++
1198             "base_total_ns={d} run_total_ns={d} delta_total_ns={d} base_mean_ns={d} " ++
1199             "run_mean_ns={d} delta_mean_ns={d} base_min_ns={d} run_min_ns={d} " ++
1200             "delta_min_ns={d} base_p50_ns={d} run_p50_ns={d} delta_p50_ns={d} " ++
1201             "base_p90_ns={d} run_p90_ns={d} delta_p90_ns={d} base_p99_ns={d} " ++
1202             "run_p99_ns={d} delta_p99_ns={d} base_max_ns={d} run_max_ns={d} " ++
1203             "delta_max_ns={d}\n",
1204         .{
1205             item.baseline.completed_lifetimes,
1206             item.run.completed_lifetimes,
1207             item.baseline.lifetime_samples,
1208             item.run.lifetime_samples,
1209             item.baseline.total_lifetime_ns,
1210             item.run.total_lifetime_ns,
1211             signedDelta(item.baseline.total_lifetime_ns, item.run.total_lifetime_ns),
1212             item.baseline.mean_lifetime_ns,
1213             item.run.mean_lifetime_ns,
1214             signedDelta(item.baseline.mean_lifetime_ns, item.run.mean_lifetime_ns),
1215             item.baseline.min_lifetime_ns,
1216             item.run.min_lifetime_ns,
1217             signedDelta(item.baseline.min_lifetime_ns, item.run.min_lifetime_ns),
1218             item.baseline.p50_lifetime_ns,
1219             item.run.p50_lifetime_ns,
1220             signedDelta(item.baseline.p50_lifetime_ns, item.run.p50_lifetime_ns),
1221             item.baseline.p90_lifetime_ns,
1222             item.run.p90_lifetime_ns,
1223             signedDelta(item.baseline.p90_lifetime_ns, item.run.p90_lifetime_ns),
1224             item.baseline.p99_lifetime_ns,
1225             item.run.p99_lifetime_ns,
1226             signedDelta(item.baseline.p99_lifetime_ns, item.run.p99_lifetime_ns),
1227             item.baseline.max_lifetime_ns,
1228             item.run.max_lifetime_ns,
1229             signedDelta(item.baseline.max_lifetime_ns, item.run.max_lifetime_ns),
1230         },
1231     );
1232 }
1233 
1234 fn writeMemoryIntegrityText(writer: *std.Io.Writer, item: MemoryDelta) !void {
1235     try writer.print(
1236         "  integrity baseline_evidence={s} run_evidence={s} base_allocations={d} " ++
1237             "run_allocations={d} base_frees={d} run_frees={d} base_right_censored={d} " ++
1238             "run_right_censored={d} base_unmatched_frees={d} run_unmatched_frees={d} " ++
1239             "base_duplicate_allocations={d} run_duplicate_allocations={d} " ++
1240             "base_timestamp_regressions={d} run_timestamp_regressions={d} " ++
1241             "base_untracked_allocations={d} run_untracked_allocations={d} " ++
1242             "base_size_mismatches={d} run_size_mismatches={d}\n",
1243         .{
1244             item.baseline_lifetime_evidence,
1245             item.run_lifetime_evidence,
1246             item.baseline.allocations,
1247             item.run.allocations,
1248             item.baseline.frees,
1249             item.run.frees,
1250             item.baseline.right_censored_allocations,
1251             item.run.right_censored_allocations,
1252             item.baseline.unmatched_frees,
1253             item.run.unmatched_frees,
1254             item.baseline.duplicate_allocations,
1255             item.run.duplicate_allocations,
1256             item.baseline.timestamp_regressions,
1257             item.run.timestamp_regressions,
1258             item.baseline.untracked_allocations,
1259             item.run.untracked_allocations,
1260             item.baseline.size_mismatches,
1261             item.run.size_mismatches,
1262         },
1263     );
1264 }
1265 
1266 fn writeGpuText(
1267     baseline: *summary.Analyzer,
1268     run: *summary.Analyzer,
1269     writer: *std.Io.Writer,
1270     rows: []const GpuDelta,
1271     top: usize,
1272 ) !void {
1273     const counts = countStatuses(rows);
1274     try writer.print(
1275         "tracy compare gpu shared={d} changed={d} stable={d} new={d} removed={d} " ++
1276             "partial={d} baseline_incomplete_zones={d} run_incomplete_zones={d} " ++
1277             "baseline_unmatched_ends={d} run_unmatched_ends={d} " ++
1278             "baseline_unmatched_times={d} run_unmatched_times={d}\n",
1279         .{
1280             counts.shared,
1281             counts.changed,
1282             counts.stable,
1283             counts.new,
1284             counts.removed,
1285             gpuPartialCount(rows),
1286             baseline.gpu.incompleteZoneCount(),
1287             run.gpu.incompleteZoneCount(),
1288             baseline.gpu.counters.unmatched_ends,
1289             run.gpu.counters.unmatched_ends,
1290             baseline.gpu.counters.unmatched_times,
1291             run.gpu.counters.unmatched_times,
1292         },
1293     );
1294     for (rows[0..@min(top, rows.len)]) |row| try writeGpuTextRow(writer, row);
1295 }
1296 
1297 fn writeGpuTextRow(writer: *std.Io.Writer, row: GpuDelta) !void {
1298     try writer.print("gpu status={s} evidence={s} name=", .{
1299         row.status.tag(),
1300         row.evidence(),
1301     });
1302     try pretty_json.writeString(writer, row.name);
1303     try writer.print(
1304         " base_total_gpu_ns={d} run_total_gpu_ns={d} delta_total_gpu_ns={d} " ++
1305             "base_mean_gpu_ns={d} run_mean_gpu_ns={d} delta_mean_gpu_ns={d} " ++
1306             "base_count={d} run_count={d} base_completed_zones={d} " ++
1307             "run_completed_zones={d}\n",
1308         .{
1309             row.base_total_gpu_ns,
1310             row.run_total_gpu_ns,
1311             row.deltaTotalGpuNs(),
1312             row.base_mean_gpu_ns,
1313             row.run_mean_gpu_ns,
1314             row.deltaMeanGpuNs(),
1315             row.base_count,
1316             row.run_count,
1317             row.base_completed_zones,
1318             row.run_completed_zones,
1319         },
1320     );
1321 }
1322 
1323 pub fn writeJsonl(
1324     allocator: std.mem.Allocator,
1325     baseline: *Analyzer,
1326     run: *Analyzer,
1327     writer: *std.Io.Writer,
1328     options: Options,
1329 ) !void {
1330     var evidence = try Evidence.init(allocator, baseline, run, options);
1331     defer evidence.deinit(allocator);
1332     try writeComparisonSummaryJson(baseline, run, writer, evidence, options);
1333     try writeFrameJson(writer, evidence.frames.deltas.items, options.top);
1334     try writePlotJson(writer, evidence.plots.deltas.items, options.top);
1335     try writeZoneJson(writer, evidence.zones.items, options.top);
1336     try writeMemoryJson(writer, evidence.memory.deltas.items, options.top);
1337     try writeGpuJson(writer, evidence.gpu.deltas.items, options.top);
1338 }
1339 
1340 fn writeComparisonSummaryJson(
1341     baseline: *Analyzer,
1342     run: *Analyzer,
1343     writer: *std.Io.Writer,
1344     evidence: Evidence,
1345     options: Options,
1346 ) !void {
1347     var stream = pretty_json.Writer.init(writer, .minified);
1348     const object = try stream.object();
1349     try object.field("schema", schema);
1350     try object.field("kind", "summary");
1351     try writeComparisonCountFields(object, evidence);
1352     try writePlotCountFields(object, baseline, run, evidence.plots.deltas.items);
1353     try writeZoneEvidenceFields(object, baseline, run);
1354     try object.field("baseline_duration_ns", baseline.summary.durationNs());
1355     try object.field("run_duration_ns", run.summary.durationNs());
1356     try object.field(
1357         "delta_duration_ns",
1358         signedDelta(baseline.summary.durationNs(), run.summary.durationNs()),
1359     );
1360     try object.field("frame_budget_ns", options.frame_budget_ns);
1361     try writeComparisonIntegrityFields(object, baseline, run);
1362     try object.endLine();
1363 }
1364 
1365 fn writeZoneEvidenceFields(
1366     object: pretty_json.Object,
1367     baseline: *Analyzer,
1368     run: *Analyzer,
1369 ) !void {
1370     try object.field(
1371         "baseline_zone_duration_samples",
1372         baseline.summary.counters.zone_duration_samples,
1373     );
1374     try object.field(
1375         "run_zone_duration_samples",
1376         run.summary.counters.zone_duration_samples,
1377     );
1378     try object.field(
1379         "baseline_zone_timestamp_regressions",
1380         baseline.summary.counters.zone_timestamp_regressions,
1381     );
1382     try object.field(
1383         "run_zone_timestamp_regressions",
1384         run.summary.counters.zone_timestamp_regressions,
1385     );
1386     try object.field(
1387         "baseline_zone_duration_evidence",
1388         baseline.summary.zoneDurationEvidence(),
1389     );
1390     try object.field("run_zone_duration_evidence", run.summary.zoneDurationEvidence());
1391 }
1392 
1393 fn writeComparisonCountFields(object: pretty_json.Object, evidence: Evidence) !void {
1394     const frame_counts = countStatuses(evidence.frames.deltas.items);
1395     const zone_counts = countStatuses(evidence.zones.items);
1396     const memory_counts = countStatuses(evidence.memory.deltas.items);
1397     const gpu_counts = countStatuses(evidence.gpu.deltas.items);
1398     try object.field("frame_shared", frame_counts.shared);
1399     try object.field("frame_changed", frame_counts.changed);
1400     try object.field("frame_stable", frame_counts.stable);
1401     try object.field("frame_new", frame_counts.new);
1402     try object.field("frame_removed", frame_counts.removed);
1403     try object.field("frame_partial", framePartialCount(evidence.frames.deltas.items));
1404     try object.field("zone_shared", zone_counts.shared);
1405     try object.field("zone_changed", zone_counts.changed);
1406     try object.field("zone_stable", zone_counts.stable);
1407     try object.field("zone_new", zone_counts.new);
1408     try object.field("zone_removed", zone_counts.removed);
1409     try object.field("zone_partial", zonePartialCount(evidence.zones.items));
1410     try object.field("memory_shared", memory_counts.shared);
1411     try object.field("memory_changed", memory_counts.changed);
1412     try object.field("memory_stable", memory_counts.stable);
1413     try object.field("memory_new", memory_counts.new);
1414     try object.field("memory_removed", memory_counts.removed);
1415     try object.field("memory_partial", memoryPartialCount(evidence.memory.deltas.items));
1416     try object.field("gpu_shared", gpu_counts.shared);
1417     try object.field("gpu_changed", gpu_counts.changed);
1418     try object.field("gpu_stable", gpu_counts.stable);
1419     try object.field("gpu_new", gpu_counts.new);
1420     try object.field("gpu_removed", gpu_counts.removed);
1421     try object.field("gpu_partial", gpuPartialCount(evidence.gpu.deltas.items));
1422 }
1423 
1424 fn writePlotCountFields(
1425     object: pretty_json.Object,
1426     baseline: *Analyzer,
1427     run: *Analyzer,
1428     rows: []const PlotDelta,
1429 ) !void {
1430     const counts = countStatuses(rows);
1431     try object.field("plot_shared", counts.shared);
1432     try object.field("plot_changed", counts.changed);
1433     try object.field("plot_stable", counts.stable);
1434     try object.field("plot_new", counts.new);
1435     try object.field("plot_removed", counts.removed);
1436     try object.field("plot_partial", plotPartialCount(rows));
1437     try object.field("plot_unit_mismatch", plotCompatibilityCount(rows, .mismatch));
1438     try object.field("plot_unit_unknown", plotCompatibilityCount(rows, .unknown));
1439     try object.field("baseline_plot_samples", baseline.summary.plots.counters.samples);
1440     try object.field("run_plot_samples", run.summary.plots.counters.samples);
1441     try object.field(
1442         "baseline_plot_configurations",
1443         baseline.summary.plots.counters.configurations,
1444     );
1445     try object.field(
1446         "run_plot_configurations",
1447         run.summary.plots.counters.configurations,
1448     );
1449     try object.field(
1450         "baseline_plot_configuration_conflicts",
1451         baseline.summary.plots.counters.configuration_conflicts,
1452     );
1453     try object.field(
1454         "run_plot_configuration_conflicts",
1455         run.summary.plots.counters.configuration_conflicts,
1456     );
1457     try object.field(
1458         "baseline_plot_ignored_missing_values",
1459         baseline.summary.plots.counters.ignored_missing_values,
1460     );
1461     try object.field(
1462         "run_plot_ignored_missing_values",
1463         run.summary.plots.counters.ignored_missing_values,
1464     );
1465     try object.field(
1466         "baseline_plot_ignored_nonfinite",
1467         baseline.summary.plots.counters.ignored_nonfinite,
1468     );
1469     try object.field(
1470         "run_plot_ignored_nonfinite",
1471         run.summary.plots.counters.ignored_nonfinite,
1472     );
1473 }
1474 
1475 fn writeComparisonIntegrityFields(
1476     object: pretty_json.Object,
1477     baseline: *Analyzer,
1478     run: *Analyzer,
1479 ) !void {
1480     const base_integrity = baseline.summary.captureIntegrity();
1481     const run_integrity = run.summary.captureIntegrity();
1482     try object.field("baseline_capture_integrity", base_integrity.status);
1483     try object.field("run_capture_integrity", run_integrity.status);
1484     try object.field("baseline_unbalanced_events", base_integrity.unbalanced_event_count);
1485     try object.field("run_unbalanced_events", run_integrity.unbalanced_event_count);
1486     try object.field(
1487         "baseline_frame_out_of_order_marks",
1488         baseline.frames.counters.out_of_order_marks,
1489     );
1490     try object.field(
1491         "run_frame_out_of_order_marks",
1492         run.frames.counters.out_of_order_marks,
1493     );
1494     try object.field(
1495         "baseline_gpu_incomplete_zones",
1496         baseline.summary.gpu.incompleteZoneCount(),
1497     );
1498     try object.field(
1499         "run_gpu_incomplete_zones",
1500         run.summary.gpu.incompleteZoneCount(),
1501     );
1502     try object.field(
1503         "baseline_gpu_unmatched_ends",
1504         baseline.summary.gpu.counters.unmatched_ends,
1505     );
1506     try object.field(
1507         "run_gpu_unmatched_ends",
1508         run.summary.gpu.counters.unmatched_ends,
1509     );
1510     try object.field(
1511         "baseline_gpu_unmatched_times",
1512         baseline.summary.gpu.counters.unmatched_times,
1513     );
1514     try object.field(
1515         "run_gpu_unmatched_times",
1516         run.summary.gpu.counters.unmatched_times,
1517     );
1518 }
1519 
1520 fn writeFrameJson(writer: *std.Io.Writer, rows: []const FrameDelta, top: usize) !void {
1521     for (rows[0..@min(top, rows.len)]) |row| {
1522         try writeFrameJsonRow(writer, row);
1523         if (row.budget_ns != null) try writeFrameBudgetJsonRow(writer, row);
1524     }
1525 }
1526 
1527 fn writeFrameJsonRow(writer: *std.Io.Writer, row: FrameDelta) !void {
1528     var stream = pretty_json.Writer.init(writer, .minified);
1529     const object = try stream.object();
1530     try writeFrameFields(object, "frame", row);
1531     try object.field("base_marks", row.base_marks);
1532     try object.field("run_marks", row.run_marks);
1533     try object.field("delta_marks", signedDelta(row.base_marks, row.run_marks));
1534     try object.field("base_frames", row.base_frames);
1535     try object.field("run_frames", row.run_frames);
1536     try object.field("delta_frames", signedDelta(row.base_frames, row.run_frames));
1537     try object.field("base_mean_ns", row.base_mean_ns);
1538     try object.field("run_mean_ns", row.run_mean_ns);
1539     try object.field("delta_mean_ns", signedDelta(row.base_mean_ns, row.run_mean_ns));
1540     try object.field("base_p50_ns", row.base_p50_ns);
1541     try object.field("run_p50_ns", row.run_p50_ns);
1542     try object.field("delta_p50_ns", signedDelta(row.base_p50_ns, row.run_p50_ns));
1543     try object.field("base_p90_ns", row.base_p90_ns);
1544     try object.field("run_p90_ns", row.run_p90_ns);
1545     try object.field("delta_p90_ns", signedDelta(row.base_p90_ns, row.run_p90_ns));
1546     try object.field("base_p99_ns", row.base_p99_ns);
1547     try object.field("run_p99_ns", row.run_p99_ns);
1548     try object.field("delta_p99_ns", signedDelta(row.base_p99_ns, row.run_p99_ns));
1549     try object.field("base_max_ns", row.base_max_ns);
1550     try object.field("run_max_ns", row.run_max_ns);
1551     try object.field("delta_max_ns", signedDelta(row.base_max_ns, row.run_max_ns));
1552     try object.field("base_discrepancy_ns", row.base_discrepancy_ns);
1553     try object.field("run_discrepancy_ns", row.run_discrepancy_ns);
1554     try object.field(
1555         "delta_discrepancy_ns",
1556         signedDelta(row.base_discrepancy_ns, row.run_discrepancy_ns),
1557     );
1558     try object.endLine();
1559 }
1560 
1561 fn writeFrameBudgetJsonRow(writer: *std.Io.Writer, row: FrameDelta) !void {
1562     var stream = pretty_json.Writer.init(writer, .minified);
1563     const object = try stream.object();
1564     try writeFrameFields(object, "frame_budget", row);
1565     try object.field("budget_ns", row.budget_ns.?);
1566     try object.field("base_over_budget_frames", row.base_over_budget_frames);
1567     try object.field("run_over_budget_frames", row.run_over_budget_frames);
1568     try object.field(
1569         "delta_over_budget_frames",
1570         signedDelta(row.base_over_budget_frames, row.run_over_budget_frames),
1571     );
1572     try object.field("base_over_budget_per_mille", row.base_over_budget_per_mille);
1573     try object.field("run_over_budget_per_mille", row.run_over_budget_per_mille);
1574     try object.field(
1575         "delta_over_budget_per_mille",
1576         signedDelta(row.base_over_budget_per_mille, row.run_over_budget_per_mille),
1577     );
1578     try object.field("base_missed_intervals", row.base_missed_intervals);
1579     try object.field("run_missed_intervals", row.run_missed_intervals);
1580     try object.field(
1581         "delta_missed_intervals",
1582         signedDelta(row.base_missed_intervals, row.run_missed_intervals),
1583     );
1584     try object.field("base_missed_per_1000_frames", row.base_missed_per_1000_frames);
1585     try object.field("run_missed_per_1000_frames", row.run_missed_per_1000_frames);
1586     try object.field(
1587         "delta_missed_per_1000_frames",
1588         signedDelta(row.base_missed_per_1000_frames, row.run_missed_per_1000_frames),
1589     );
1590     try object.field("base_total_overrun_ns", row.base_total_overrun_ns);
1591     try object.field("run_total_overrun_ns", row.run_total_overrun_ns);
1592     try object.field(
1593         "delta_total_overrun_ns",
1594         signedDelta(row.base_total_overrun_ns, row.run_total_overrun_ns),
1595     );
1596     try object.field("base_max_overrun_ns", row.base_max_overrun_ns);
1597     try object.field("run_max_overrun_ns", row.run_max_overrun_ns);
1598     try object.field(
1599         "delta_max_overrun_ns",
1600         signedDelta(row.base_max_overrun_ns, row.run_max_overrun_ns),
1601     );
1602     try object.field("base_longest_overrun_streak", row.base_longest_overrun_streak);
1603     try object.field("run_longest_overrun_streak", row.run_longest_overrun_streak);
1604     try object.field(
1605         "delta_longest_overrun_streak",
1606         signedDelta(row.base_longest_overrun_streak, row.run_longest_overrun_streak),
1607     );
1608     try object.endLine();
1609 }
1610 
1611 fn writeFrameFields(object: pretty_json.Object, kind: []const u8, row: FrameDelta) !void {
1612     try object.field("schema", schema);
1613     try object.field("kind", kind);
1614     try object.field("status", row.status.tag());
1615     try object.field("evidence", row.evidence());
1616     try object.field("name", row.name);
1617 }
1618 
1619 fn writePlotJson(writer: *std.Io.Writer, rows: []const PlotDelta, top: usize) !void {
1620     for (rows[0..@min(top, rows.len)]) |row| {
1621         const baseline = plotOrEmpty(row.baseline, row.name);
1622         const run = plotOrEmpty(row.run, row.name);
1623         var stream = pretty_json.Writer.init(writer, .minified);
1624         const object = try stream.object();
1625         try object.field("schema", schema);
1626         try object.field("kind", "plot");
1627         try object.field("status", row.status.tag());
1628         try object.field("evidence", row.evidence());
1629         try object.field("unit_compatibility", row.compatibility.tag());
1630         try object.field("name", row.name);
1631         try writePlotDefinitionFields(object, row);
1632         try object.field("base_samples", baseline.count);
1633         try object.field("run_samples", run.count);
1634         try object.field("delta_samples", signedDelta(baseline.count, run.count));
1635         try object.field("base_threads", baseline.threads);
1636         try object.field("run_threads", run.threads);
1637         try object.field("delta_threads", signedDelta(baseline.threads, run.threads));
1638         try object.field("base_duration_ns", baseline.duration_ns);
1639         try object.field("run_duration_ns", run.duration_ns);
1640         try object.field(
1641             "delta_duration_ns",
1642             signedDelta(baseline.duration_ns, run.duration_ns),
1643         );
1644         try writePlotDistributionFields(object, row);
1645         try object.endLine();
1646     }
1647 }
1648 
1649 fn writePlotDefinitionFields(object: pretty_json.Object, row: PlotDelta) !void {
1650     const baseline = plotOrEmpty(row.baseline, row.name);
1651     const run = plotOrEmpty(row.run, row.name);
1652     try object.field("base_present", row.baseline != null);
1653     try object.field("run_present", row.run != null);
1654     try object.field("base_kind", baseline.kind);
1655     try object.field("run_kind", run.kind);
1656     try object.field("base_unit", baseline.unit);
1657     try object.field("run_unit", run.unit);
1658     try object.field("base_configured", baseline.configured);
1659     try object.field("run_configured", run.configured);
1660     try object.field("base_step", baseline.step);
1661     try object.field("run_step", run.step);
1662     try object.field("base_fill", baseline.fill);
1663     try object.field("run_fill", run.fill);
1664     try object.field("base_color", baseline.color);
1665     try object.field("run_color", run.color);
1666     try object.field("base_configurations", baseline.configurations);
1667     try object.field("run_configurations", run.configurations);
1668     try object.field("base_configuration_conflicts", baseline.configuration_conflicts);
1669     try object.field("run_configuration_conflicts", run.configuration_conflicts);
1670 }
1671 
1672 fn writePlotDistributionFields(object: pretty_json.Object, row: PlotDelta) !void {
1673     const baseline = plotOrEmpty(row.baseline, row.name);
1674     const run = plotOrEmpty(row.run, row.name);
1675     inline for (std.meta.tags(PlotMetric)) |metric| {
1676         try writePlotMetricFields(
1677             object,
1678             metric.tag(),
1679             metric.value(baseline),
1680             metric.value(run),
1681             row.delta(metric),
1682         );
1683     }
1684 }
1685 
1686 fn writePlotMetricFields(
1687     object: pretty_json.Object,
1688     label: []const u8,
1689     baseline: f64,
1690     run: f64,
1691     delta: ?f64,
1692 ) !void {
1693     try object.fieldParts(&.{ "base_", label }, baseline);
1694     try object.fieldParts(&.{ "run_", label }, run);
1695     try object.fieldParts(&.{ "delta_", label }, delta);
1696 }
1697 
1698 fn writeZoneJson(writer: *std.Io.Writer, rows: []const ZoneDelta, top: usize) !void {
1699     for (rows[0..@min(top, rows.len)]) |zone| {
1700         var stream = pretty_json.Writer.init(writer, .minified);
1701         const object = try stream.object();
1702         try object.field("schema", schema);
1703         try object.field("kind", "zone");
1704         try object.field("status", zone.status.tag());
1705         try object.field("evidence", zone.evidence());
1706         try object.field("name", zone.name);
1707         try object.field("base_count", zone.baseline.count);
1708         try object.field("run_count", zone.run.count);
1709         try object.field("base_duration_samples", zone.baseline.duration_samples);
1710         try object.field("run_duration_samples", zone.run.duration_samples);
1711         inline for (std.meta.tags(ZoneMetric)) |metric| {
1712             try writeZoneMetricFields(object, metric, zone);
1713         }
1714         try object.endLine();
1715     }
1716 }
1717 
1718 fn writeZoneMetricFields(
1719     object: pretty_json.Object,
1720     metric: ZoneMetric,
1721     zone: ZoneDelta,
1722 ) !void {
1723     try object.fieldParts(
1724         &.{ "base_", metric.tag(), "_ns" },
1725         zone.baseline.value(metric),
1726     );
1727     try object.fieldParts(
1728         &.{ "run_", metric.tag(), "_ns" },
1729         zone.run.value(metric),
1730     );
1731     try object.fieldParts(&.{ "delta_", metric.tag(), "_ns" }, zone.delta(metric));
1732 }
1733 
1734 fn writeDeltaFields(
1735     object: pretty_json.Object,
1736     label: []const u8,
1737     baseline: anytype,
1738     run: anytype,
1739     delta: anytype,
1740 ) !void {
1741     try object.fieldParts(&.{ "base_", label }, baseline);
1742     try object.fieldParts(&.{ "run_", label }, run);
1743     try object.fieldParts(&.{ "delta_", label }, delta);
1744 }
1745 
1746 fn writeMemoryJson(writer: *std.Io.Writer, rows: []const MemoryDelta, top: usize) !void {
1747     for (rows[0..@min(top, rows.len)]) |item| try writeMemoryJsonRow(writer, item);
1748 }
1749 
1750 fn writeMemoryJsonRow(writer: *std.Io.Writer, item: MemoryDelta) !void {
1751     var stream = pretty_json.Writer.init(writer, .minified);
1752     const object = try stream.object();
1753     try object.field("schema", schema);
1754     try object.field("kind", "memory");
1755     try object.field("status", item.status.tag());
1756     try object.field("evidence", item.evidence());
1757     try object.field("name", item.name);
1758     try writeMemoryByteFields(object, item);
1759     try writeMemoryLifetimeFields(object, item);
1760     try writeMemoryIntegrityFields(object, item);
1761     try object.endLine();
1762 }
1763 
1764 fn writeMemoryByteFields(object: pretty_json.Object, item: MemoryDelta) !void {
1765     try writeDeltaFields(
1766         object,
1767         "high_water_live_bytes",
1768         item.baseline.high_water_live_bytes,
1769         item.run.high_water_live_bytes,
1770         signedDelta(item.baseline.high_water_live_bytes, item.run.high_water_live_bytes),
1771     );
1772     try writeDeltaFields(
1773         object,
1774         "live_bytes",
1775         item.baseline.live_bytes,
1776         item.run.live_bytes,
1777         signedDelta(item.baseline.live_bytes, item.run.live_bytes),
1778     );
1779     try writeDeltaFields(
1780         object,
1781         "allocated_bytes",
1782         item.baseline.allocated_bytes,
1783         item.run.allocated_bytes,
1784         signedDelta(item.baseline.allocated_bytes, item.run.allocated_bytes),
1785     );
1786     try writeDeltaFields(
1787         object,
1788         "freed_bytes",
1789         item.baseline.freed_bytes,
1790         item.run.freed_bytes,
1791         signedDelta(item.baseline.freed_bytes, item.run.freed_bytes),
1792     );
1793 }
1794 
1795 fn writeMemoryLifetimeFields(object: pretty_json.Object, item: MemoryDelta) !void {
1796     try object.field("base_completed_lifetimes", item.baseline.completed_lifetimes);
1797     try object.field("run_completed_lifetimes", item.run.completed_lifetimes);
1798     try object.field("base_lifetime_samples", item.baseline.lifetime_samples);
1799     try object.field("run_lifetime_samples", item.run.lifetime_samples);
1800     try writeDeltaFields(
1801         object,
1802         "lifetime_total_ns",
1803         item.baseline.total_lifetime_ns,
1804         item.run.total_lifetime_ns,
1805         signedDelta(item.baseline.total_lifetime_ns, item.run.total_lifetime_ns),
1806     );
1807     try writeDeltaFields(
1808         object,
1809         "lifetime_mean_ns",
1810         item.baseline.mean_lifetime_ns,
1811         item.run.mean_lifetime_ns,
1812         signedDelta(item.baseline.mean_lifetime_ns, item.run.mean_lifetime_ns),
1813     );
1814     try writeDeltaFields(
1815         object,
1816         "lifetime_min_ns",
1817         item.baseline.min_lifetime_ns,
1818         item.run.min_lifetime_ns,
1819         signedDelta(item.baseline.min_lifetime_ns, item.run.min_lifetime_ns),
1820     );
1821     try writeDeltaFields(
1822         object,
1823         "lifetime_p50_ns",
1824         item.baseline.p50_lifetime_ns,
1825         item.run.p50_lifetime_ns,
1826         signedDelta(item.baseline.p50_lifetime_ns, item.run.p50_lifetime_ns),
1827     );
1828     try writeDeltaFields(
1829         object,
1830         "lifetime_p90_ns",
1831         item.baseline.p90_lifetime_ns,
1832         item.run.p90_lifetime_ns,
1833         signedDelta(item.baseline.p90_lifetime_ns, item.run.p90_lifetime_ns),
1834     );
1835     try writeDeltaFields(
1836         object,
1837         "lifetime_p99_ns",
1838         item.baseline.p99_lifetime_ns,
1839         item.run.p99_lifetime_ns,
1840         signedDelta(item.baseline.p99_lifetime_ns, item.run.p99_lifetime_ns),
1841     );
1842     try writeDeltaFields(
1843         object,
1844         "lifetime_max_ns",
1845         item.baseline.max_lifetime_ns,
1846         item.run.max_lifetime_ns,
1847         signedDelta(item.baseline.max_lifetime_ns, item.run.max_lifetime_ns),
1848     );
1849 }
1850 
1851 fn writeMemoryIntegrityFields(object: pretty_json.Object, item: MemoryDelta) !void {
1852     try object.field("baseline_lifetime_evidence", item.baseline_lifetime_evidence);
1853     try object.field("run_lifetime_evidence", item.run_lifetime_evidence);
1854     try object.field("base_allocations", item.baseline.allocations);
1855     try object.field("run_allocations", item.run.allocations);
1856     try object.field("base_frees", item.baseline.frees);
1857     try object.field("run_frees", item.run.frees);
1858     try object.field(
1859         "base_right_censored_allocations",
1860         item.baseline.right_censored_allocations,
1861     );
1862     try object.field(
1863         "run_right_censored_allocations",
1864         item.run.right_censored_allocations,
1865     );
1866     try object.field("base_unmatched_frees", item.baseline.unmatched_frees);
1867     try object.field("run_unmatched_frees", item.run.unmatched_frees);
1868     try object.field("base_duplicate_allocations", item.baseline.duplicate_allocations);
1869     try object.field("run_duplicate_allocations", item.run.duplicate_allocations);
1870     try object.field("base_timestamp_regressions", item.baseline.timestamp_regressions);
1871     try object.field("run_timestamp_regressions", item.run.timestamp_regressions);
1872     try object.field("base_untracked_allocations", item.baseline.untracked_allocations);
1873     try object.field("run_untracked_allocations", item.run.untracked_allocations);
1874     try object.field("base_size_mismatches", item.baseline.size_mismatches);
1875     try object.field("run_size_mismatches", item.run.size_mismatches);
1876 }
1877 
1878 fn writeGpuJson(writer: *std.Io.Writer, rows: []const GpuDelta, top: usize) !void {
1879     for (rows[0..@min(top, rows.len)]) |row| {
1880         var stream = pretty_json.Writer.init(writer, .minified);
1881         const object = try stream.object();
1882         try object.field("schema", schema);
1883         try object.field("kind", "gpu");
1884         try object.field("status", row.status.tag());
1885         try object.field("evidence", row.evidence());
1886         try object.field("name", row.name);
1887         try object.field("base_total_gpu_ns", row.base_total_gpu_ns);
1888         try object.field("run_total_gpu_ns", row.run_total_gpu_ns);
1889         try object.field("delta_total_gpu_ns", row.deltaTotalGpuNs());
1890         try object.field("base_mean_gpu_ns", row.base_mean_gpu_ns);
1891         try object.field("run_mean_gpu_ns", row.run_mean_gpu_ns);
1892         try object.field("delta_mean_gpu_ns", row.deltaMeanGpuNs());
1893         try object.field("base_count", row.base_count);
1894         try object.field("run_count", row.run_count);
1895         try object.field("base_completed_zones", row.base_completed_zones);
1896         try object.field("run_completed_zones", row.run_completed_zones);
1897         try object.endLine();
1898     }
1899 }
1900 
1901 fn collectFrameDeltas(
1902     allocator: std.mem.Allocator,
1903     baseline: []frame_mod.Summary,
1904     run: []frame_mod.Summary,
1905     partial: bool,
1906     options: Options,
1907 ) !std.ArrayListUnmanaged(FrameDelta) {
1908     var rows: std.ArrayListUnmanaged(FrameDelta) = .empty;
1909     errdefer rows.deinit(allocator);
1910     std.mem.sort(frame_mod.Summary, baseline, {}, frameSummaryNameLessThan);
1911     std.mem.sort(frame_mod.Summary, run, {}, frameSummaryNameLessThan);
1912     var base_index: usize = 0;
1913     var run_index: usize = 0;
1914     try mergeFrameDeltas(
1915         allocator,
1916         &rows,
1917         baseline,
1918         run,
1919         &base_index,
1920         &run_index,
1921         partial,
1922         options,
1923     );
1924     try appendFrameTails(
1925         allocator,
1926         &rows,
1927         baseline[base_index..],
1928         run[run_index..],
1929         partial,
1930         options,
1931     );
1932     std.mem.sort(FrameDelta, rows.items, {}, frameDeltaGreaterThan);
1933     return rows;
1934 }
1935 
1936 fn mergeFrameDeltas(
1937     allocator: std.mem.Allocator,
1938     rows: *std.ArrayListUnmanaged(FrameDelta),
1939     baseline: []const frame_mod.Summary,
1940     run: []const frame_mod.Summary,
1941     base_index: *usize,
1942     run_index: *usize,
1943     partial: bool,
1944     options: Options,
1945 ) !void {
1946     while (base_index.* < baseline.len and run_index.* < run.len) {
1947         const base_row = baseline[base_index.*];
1948         const run_row = run[run_index.*];
1949         if (std.mem.eql(u8, base_row.name, run_row.name)) {
1950             try appendFrameDelta(
1951                 allocator,
1952                 rows,
1953                 base_row.name,
1954                 base_row,
1955                 run_row,
1956                 partial,
1957                 options,
1958             );
1959             base_index.* += 1;
1960             run_index.* += 1;
1961         } else if (std.mem.lessThan(u8, base_row.name, run_row.name)) {
1962             try appendFrameDelta(
1963                 allocator,
1964                 rows,
1965                 base_row.name,
1966                 base_row,
1967                 null,
1968                 partial,
1969                 options,
1970             );
1971             base_index.* += 1;
1972         } else {
1973             try appendFrameDelta(
1974                 allocator,
1975                 rows,
1976                 run_row.name,
1977                 null,
1978                 run_row,
1979                 partial,
1980                 options,
1981             );
1982             run_index.* += 1;
1983         }
1984     }
1985 }
1986 
1987 fn appendFrameTails(
1988     allocator: std.mem.Allocator,
1989     rows: *std.ArrayListUnmanaged(FrameDelta),
1990     baseline: []const frame_mod.Summary,
1991     run: []const frame_mod.Summary,
1992     partial: bool,
1993     options: Options,
1994 ) !void {
1995     for (baseline) |row| {
1996         try appendFrameDelta(
1997             allocator,
1998             rows,
1999             row.name,
2000             row,
2001             null,
2002             partial,
2003             options,
2004         );
2005     }
2006     for (run) |row| {
2007         try appendFrameDelta(
2008             allocator,
2009             rows,
2010             row.name,
2011             null,
2012             row,
2013             partial,
2014             options,
2015         );
2016     }
2017 }
2018 
2019 fn appendFrameDelta(
2020     allocator: std.mem.Allocator,
2021     rows: *std.ArrayListUnmanaged(FrameDelta),
2022     name: []const u8,
2023     baseline: ?frame_mod.Summary,
2024     run: ?frame_mod.Summary,
2025     partial: bool,
2026     options: Options,
2027 ) !void {
2028     const row = frameDelta(name, baseline, run, partial, options.frame_budget_ns);
2029     if (includeFrame(row, options)) try rows.append(allocator, row);
2030 }
2031 
2032 fn frameSummaryNameLessThan(_: void, left: frame_mod.Summary, right: frame_mod.Summary) bool {
2033     return std.mem.lessThan(u8, left.name, right.name);
2034 }
2035 
2036 fn collectPlotDeltas(
2037     allocator: std.mem.Allocator,
2038     baseline: []plot_mod.Summary,
2039     run: []plot_mod.Summary,
2040     partial: bool,
2041     options: Options,
2042 ) !std.ArrayListUnmanaged(PlotDelta) {
2043     var rows: std.ArrayListUnmanaged(PlotDelta) = .empty;
2044     errdefer rows.deinit(allocator);
2045     std.mem.sort(plot_mod.Summary, baseline, {}, plotSummaryNameLessThan);
2046     std.mem.sort(plot_mod.Summary, run, {}, plotSummaryNameLessThan);
2047     var base_index: usize = 0;
2048     var run_index: usize = 0;
2049     while (base_index < baseline.len and run_index < run.len) {
2050         const base_row = baseline[base_index];
2051         const run_row = run[run_index];
2052         if (std.mem.eql(u8, base_row.name, run_row.name)) {
2053             try appendPlotDelta(
2054                 allocator,
2055                 &rows,
2056                 base_row.name,
2057                 base_row,
2058                 run_row,
2059                 partial,
2060                 options,
2061             );
2062             base_index += 1;
2063             run_index += 1;
2064         } else if (std.mem.lessThan(u8, base_row.name, run_row.name)) {
2065             try appendPlotDelta(allocator, &rows, base_row.name, base_row, null, partial, options);
2066             base_index += 1;
2067         } else {
2068             try appendPlotDelta(allocator, &rows, run_row.name, null, run_row, partial, options);
2069             run_index += 1;
2070         }
2071     }
2072     for (baseline[base_index..]) |row| {
2073         try appendPlotDelta(allocator, &rows, row.name, row, null, partial, options);
2074     }
2075     for (run[run_index..]) |row| {
2076         try appendPlotDelta(allocator, &rows, row.name, null, row, partial, options);
2077     }
2078     std.mem.sort(PlotDelta, rows.items, {}, plotDeltaGreaterThan);
2079     return rows;
2080 }
2081 
2082 fn appendPlotDelta(
2083     allocator: std.mem.Allocator,
2084     rows: *std.ArrayListUnmanaged(PlotDelta),
2085     name: []const u8,
2086     baseline: ?plot_mod.Summary,
2087     run: ?plot_mod.Summary,
2088     partial: bool,
2089     options: Options,
2090 ) !void {
2091     const row = plotDelta(name, baseline, run, partial);
2092     if (includePlot(row, options)) try rows.append(allocator, row);
2093 }
2094 
2095 fn plotSummaryNameLessThan(_: void, left: plot_mod.Summary, right: plot_mod.Summary) bool {
2096     return std.mem.lessThan(u8, left.name, right.name);
2097 }
2098 
2099 fn collectZoneDeltas(
2100     allocator: std.mem.Allocator,
2101     baseline: *summary.Analyzer,
2102     run: *summary.Analyzer,
2103     options: Options,
2104 ) !std.ArrayListUnmanaged(ZoneDelta) {
2105     var baseline_rows = try baseline.collectZones();
2106     defer baseline_rows.deinit(baseline.allocator);
2107     var run_rows = try run.collectZones();
2108     defer run_rows.deinit(run.allocator);
2109     std.mem.sort(summary.ZoneSummary, baseline_rows.items, {}, zoneSummaryNameLessThan);
2110     std.mem.sort(summary.ZoneSummary, run_rows.items, {}, zoneSummaryNameLessThan);
2111     return try correlateZoneDeltas(
2112         allocator,
2113         baseline_rows.items,
2114         run_rows.items,
2115         baseline.zoneDurationEvidence(),
2116         run.zoneDurationEvidence(),
2117         options,
2118     );
2119 }
2120 
2121 fn correlateZoneDeltas(
2122     allocator: std.mem.Allocator,
2123     baseline: []const summary.ZoneSummary,
2124     run: []const summary.ZoneSummary,
2125     baseline_evidence: []const u8,
2126     run_evidence: []const u8,
2127     options: Options,
2128 ) !std.ArrayListUnmanaged(ZoneDelta) {
2129     var rows: std.ArrayListUnmanaged(ZoneDelta) = .empty;
2130     errdefer rows.deinit(allocator);
2131     const indices = try appendOverlappingZoneDeltas(
2132         allocator,
2133         &rows,
2134         baseline,
2135         run,
2136         baseline_evidence,
2137         run_evidence,
2138         options,
2139     );
2140     for (baseline[indices.baseline..]) |row| try appendZoneDelta(
2141         allocator,
2142         &rows,
2143         row.name,
2144         row,
2145         null,
2146         baseline_evidence,
2147         run_evidence,
2148         options,
2149     );
2150     for (run[indices.run..]) |row| try appendZoneDelta(
2151         allocator,
2152         &rows,
2153         row.name,
2154         null,
2155         row,
2156         baseline_evidence,
2157         run_evidence,
2158         options,
2159     );
2160     std.mem.sort(ZoneDelta, rows.items, {}, zoneDeltaGreaterThan);
2161     return rows;
2162 }
2163 
2164 const ZoneIndices = struct {
2165     baseline: usize,
2166     run: usize,
2167 };
2168 
2169 fn appendOverlappingZoneDeltas(
2170     allocator: std.mem.Allocator,
2171     rows: *std.ArrayListUnmanaged(ZoneDelta),
2172     baseline: []const summary.ZoneSummary,
2173     run: []const summary.ZoneSummary,
2174     baseline_evidence: []const u8,
2175     run_evidence: []const u8,
2176     options: Options,
2177 ) !ZoneIndices {
2178     var baseline_index: usize = 0;
2179     var run_index: usize = 0;
2180     while (baseline_index < baseline.len and run_index < run.len) {
2181         const base_row = baseline[baseline_index];
2182         const run_row = run[run_index];
2183         if (std.mem.eql(u8, base_row.name, run_row.name)) {
2184             try appendZoneDelta(
2185                 allocator,
2186                 rows,
2187                 base_row.name,
2188                 base_row,
2189                 run_row,
2190                 baseline_evidence,
2191                 run_evidence,
2192                 options,
2193             );
2194             baseline_index += 1;
2195             run_index += 1;
2196         } else if (std.mem.lessThan(u8, base_row.name, run_row.name)) {
2197             try appendZoneDelta(
2198                 allocator,
2199                 rows,
2200                 base_row.name,
2201                 base_row,
2202                 null,
2203                 baseline_evidence,
2204                 run_evidence,
2205                 options,
2206             );
2207             baseline_index += 1;
2208         } else {
2209             try appendZoneDelta(
2210                 allocator,
2211                 rows,
2212                 run_row.name,
2213                 null,
2214                 run_row,
2215                 baseline_evidence,
2216                 run_evidence,
2217                 options,
2218             );
2219             run_index += 1;
2220         }
2221     }
2222     return .{ .baseline = baseline_index, .run = run_index };
2223 }
2224 
2225 fn appendZoneDelta(
2226     allocator: std.mem.Allocator,
2227     rows: *std.ArrayListUnmanaged(ZoneDelta),
2228     name: []const u8,
2229     baseline: ?summary.ZoneSummary,
2230     run: ?summary.ZoneSummary,
2231     baseline_evidence: []const u8,
2232     run_evidence: []const u8,
2233     options: Options,
2234 ) !void {
2235     const row = zoneDelta(name, baseline, run, baseline_evidence, run_evidence);
2236     if (includeZone(row, options)) try rows.append(allocator, row);
2237 }
2238 
2239 fn zoneSummaryNameLessThan(
2240     _: void,
2241     left: summary.ZoneSummary,
2242     right: summary.ZoneSummary,
2243 ) bool {
2244     return std.mem.lessThan(u8, left.name, right.name);
2245 }
2246 
2247 fn collectMemoryDeltas(
2248     allocator: std.mem.Allocator,
2249     baseline: []memory_mod.Summary,
2250     run: []memory_mod.Summary,
2251     baseline_evidence: []const u8,
2252     run_evidence: []const u8,
2253     options: Options,
2254 ) !std.ArrayListUnmanaged(MemoryDelta) {
2255     var rows: std.ArrayListUnmanaged(MemoryDelta) = .empty;
2256     errdefer rows.deinit(allocator);
2257     std.mem.sort(memory_mod.Summary, baseline, {}, memorySummaryNameLessThan);
2258     std.mem.sort(memory_mod.Summary, run, {}, memorySummaryNameLessThan);
2259     var base_index: usize = 0;
2260     var run_index: usize = 0;
2261     while (base_index < baseline.len and run_index < run.len) {
2262         const base_row = baseline[base_index];
2263         const run_row = run[run_index];
2264         if (std.mem.eql(u8, base_row.name, run_row.name)) {
2265             try appendMemoryDelta(
2266                 allocator,
2267                 &rows,
2268                 base_row.name,
2269                 base_row,
2270                 run_row,
2271                 baseline_evidence,
2272                 run_evidence,
2273                 options,
2274             );
2275             base_index += 1;
2276             run_index += 1;
2277         } else if (std.mem.lessThan(u8, base_row.name, run_row.name)) {
2278             try appendMemoryDelta(
2279                 allocator,
2280                 &rows,
2281                 base_row.name,
2282                 base_row,
2283                 null,
2284                 baseline_evidence,
2285                 run_evidence,
2286                 options,
2287             );
2288             base_index += 1;
2289         } else {
2290             try appendMemoryDelta(
2291                 allocator,
2292                 &rows,
2293                 run_row.name,
2294                 null,
2295                 run_row,
2296                 baseline_evidence,
2297                 run_evidence,
2298                 options,
2299             );
2300             run_index += 1;
2301         }
2302     }
2303     try appendMemoryTails(
2304         allocator,
2305         &rows,
2306         baseline[base_index..],
2307         run[run_index..],
2308         baseline_evidence,
2309         run_evidence,
2310         options,
2311     );
2312     std.mem.sort(MemoryDelta, rows.items, {}, memoryDeltaGreaterThan);
2313     return rows;
2314 }
2315 
2316 fn appendMemoryTails(
2317     allocator: std.mem.Allocator,
2318     rows: *std.ArrayListUnmanaged(MemoryDelta),
2319     baseline: []const memory_mod.Summary,
2320     run: []const memory_mod.Summary,
2321     baseline_evidence: []const u8,
2322     run_evidence: []const u8,
2323     options: Options,
2324 ) !void {
2325     for (baseline) |row| {
2326         try appendMemoryDelta(
2327             allocator,
2328             rows,
2329             row.name,
2330             row,
2331             null,
2332             baseline_evidence,
2333             run_evidence,
2334             options,
2335         );
2336     }
2337     for (run) |row| {
2338         try appendMemoryDelta(
2339             allocator,
2340             rows,
2341             row.name,
2342             null,
2343             row,
2344             baseline_evidence,
2345             run_evidence,
2346             options,
2347         );
2348     }
2349 }
2350 
2351 fn appendMemoryDelta(
2352     allocator: std.mem.Allocator,
2353     rows: *std.ArrayListUnmanaged(MemoryDelta),
2354     name: []const u8,
2355     baseline: ?memory_mod.Summary,
2356     run: ?memory_mod.Summary,
2357     baseline_evidence: []const u8,
2358     run_evidence: []const u8,
2359     options: Options,
2360 ) !void {
2361     const row = memoryDelta(name, baseline, run, baseline_evidence, run_evidence);
2362     if (includeMemory(row, options)) try rows.append(allocator, row);
2363 }
2364 
2365 fn memorySummaryNameLessThan(
2366     _: void,
2367     left: memory_mod.Summary,
2368     right: memory_mod.Summary,
2369 ) bool {
2370     return std.mem.lessThan(u8, left.name, right.name);
2371 }
2372 
2373 fn collectGpuDeltas(
2374     allocator: std.mem.Allocator,
2375     baseline: []gpu_mod.Summary,
2376     run: []gpu_mod.Summary,
2377     options: Options,
2378 ) !std.ArrayListUnmanaged(GpuDelta) {
2379     var rows: std.ArrayListUnmanaged(GpuDelta) = .empty;
2380     errdefer rows.deinit(allocator);
2381     std.mem.sort(gpu_mod.Summary, baseline, {}, gpuSummaryNameLessThan);
2382     std.mem.sort(gpu_mod.Summary, run, {}, gpuSummaryNameLessThan);
2383     var base_index: usize = 0;
2384     var run_index: usize = 0;
2385     while (base_index < baseline.len and run_index < run.len) {
2386         const base_row = baseline[base_index];
2387         const run_row = run[run_index];
2388         if (std.mem.eql(u8, base_row.label, run_row.label)) {
2389             try appendGpuDelta(allocator, &rows, base_row.label, base_row, run_row, options);
2390             base_index += 1;
2391             run_index += 1;
2392         } else if (std.mem.lessThan(u8, base_row.label, run_row.label)) {
2393             try appendGpuDelta(allocator, &rows, base_row.label, base_row, null, options);
2394             base_index += 1;
2395         } else {
2396             try appendGpuDelta(allocator, &rows, run_row.label, null, run_row, options);
2397             run_index += 1;
2398         }
2399     }
2400     while (base_index < baseline.len) : (base_index += 1) {
2401         const row = baseline[base_index];
2402         try appendGpuDelta(allocator, &rows, row.label, row, null, options);
2403     }
2404     while (run_index < run.len) : (run_index += 1) {
2405         const row = run[run_index];
2406         try appendGpuDelta(allocator, &rows, row.label, null, row, options);
2407     }
2408     std.mem.sort(GpuDelta, rows.items, {}, gpuDeltaGreaterThan);
2409     return rows;
2410 }
2411 
2412 fn appendGpuDelta(
2413     allocator: std.mem.Allocator,
2414     rows: *std.ArrayListUnmanaged(GpuDelta),
2415     name: []const u8,
2416     baseline: ?gpu_mod.Summary,
2417     run: ?gpu_mod.Summary,
2418     options: Options,
2419 ) !void {
2420     const row = gpuDelta(name, baseline, run);
2421     if (includeGpu(row, options)) try rows.append(allocator, row);
2422 }
2423 
2424 fn gpuSummaryNameLessThan(_: void, left: gpu_mod.Summary, right: gpu_mod.Summary) bool {
2425     return std.mem.lessThan(u8, left.label, right.label);
2426 }
2427 
2428 fn plotDelta(
2429     name: []const u8,
2430     baseline: ?plot_mod.Summary,
2431     run: ?plot_mod.Summary,
2432     capture_partial: bool,
2433 ) PlotDelta {
2434     var result = PlotDelta{
2435         .name = name,
2436         .status = .stable,
2437         .partial = capture_partial or plotConfigCaveat(baseline) or plotConfigCaveat(run),
2438         .compatibility = plotCompatibility(baseline, run),
2439         .baseline = baseline,
2440         .run = run,
2441     };
2442     result.status = plotStatusFor(result);
2443     return result;
2444 }
2445 
2446 fn plotCompatibility(
2447     baseline: ?plot_mod.Summary,
2448     run: ?plot_mod.Summary,
2449 ) PlotCompatibility {
2450     const base_row = baseline orelse return .not_applicable;
2451     const run_row = run orelse return .not_applicable;
2452     if (!base_row.configured or !run_row.configured) return .unknown;
2453     const base_unit = base_row.unit orelse return .unknown;
2454     const run_unit = run_row.unit orelse return .unknown;
2455     return if (std.mem.eql(u8, base_unit, run_unit)) .matching else .mismatch;
2456 }
2457 
2458 fn plotConfigCaveat(value: ?plot_mod.Summary) bool {
2459     const row = value orelse return false;
2460     if (!row.configured) return true;
2461     return row.configuration_conflicts != 0;
2462 }
2463 
2464 fn plotStatusFor(row: PlotDelta) Status {
2465     const baseline = row.baseline orelse return .new;
2466     const run = row.run orelse return .removed;
2467     if (!plotDefinitionEqual(baseline, run)) return .changed;
2468     if (!plotValuesEqual(baseline, run)) return .changed;
2469     return .stable;
2470 }
2471 
2472 fn plotDefinitionEqual(baseline: plot_mod.Summary, run: plot_mod.Summary) bool {
2473     if (!optionalStringEqual(baseline.kind, run.kind)) return false;
2474     if (!optionalStringEqual(baseline.unit, run.unit)) return false;
2475     if (baseline.configured != run.configured) return false;
2476     if (baseline.step != run.step) return false;
2477     if (baseline.fill != run.fill) return false;
2478     return baseline.color == run.color;
2479 }
2480 
2481 fn plotValuesEqual(baseline: plot_mod.Summary, run: plot_mod.Summary) bool {
2482     if (baseline.count != run.count) return false;
2483     if (baseline.threads != run.threads) return false;
2484     if (baseline.duration_ns != run.duration_ns) return false;
2485     inline for (std.meta.tags(PlotMetric)) |metric| {
2486         if (metric.value(baseline) != metric.value(run)) return false;
2487     }
2488     return true;
2489 }
2490 
2491 fn zoneDelta(
2492     name: []const u8,
2493     baseline: ?summary.ZoneSummary,
2494     run: ?summary.ZoneSummary,
2495     baseline_evidence: []const u8,
2496     run_evidence: []const u8,
2497 ) ZoneDelta {
2498     const baseline_values = ZoneValues.from(baseline);
2499     const run_values = ZoneValues.from(run);
2500     return .{
2501         .name = name,
2502         .status = zoneStatusFor(
2503             baseline_values,
2504             run_values,
2505             baseline != null,
2506             run != null,
2507         ),
2508         .baseline = baseline_values,
2509         .run = run_values,
2510         .baseline_evidence = baseline_evidence,
2511         .run_evidence = run_evidence,
2512     };
2513 }
2514 
2515 fn zoneStatusFor(
2516     baseline: ZoneValues,
2517     run: ZoneValues,
2518     has_baseline: bool,
2519     has_run: bool,
2520 ) Status {
2521     if (!has_baseline and has_run) return .new;
2522     if (has_baseline and !has_run) return .removed;
2523     if (std.meta.eql(baseline, run)) return .stable;
2524     return .changed;
2525 }
2526 
2527 fn memoryDelta(
2528     name: []const u8,
2529     baseline: ?memory_mod.Summary,
2530     run: ?memory_mod.Summary,
2531     baseline_evidence: []const u8,
2532     run_evidence: []const u8,
2533 ) MemoryDelta {
2534     const baseline_values = MemoryValues.from(baseline);
2535     const run_values = MemoryValues.from(run);
2536     return .{
2537         .name = name,
2538         .status = memoryStatusFor(
2539             baseline_values,
2540             run_values,
2541             baseline != null,
2542             run != null,
2543         ),
2544         .baseline = baseline_values,
2545         .run = run_values,
2546         .baseline_lifetime_evidence = baseline_evidence,
2547         .run_lifetime_evidence = run_evidence,
2548     };
2549 }
2550 
2551 fn memoryStatusFor(
2552     baseline: MemoryValues,
2553     run: MemoryValues,
2554     has_baseline: bool,
2555     has_run: bool,
2556 ) Status {
2557     if (!has_baseline and has_run) return .new;
2558     if (has_baseline and !has_run) return .removed;
2559     if (std.meta.eql(baseline, run)) return .stable;
2560     return .changed;
2561 }
2562 
2563 const FrameSide = enum {
2564     baseline,
2565     run,
2566 };
2567 
2568 fn frameDelta(
2569     name: []const u8,
2570     baseline: ?frame_mod.Summary,
2571     run: ?frame_mod.Summary,
2572     partial: bool,
2573     budget_ns: ?u64,
2574 ) FrameDelta {
2575     var result = FrameDelta{
2576         .name = name,
2577         .status = .stable,
2578         .partial = partial,
2579         .budget_ns = budget_ns,
2580     };
2581     if (baseline) |row| applyFrameSummary(&result, row, .baseline);
2582     if (run) |row| applyFrameSummary(&result, row, .run);
2583     result.status = frameStatusFor(baseline != null, run != null, result);
2584     return result;
2585 }
2586 
2587 fn applyFrameSummary(result: *FrameDelta, row: frame_mod.Summary, side: FrameSide) void {
2588     const budget = row.budget orelse frame_mod.BudgetSummary{
2589         .budget_ns = result.budget_ns orelse 1,
2590     };
2591     const over_budget_per_mille = perThousand(budget.over_budget_frames, row.frames);
2592     const missed_per_1000 = perThousand(budget.estimated_missed_intervals, row.frames);
2593     switch (side) {
2594         .baseline => {
2595             result.base_marks = row.marks;
2596             result.base_frames = row.frames;
2597             result.base_mean_ns = row.mean_ns;
2598             result.base_p50_ns = row.p50_ns;
2599             result.base_p90_ns = row.p90_ns;
2600             result.base_p99_ns = row.p99_ns;
2601             result.base_max_ns = row.max_ns;
2602             result.base_discrepancy_ns = row.frame_time_discrepancy_ns;
2603             result.base_over_budget_frames = budget.over_budget_frames;
2604             result.base_over_budget_per_mille = over_budget_per_mille;
2605             result.base_missed_intervals = budget.estimated_missed_intervals;
2606             result.base_missed_per_1000_frames = missed_per_1000;
2607             result.base_total_overrun_ns = budget.total_overrun_ns;
2608             result.base_max_overrun_ns = budget.max_overrun_ns;
2609             result.base_longest_overrun_streak = budget.longest_overrun_streak;
2610         },
2611         .run => {
2612             result.run_marks = row.marks;
2613             result.run_frames = row.frames;
2614             result.run_mean_ns = row.mean_ns;
2615             result.run_p50_ns = row.p50_ns;
2616             result.run_p90_ns = row.p90_ns;
2617             result.run_p99_ns = row.p99_ns;
2618             result.run_max_ns = row.max_ns;
2619             result.run_discrepancy_ns = row.frame_time_discrepancy_ns;
2620             result.run_over_budget_frames = budget.over_budget_frames;
2621             result.run_over_budget_per_mille = over_budget_per_mille;
2622             result.run_missed_intervals = budget.estimated_missed_intervals;
2623             result.run_missed_per_1000_frames = missed_per_1000;
2624             result.run_total_overrun_ns = budget.total_overrun_ns;
2625             result.run_max_overrun_ns = budget.max_overrun_ns;
2626             result.run_longest_overrun_streak = budget.longest_overrun_streak;
2627         },
2628     }
2629 }
2630 
2631 fn frameStatusFor(has_baseline: bool, has_run: bool, row: FrameDelta) Status {
2632     if (!has_baseline and has_run) return .new;
2633     if (has_baseline and !has_run) return .removed;
2634     if (row.populationChanged()) return .changed;
2635     if (row.magnitude() != 0) return .changed;
2636     if (row.budgetChanged()) return .changed;
2637     return .stable;
2638 }
2639 
2640 fn perThousand(numerator: u64, denominator: u64) u64 {
2641     if (denominator == 0) return 0;
2642     const value = (@as(u128, numerator) * 1_000) / denominator;
2643     return @intCast(@min(value, std.math.maxInt(u64)));
2644 }
2645 
2646 fn gpuDelta(
2647     name: []const u8,
2648     baseline: ?gpu_mod.Summary,
2649     run: ?gpu_mod.Summary,
2650 ) GpuDelta {
2651     const base_total = if (baseline) |row| row.gpu_ns else 0;
2652     const run_total = if (run) |row| row.gpu_ns else 0;
2653     const base_completed = if (baseline) |row| row.completed_zones else 0;
2654     const run_completed = if (run) |row| row.completed_zones else 0;
2655     const base_mean = meanCompleted(base_total, base_completed);
2656     const run_mean = meanCompleted(run_total, run_completed);
2657     return .{
2658         .name = name,
2659         .status = gpuStatusFor(baseline, run),
2660         .base_count = if (baseline) |row| row.count else 0,
2661         .run_count = if (run) |row| row.count else 0,
2662         .base_completed_zones = base_completed,
2663         .run_completed_zones = run_completed,
2664         .base_total_gpu_ns = base_total,
2665         .run_total_gpu_ns = run_total,
2666         .base_mean_gpu_ns = base_mean,
2667         .run_mean_gpu_ns = run_mean,
2668     };
2669 }
2670 
2671 fn gpuStatusFor(baseline: ?gpu_mod.Summary, run: ?gpu_mod.Summary) Status {
2672     const base_row = baseline orelse return .new;
2673     const run_row = run orelse return .removed;
2674     if (base_row.gpu_ns != run_row.gpu_ns) return .changed;
2675     if (base_row.count != run_row.count) return .changed;
2676     if (base_row.completed_zones != run_row.completed_zones) return .changed;
2677     if (meanCompleted(base_row.gpu_ns, base_row.completed_zones) !=
2678         meanCompleted(run_row.gpu_ns, run_row.completed_zones)) return .changed;
2679     return .stable;
2680 }
2681 
2682 fn meanCompleted(total: u64, completed: u64) u64 {
2683     if (completed == 0) return 0;
2684     return total / completed;
2685 }
2686 
2687 fn includeZone(row: ZoneDelta, options: Options) bool {
2688     if (!std.mem.eql(u8, row.evidence(), "complete")) return true;
2689     if (row.populationChanged()) return true;
2690     if (row.status == .stable) return options.include_stable;
2691     return row.magnitude() >= options.min_zone_delta_ns;
2692 }
2693 
2694 fn includeMemory(row: MemoryDelta, options: Options) bool {
2695     if (!std.mem.eql(u8, row.evidence(), "complete")) return true;
2696     if (row.populationChanged() or row.anomaliesChanged()) return true;
2697     if (row.status == .stable) return options.include_stable;
2698     const byte_changed = row.byteMagnitude() != 0 and
2699         row.byteMagnitude() >= options.min_memory_delta_bytes;
2700     const lifetime_changed = row.lifetimeMagnitude() != 0 and
2701         row.lifetimeMagnitude() >= options.min_memory_lifetime_delta_ns;
2702     return byte_changed or lifetime_changed;
2703 }
2704 
2705 fn includePlot(row: PlotDelta, options: Options) bool {
2706     if (row.partial) return true;
2707     if (row.compatibility == .mismatch or row.compatibility == .unknown) return true;
2708     return row.status != .stable or options.include_stable;
2709 }
2710 
2711 fn includeFrame(row: FrameDelta, options: Options) bool {
2712     if (row.partial) return true;
2713     if (row.status == .stable and !options.include_stable) return false;
2714     if (row.populationChanged()) return true;
2715     if (row.budgetChanged()) return true;
2716     return row.magnitude() >= options.min_frame_delta_ns;
2717 }
2718 
2719 fn includeGpu(row: GpuDelta, options: Options) bool {
2720     if (!std.mem.eql(u8, row.evidence(), "complete")) return true;
2721     if (row.status == .stable and !options.include_stable) return false;
2722     return row.magnitude() >= options.min_gpu_delta_ns;
2723 }
2724 
2725 fn countStatuses(rows: anytype) Counts {
2726     var counts: Counts = .{};
2727     for (rows) |row| counts.observe(row.status);
2728     return counts;
2729 }
2730 
2731 fn gpuPartialCount(rows: []const GpuDelta) u64 {
2732     var count: u64 = 0;
2733     for (rows) |row| {
2734         if (!std.mem.eql(u8, row.evidence(), "complete")) count += 1;
2735     }
2736     return count;
2737 }
2738 
2739 fn zonePartialCount(rows: []const ZoneDelta) u64 {
2740     var count: u64 = 0;
2741     for (rows) |row| {
2742         if (!std.mem.eql(u8, row.evidence(), "complete")) count += 1;
2743     }
2744     return count;
2745 }
2746 
2747 fn memoryPartialCount(rows: []const MemoryDelta) u64 {
2748     var count: u64 = 0;
2749     for (rows) |row| {
2750         if (!std.mem.eql(u8, row.evidence(), "complete")) count += 1;
2751     }
2752     return count;
2753 }
2754 
2755 fn plotPartialCount(rows: []const PlotDelta) u64 {
2756     var count: u64 = 0;
2757     for (rows) |row| count += @intFromBool(row.partial);
2758     return count;
2759 }
2760 
2761 fn plotCompatibilityCount(
2762     rows: []const PlotDelta,
2763     compatibility: PlotCompatibility,
2764 ) u64 {
2765     var count: u64 = 0;
2766     for (rows) |row| count += @intFromBool(row.compatibility == compatibility);
2767     return count;
2768 }
2769 
2770 fn framePartialCount(rows: []const FrameDelta) u64 {
2771     var count: u64 = 0;
2772     for (rows) |row| count += @intFromBool(row.partial);
2773     return count;
2774 }
2775 
2776 fn frameCapturePartial(analyzer: *frame_mod.Analyzer) bool {
2777     if (!std.mem.eql(u8, analyzer.captureIntegrity().status, "complete")) return true;
2778     return analyzer.counters.out_of_order_marks != 0;
2779 }
2780 
2781 fn plotCapturePartial(analyzer: *summary.Analyzer) bool {
2782     if (!std.mem.eql(u8, analyzer.plots.captureIntegrity().status, "complete")) return true;
2783     if (analyzer.plots.counters.ignored_missing_values != 0) return true;
2784     return analyzer.plots.counters.ignored_nonfinite != 0;
2785 }
2786 
2787 fn zoneDeltaGreaterThan(_: void, left: ZoneDelta, right: ZoneDelta) bool {
2788     const left_partial = !std.mem.eql(u8, left.evidence(), "complete");
2789     const right_partial = !std.mem.eql(u8, right.evidence(), "complete");
2790     if (left_partial != right_partial) return left_partial;
2791     if (left.magnitude() != right.magnitude()) return left.magnitude() > right.magnitude();
2792     if (left.run.p99_ns != right.run.p99_ns) return left.run.p99_ns > right.run.p99_ns;
2793     if (left.run.total_ns != right.run.total_ns) return left.run.total_ns > right.run.total_ns;
2794     return std.mem.lessThan(u8, left.name, right.name);
2795 }
2796 
2797 fn memoryDeltaGreaterThan(_: void, left: MemoryDelta, right: MemoryDelta) bool {
2798     const left_partial = !std.mem.eql(u8, left.evidence(), "complete");
2799     const right_partial = !std.mem.eql(u8, right.evidence(), "complete");
2800     if (left_partial != right_partial) return left_partial;
2801     if (left.byteMagnitude() != right.byteMagnitude()) {
2802         return left.byteMagnitude() > right.byteMagnitude();
2803     }
2804     if (left.lifetimeMagnitude() != right.lifetimeMagnitude()) {
2805         return left.lifetimeMagnitude() > right.lifetimeMagnitude();
2806     }
2807     if (left.run.high_water_live_bytes != right.run.high_water_live_bytes) {
2808         return left.run.high_water_live_bytes > right.run.high_water_live_bytes;
2809     }
2810     if (left.run.p99_lifetime_ns != right.run.p99_lifetime_ns) {
2811         return left.run.p99_lifetime_ns > right.run.p99_lifetime_ns;
2812     }
2813     return std.mem.lessThan(u8, left.name, right.name);
2814 }
2815 
2816 fn plotDeltaGreaterThan(_: void, left: PlotDelta, right: PlotDelta) bool {
2817     const left_priority = plotPriority(left);
2818     const right_priority = plotPriority(right);
2819     if (left_priority != right_priority) return left_priority > right_priority;
2820     if (left.magnitude() != right.magnitude()) return left.magnitude() > right.magnitude();
2821     const left_samples = if (left.run) |plot| plot.count else 0;
2822     const right_samples = if (right.run) |plot| plot.count else 0;
2823     if (left_samples != right_samples) return left_samples > right_samples;
2824     return std.mem.lessThan(u8, left.name, right.name);
2825 }
2826 
2827 fn plotPriority(row: PlotDelta) u8 {
2828     if (row.compatibility == .mismatch) return 3;
2829     if (row.partial) return 2;
2830     if (row.compatibility == .unknown) return 1;
2831     return 0;
2832 }
2833 
2834 fn frameDeltaGreaterThan(_: void, left: FrameDelta, right: FrameDelta) bool {
2835     if (left.magnitude() != right.magnitude()) return left.magnitude() > right.magnitude();
2836     if (left.run_p99_ns != right.run_p99_ns) return left.run_p99_ns > right.run_p99_ns;
2837     return std.mem.lessThan(u8, left.name, right.name);
2838 }
2839 
2840 fn gpuDeltaGreaterThan(_: void, left: GpuDelta, right: GpuDelta) bool {
2841     if (left.magnitude() != right.magnitude()) return left.magnitude() > right.magnitude();
2842     if (left.run_total_gpu_ns != right.run_total_gpu_ns) {
2843         return left.run_total_gpu_ns > right.run_total_gpu_ns;
2844     }
2845     return std.mem.lessThan(u8, left.name, right.name);
2846 }
2847 
2848 fn signedDelta(base: u64, run: u64) i128 {
2849     return @as(i128, @intCast(run)) - @as(i128, @intCast(base));
2850 }
2851 
2852 fn absI128(value: i128) u128 {
2853     if (value < 0) return @intCast(-value);
2854     return @intCast(value);
2855 }
2856 
2857 fn absF64(value: f64) f64 {
2858     return if (value < 0) -value else value;
2859 }
2860 
2861 fn plotOrEmpty(value: ?plot_mod.Summary, name: []const u8) plot_mod.Summary {
2862     return value orelse .{ .name = name };
2863 }
2864 
2865 fn optionalStringEqual(left: ?[]const u8, right: ?[]const u8) bool {
2866     if (left) |left_text| {
2867         const right_text = right orelse return false;
2868         return std.mem.eql(u8, left_text, right_text);
2869     }
2870     return right == null;
2871 }
2872 
2873 fn writeOptionalStringText(writer: *std.Io.Writer, value: ?[]const u8) !void {
2874     if (value) |actual| {
2875         try pretty_json.writeString(writer, actual);
2876     } else {
2877         try writer.writeAll("none");
2878     }
2879 }
2880 
2881 fn writeOptionalU32Text(writer: *std.Io.Writer, value: ?u32) !void {
2882     if (value) |actual| {
2883         try writer.print("{d}", .{actual});
2884     } else {
2885         try writer.writeAll("none");
2886     }
2887 }
2888 
2889 fn writeOptionalF64Text(writer: *std.Io.Writer, value: ?f64) !void {
2890     if (value) |actual| {
2891         try writer.print("{d}", .{actual});
2892     } else {
2893         try writer.writeAll("none");
2894     }
2895 }
2896 
2897 fn writeOptionalU64Text(writer: *std.Io.Writer, value: ?u64) !void {
2898     if (value) |actual| {
2899         try writer.print("{d}", .{actual});
2900     } else {
2901         try writer.writeAll("none");
2902     }
2903 }
2904 
2905 test "compare reports zone and memory deltas" {
2906     var baseline = Analyzer.init(std.testing.allocator);
2907     defer baseline.deinit();
2908     try addZone(&baseline, "phase", 100);
2909     try addMemory(&baseline, "arena", 64);
2910 
2911     var run = Analyzer.init(std.testing.allocator);
2912     defer run.deinit();
2913     try addZoneTrace(&run, &.{
2914         .{ .name = "phase", .durations_ns = &.{150} },
2915         .{ .name = "new-phase", .durations_ns = &.{40} },
2916     }, false);
2917     try addMemory(&run, "arena", 128);
2918 
2919     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
2920     defer out.deinit();
2921     try writeText(std.testing.allocator, &baseline, &run, &out.writer, .{ .top = 8 });
2922     const text = out.written();
2923     try std.testing.expect(std.mem.indexOf(u8, text, "tracy compare zones") != null);
2924     try std.testing.expect(
2925         std.mem.indexOf(u8, text, "zone status=changed evidence=partial name=\"phase\"") != null,
2926     );
2927     try std.testing.expect(std.mem.indexOf(u8, text, "delta_total_ns=50") != null);
2928     try std.testing.expect(
2929         std.mem.indexOf(u8, text, "zone status=new evidence=partial name=\"new-phase\"") != null,
2930     );
2931     try std.testing.expect(std.mem.indexOf(
2932         u8,
2933         text,
2934         "memory status=changed name=\"arena\"",
2935     ) != null);
2936     try std.testing.expect(std.mem.indexOf(u8, text, "delta_high_water_live_bytes=64") != null);
2937 }
2938 
2939 test "compare preserves equal-total zone tails and population changes" {
2940     var baseline = Analyzer.init(std.testing.allocator);
2941     defer baseline.deinit();
2942     try addZoneTrace(&baseline, &.{
2943         .{ .name = "tail", .durations_ns = &.{ 10, 10, 10, 10, 60 } },
2944         .{ .name = "population", .durations_ns = &.{50} },
2945     }, false);
2946     var run = Analyzer.init(std.testing.allocator);
2947     defer run.deinit();
2948     try addZoneTrace(&run, &.{
2949         .{ .name = "tail", .durations_ns = &.{ 1, 1, 1, 47, 50 } },
2950         .{ .name = "population", .durations_ns = &.{ 25, 25 } },
2951     }, false);
2952 
2953     var text = std.Io.Writer.Allocating.init(std.testing.allocator);
2954     defer text.deinit();
2955     try writeText(std.testing.allocator, &baseline, &run, &text.writer, .{
2956         .min_zone_delta_ns = 20,
2957     });
2958     try expectContains(text.written(), "zones shared=2 changed=2 stable=0");
2959     try expectContains(text.written(), "partial=0 baseline_duration_evidence=complete");
2960     try expectContains(text.written(), "name=\"tail\" base_count=5 run_count=5");
2961     try expectContains(text.written(), "base_total_ns=100 run_total_ns=100 delta_total_ns=0");
2962     try expectContains(text.written(), "base_p99_ns=10 run_p99_ns=47 delta_p99_ns=37");
2963     try expectContains(text.written(), "name=\"population\" base_count=1 run_count=2");
2964 
2965     var json = std.Io.Writer.Allocating.init(std.testing.allocator);
2966     defer json.deinit();
2967     try writeJsonl(std.testing.allocator, &baseline, &run, &json.writer, .{});
2968     try std.testing.expectEqual(@as(usize, 3), try validJsonLineCount(json.written()));
2969     try expectContains(json.written(), "\"zone_partial\":0");
2970     try expectContains(json.written(), "\"base_p99_ns\":10,\"run_p99_ns\":47");
2971 }
2972 
2973 test "compare always surfaces partial zone duration evidence" {
2974     var baseline = Analyzer.init(std.testing.allocator);
2975     defer baseline.deinit();
2976     try addZoneTrace(&baseline, &.{
2977         .{ .name = "invalid", .durations_ns = &.{ 10, 10 } },
2978         .{ .name = "stable", .durations_ns = &.{20} },
2979     }, false);
2980     var run = Analyzer.init(std.testing.allocator);
2981     defer run.deinit();
2982     try addZoneTrace(&run, &.{
2983         .{ .name = "invalid", .durations_ns = &.{ 10, 10 }, .invalid_last = true },
2984         .{ .name = "stable", .durations_ns = &.{20} },
2985     }, true);
2986 
2987     var text = std.Io.Writer.Allocating.init(std.testing.allocator);
2988     defer text.deinit();
2989     try writeText(std.testing.allocator, &baseline, &run, &text.writer, .{
2990         .min_zone_delta_ns = std.math.maxInt(u64),
2991     });
2992     try expectContains(text.written(), "partial=2 baseline_duration_evidence=complete");
2993     try expectContains(text.written(), "run_duration_evidence=partial");
2994     try expectContains(text.written(), "run_timestamp_regressions=1");
2995     try expectContains(text.written(), "status=stable evidence=partial name=\"stable\"");
2996     try expectContains(text.written(), "name=\"invalid\" base_count=2 run_count=2");
2997     try expectContains(text.written(), "base_duration_samples=2 run_duration_samples=1");
2998 }
2999 
3000 test "compare preserves lifetime tails with equal high water" {
3001     var baseline = Analyzer.init(std.testing.allocator);
3002     defer baseline.deinit();
3003     try addMemoryLifetimes(&baseline, "arena", 64, &.{ 10, 20, 30, 40, 50 }, .{});
3004     var run = Analyzer.init(std.testing.allocator);
3005     defer run.deinit();
3006     try addMemoryLifetimes(&run, "arena", 64, &.{ 10, 20, 30, 40, 200 }, .{});
3007 
3008     var text = std.Io.Writer.Allocating.init(std.testing.allocator);
3009     defer text.deinit();
3010     try writeText(std.testing.allocator, &baseline, &run, &text.writer, .{
3011         .min_memory_delta_bytes = 1000,
3012     });
3013     try expectContains(text.written(), "memory status=changed name=\"arena\" evidence=complete");
3014     try expectContains(text.written(), "delta_high_water_live_bytes=0");
3015     try expectContains(text.written(), "base_p99_ns=50 run_p99_ns=200 delta_p99_ns=150");
3016     try expectContains(text.written(), "partial=0 baseline_lifetime_evidence=complete");
3017 
3018     var json = std.Io.Writer.Allocating.init(std.testing.allocator);
3019     defer json.deinit();
3020     try writeJsonl(std.testing.allocator, &baseline, &run, &json.writer, .{});
3021     try std.testing.expectEqual(@as(usize, 2), try validJsonLineCount(json.written()));
3022     try expectContains(json.written(), "\"memory_partial\":0");
3023     try expectContains(json.written(), "\"base_lifetime_p50_ns\":30");
3024     try expectContains(json.written(), "\"delta_lifetime_p99_ns\":150");
3025 
3026     var filtered = std.Io.Writer.Allocating.init(std.testing.allocator);
3027     defer filtered.deinit();
3028     try writeText(std.testing.allocator, &baseline, &run, &filtered.writer, .{
3029         .min_memory_delta_bytes = 1000,
3030         .min_memory_lifetime_delta_ns = 151,
3031     });
3032     try std.testing.expect(std.mem.indexOf(u8, filtered.written(), "memory status=") == null);
3033 }
3034 
3035 test "compare retains right censored memory evidence through thresholds" {
3036     var baseline = Analyzer.init(std.testing.allocator);
3037     defer baseline.deinit();
3038     try addMemoryLifetimes(&baseline, "arena", 64, &.{10}, .{});
3039     var run = Analyzer.init(std.testing.allocator);
3040     defer run.deinit();
3041     try addMemoryLifetimes(&run, "arena", 64, &.{10}, .{ .right_censored = true });
3042 
3043     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
3044     defer out.deinit();
3045     try writeText(std.testing.allocator, &baseline, &run, &out.writer, .{
3046         .min_memory_delta_bytes = std.math.maxInt(u64),
3047         .min_memory_lifetime_delta_ns = std.math.maxInt(u64),
3048     });
3049     try expectContains(out.written(), "memory status=changed name=\"arena\" evidence=partial");
3050     try expectContains(out.written(), "partial=1 baseline_lifetime_evidence=complete");
3051     try expectContains(out.written(), "run_lifetime_evidence=partial");
3052     try expectContains(out.written(), "base_right_censored=0 run_right_censored=1");
3053 }
3054 
3055 test "compare retains memory anomalies through thresholds" {
3056     var baseline = Analyzer.init(std.testing.allocator);
3057     defer baseline.deinit();
3058     try addMemoryLifetimes(&baseline, "arena", 64, &.{10}, .{});
3059     var run = Analyzer.init(std.testing.allocator);
3060     defer run.deinit();
3061     try addMemoryLifetimes(&run, "arena", 64, &.{10}, .{ .size_mismatch = true });
3062 
3063     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
3064     defer out.deinit();
3065     try writeText(std.testing.allocator, &baseline, &run, &out.writer, .{
3066         .min_memory_delta_bytes = std.math.maxInt(u64),
3067         .min_memory_lifetime_delta_ns = std.math.maxInt(u64),
3068     });
3069     try expectContains(out.written(), "memory status=changed name=\"arena\" evidence=partial");
3070     try expectContains(out.written(), "delta_high_water_live_bytes=0");
3071     try expectContains(out.written(), "delta_p99_ns=0");
3072     try expectContains(out.written(), "base_size_mismatches=0 run_size_mismatches=1");
3073 }
3074 
3075 test "memory correlation preserves new removed and stable pools" {
3076     var baseline = [_]memory_mod.Summary{
3077         .{ .name = "removed", .allocations = 1 },
3078         .{ .name = "stable", .allocated_bytes = 64 },
3079     };
3080     var run = [_]memory_mod.Summary{
3081         .{ .name = "new", .allocations = 1 },
3082         .{ .name = "stable", .allocated_bytes = 64 },
3083     };
3084     var rows = try collectMemoryDeltas(
3085         std.testing.allocator,
3086         &baseline,
3087         &run,
3088         "complete",
3089         "complete",
3090         .{ .include_stable = true },
3091     );
3092     defer rows.deinit(std.testing.allocator);
3093     try std.testing.expectEqual(@as(usize, 3), rows.items.len);
3094     try expectMemoryStatus(rows.items, "new", .new);
3095     try expectMemoryStatus(rows.items, "removed", .removed);
3096     try expectMemoryStatus(rows.items, "stable", .stable);
3097 
3098     var hidden = try collectMemoryDeltas(
3099         std.testing.allocator,
3100         &baseline,
3101         &run,
3102         "complete",
3103         "complete",
3104         .{},
3105     );
3106     defer hidden.deinit(std.testing.allocator);
3107     try std.testing.expectEqual(@as(usize, 2), hidden.items.len);
3108 }
3109 
3110 test "compare jsonl emits machine-readable rows" {
3111     var baseline = Analyzer.init(std.testing.allocator);
3112     defer baseline.deinit();
3113     try addZone(&baseline, "phase", 100);
3114 
3115     var run = Analyzer.init(std.testing.allocator);
3116     defer run.deinit();
3117     try addZone(&run, "phase", 60);
3118 
3119     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
3120     defer out.deinit();
3121     try writeJsonl(std.testing.allocator, &baseline, &run, &out.writer, .{ .top = 4 });
3122     const text = out.written();
3123     try std.testing.expect(std.mem.indexOf(u8, text, "\"schema\":\"tracy.compare/v0\"") != null);
3124     try std.testing.expect(std.mem.indexOf(u8, text, "\"kind\":\"zone\"") != null);
3125     try std.testing.expect(std.mem.indexOf(u8, text, "\"delta_total_ns\":-40") != null);
3126 }
3127 
3128 test "compare correlates frame sets and preserves distribution tails" {
3129     var baseline = Analyzer.init(std.testing.allocator);
3130     defer baseline.deinit();
3131     try addFrameTrace(&baseline, &.{
3132         .{ .name = "render", .durations_ns = &.{ 16, 16, 16 } },
3133         .{ .name = "removed", .durations_ns = &.{10} },
3134         .{ .name = "steady", .durations_ns = &.{5} },
3135     }, false);
3136     var run = Analyzer.init(std.testing.allocator);
3137     defer run.deinit();
3138     try addFrameTrace(&run, &.{
3139         .{ .name = "render", .durations_ns = &.{ 16, 16, 48 } },
3140         .{ .name = "new", .durations_ns = &.{10} },
3141         .{ .name = "steady", .durations_ns = &.{5} },
3142     }, false);
3143 
3144     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
3145     defer out.deinit();
3146     try writeText(std.testing.allocator, &baseline, &run, &out.writer, .{ .top = 8 });
3147     const text = out.written();
3148     try expectContains(
3149         text,
3150         "tracy compare frames shared=1 changed=1 stable=0 new=1 removed=1 partial=0",
3151     );
3152     try expectContains(text, "frame status=changed evidence=complete name=\"render\"");
3153     try expectContains(text, "base_p50_ns=16 run_p50_ns=16 delta_p50_ns=0");
3154     try expectContains(text, "base_p99_ns=16 run_p99_ns=48 delta_p99_ns=32");
3155     try expectContains(
3156         text,
3157         "base_discrepancy_ns=16 run_discrepancy_ns=48 delta_discrepancy_ns=32",
3158     );
3159     try expectContains(text, "frame status=new evidence=complete name=\"new\"");
3160     try expectContains(text, "frame status=removed evidence=complete name=\"removed\"");
3161     try std.testing.expect(std.mem.indexOf(u8, text, "name=\"steady\"") == null);
3162 }
3163 
3164 test "compare retains frame budget changes independently of time threshold" {
3165     var baseline = Analyzer.init(std.testing.allocator);
3166     defer baseline.deinit();
3167     try addFrameTrace(
3168         &baseline,
3169         &.{.{ .name = "render", .durations_ns = &.{ 16, 16, 16 } }},
3170         false,
3171     );
3172     var run = Analyzer.init(std.testing.allocator);
3173     defer run.deinit();
3174     try addFrameTrace(
3175         &run,
3176         &.{.{ .name = "render", .durations_ns = &.{ 16, 16, 48 } }},
3177         false,
3178     );
3179 
3180     var filtered = std.Io.Writer.Allocating.init(std.testing.allocator);
3181     defer filtered.deinit();
3182     try writeText(std.testing.allocator, &baseline, &run, &filtered.writer, .{
3183         .min_frame_delta_ns = 1_000,
3184     });
3185     try std.testing.expect(std.mem.indexOf(u8, filtered.written(), "frame status=") == null);
3186 
3187     var budgeted = std.Io.Writer.Allocating.init(std.testing.allocator);
3188     defer budgeted.deinit();
3189     try writeText(std.testing.allocator, &baseline, &run, &budgeted.writer, .{
3190         .frame_budget_ns = 20,
3191         .min_frame_delta_ns = 1_000,
3192     });
3193     try expectContains(budgeted.written(), "frame status=changed evidence=complete");
3194     try expectContains(budgeted.written(), "delta_over_budget_per_mille=333");
3195     try expectContains(budgeted.written(), "delta_missed_intervals=2");
3196 }
3197 
3198 test "compare retains frame population changes independently of time threshold" {
3199     var baseline = Analyzer.init(std.testing.allocator);
3200     defer baseline.deinit();
3201     try addFrameTrace(
3202         &baseline,
3203         &.{.{ .name = "render", .durations_ns = &.{ 16, 16 } }},
3204         false,
3205     );
3206     var run = Analyzer.init(std.testing.allocator);
3207     defer run.deinit();
3208     try addFrameTrace(
3209         &run,
3210         &.{.{ .name = "render", .durations_ns = &.{16} }},
3211         false,
3212     );
3213 
3214     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
3215     defer out.deinit();
3216     try writeText(std.testing.allocator, &baseline, &run, &out.writer, .{
3217         .min_frame_delta_ns = 1_000,
3218     });
3219     try expectContains(out.written(), "frame status=changed evidence=complete");
3220     try expectContains(out.written(), "base_frames=2 run_frames=1 delta_frames=-1");
3221 }
3222 
3223 test "compare always surfaces partial frame evidence" {
3224     var baseline = Analyzer.init(std.testing.allocator);
3225     defer baseline.deinit();
3226     try addFrameTrace(
3227         &baseline,
3228         &.{.{ .name = "render", .durations_ns = &.{ 16, 16 } }},
3229         false,
3230     );
3231     var run = Analyzer.init(std.testing.allocator);
3232     defer run.deinit();
3233     try addFrameTrace(
3234         &run,
3235         &.{.{ .name = "render", .durations_ns = &.{ 16, 16 } }},
3236         true,
3237     );
3238 
3239     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
3240     defer out.deinit();
3241     try writeText(std.testing.allocator, &baseline, &run, &out.writer, .{
3242         .min_frame_delta_ns = 1_000,
3243     });
3244     try expectContains(out.written(), "run_integrity=sequence_gaps");
3245     try expectContains(out.written(), "stable=1 new=0 removed=0 partial=1");
3246     try expectContains(out.written(), "frame status=stable evidence=partial name=\"render\"");
3247 }
3248 
3249 test "compare always surfaces out-of-order frame evidence" {
3250     var baseline = Analyzer.init(std.testing.allocator);
3251     defer baseline.deinit();
3252     try addOutOfOrderFrameTrace(&baseline);
3253     var run = Analyzer.init(std.testing.allocator);
3254     defer run.deinit();
3255     try addOutOfOrderFrameTrace(&run);
3256 
3257     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
3258     defer out.deinit();
3259     try writeText(std.testing.allocator, &baseline, &run, &out.writer, .{
3260         .min_frame_delta_ns = 1_000,
3261     });
3262     try expectContains(out.written(), "baseline_integrity=complete run_integrity=complete");
3263     try expectContains(
3264         out.written(),
3265         "partial=1 baseline_marks=3 run_marks=3 baseline_frames=1 run_frames=1 " ++
3266             "baseline_out_of_order_marks=1 run_out_of_order_marks=1",
3267     );
3268     try expectContains(out.written(), "frame status=stable evidence=partial name=\"render\"");
3269 }
3270 
3271 test "compare frame JSONL retains budget units and valid rows" {
3272     var baseline = Analyzer.init(std.testing.allocator);
3273     defer baseline.deinit();
3274     try addFrameTrace(
3275         &baseline,
3276         &.{.{ .name = "render", .durations_ns = &.{ 16, 16, 16 } }},
3277         false,
3278     );
3279     var run = Analyzer.init(std.testing.allocator);
3280     defer run.deinit();
3281     try addFrameTrace(
3282         &run,
3283         &.{.{ .name = "render", .durations_ns = &.{ 16, 16, 48 } }},
3284         false,
3285     );
3286 
3287     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
3288     defer out.deinit();
3289     try writeJsonl(std.testing.allocator, &baseline, &run, &out.writer, .{
3290         .frame_budget_ns = 20,
3291     });
3292     const text = out.written();
3293     try expectContains(text, "\"frame_budget_ns\":20");
3294     try expectContains(text, "\"kind\":\"frame\"");
3295     try expectContains(text, "\"delta_p99_ns\":32");
3296     try expectContains(text, "\"kind\":\"frame_budget\"");
3297     try expectContains(text, "\"delta_missed_per_1000_frames\":666");
3298     try std.testing.expectEqual(3, try validJsonLineCount(text));
3299 }
3300 
3301 test "compare rejects a zero frame budget" {
3302     var baseline = Analyzer.init(std.testing.allocator);
3303     defer baseline.deinit();
3304     var run = Analyzer.init(std.testing.allocator);
3305     defer run.deinit();
3306     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
3307     defer out.deinit();
3308     try std.testing.expectError(
3309         error.InvalidBudget,
3310         writeText(std.testing.allocator, &baseline, &run, &out.writer, .{
3311             .frame_budget_ns = 0,
3312         }),
3313     );
3314 }
3315 
3316 test "compare correlates named GPU zones and preserves completed means" {
3317     var baseline = Analyzer.init(std.testing.allocator);
3318     defer baseline.deinit();
3319     try addGpuTrace(&baseline, &.{
3320         .{ .name = "draw", .gpu_ns = 30 },
3321         .{ .name = "draw", .gpu_ns = 30 },
3322         .{ .name = "removed", .gpu_ns = 20 },
3323         .{ .name = "steady", .gpu_ns = 10 },
3324     });
3325     var run = Analyzer.init(std.testing.allocator);
3326     defer run.deinit();
3327     try addGpuTrace(&run, &.{
3328         .{ .name = "draw", .gpu_ns = 100 },
3329         .{ .name = "new", .gpu_ns = 30 },
3330         .{ .name = "steady", .gpu_ns = 10 },
3331     });
3332 
3333     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
3334     defer out.deinit();
3335     try writeText(std.testing.allocator, &baseline, &run, &out.writer, .{ .top = 8 });
3336     const text = out.written();
3337     try std.testing.expect(std.mem.indexOf(
3338         u8,
3339         text,
3340         "tracy compare capture baseline_integrity=complete run_integrity=complete",
3341     ) != null);
3342     try std.testing.expect(std.mem.indexOf(
3343         u8,
3344         text,
3345         "tracy compare gpu shared=1 changed=1 stable=0 new=1 removed=1 partial=0",
3346     ) != null);
3347     try expectContains(text, "gpu status=changed evidence=complete name=\"draw\"");
3348     try expectContains(
3349         text,
3350         "base_total_gpu_ns=60 run_total_gpu_ns=100 delta_total_gpu_ns=40",
3351     );
3352     try expectContains(
3353         text,
3354         "base_mean_gpu_ns=30 run_mean_gpu_ns=100 delta_mean_gpu_ns=70",
3355     );
3356     try expectContains(
3357         text,
3358         "base_count=2 run_count=1 base_completed_zones=2 run_completed_zones=1",
3359     );
3360     try expectContains(text, "gpu status=new evidence=complete name=\"new\"");
3361     try expectContains(text, "gpu status=removed evidence=complete name=\"removed\"");
3362     try std.testing.expect(std.mem.indexOf(u8, text, "name=\"steady\"") == null);
3363     const changed_index = std.mem.indexOf(u8, text, "gpu status=changed").?;
3364     const new_index = std.mem.indexOf(u8, text, "gpu status=new").?;
3365     const removed_index = std.mem.indexOf(u8, text, "gpu status=removed").?;
3366     try std.testing.expect(changed_index < new_index);
3367     try std.testing.expect(new_index < removed_index);
3368 }
3369 
3370 test "compare filters complete GPU deltas independently" {
3371     var baseline = Analyzer.init(std.testing.allocator);
3372     defer baseline.deinit();
3373     try addGpuTrace(&baseline, &.{
3374         .{ .name = "draw", .gpu_ns = 60 },
3375         .{ .name = "steady", .gpu_ns = 10 },
3376     });
3377     var run = Analyzer.init(std.testing.allocator);
3378     defer run.deinit();
3379     try addGpuTrace(&run, &.{
3380         .{ .name = "draw", .gpu_ns = 100 },
3381         .{ .name = "steady", .gpu_ns = 10 },
3382     });
3383 
3384     var filtered = std.Io.Writer.Allocating.init(std.testing.allocator);
3385     defer filtered.deinit();
3386     try writeText(std.testing.allocator, &baseline, &run, &filtered.writer, .{
3387         .top = 8,
3388         .min_gpu_delta_ns = 50,
3389         .include_stable = true,
3390     });
3391     try std.testing.expect(std.mem.indexOf(u8, filtered.written(), "gpu status=") == null);
3392 
3393     var stable = std.Io.Writer.Allocating.init(std.testing.allocator);
3394     defer stable.deinit();
3395     try writeText(std.testing.allocator, &baseline, &run, &stable.writer, .{
3396         .top = 8,
3397         .include_stable = true,
3398     });
3399     try std.testing.expect(std.mem.indexOf(
3400         u8,
3401         stable.written(),
3402         "gpu status=stable evidence=complete name=\"steady\"",
3403     ) != null);
3404 }
3405 
3406 test "compare GPU threshold observes completed-zone mean changes" {
3407     var baseline = Analyzer.init(std.testing.allocator);
3408     defer baseline.deinit();
3409     try addGpuTrace(&baseline, &.{
3410         .{ .name = "draw", .gpu_ns = 30 },
3411         .{ .name = "draw", .gpu_ns = 30 },
3412     });
3413     var run = Analyzer.init(std.testing.allocator);
3414     defer run.deinit();
3415     try addGpuTrace(&run, &.{.{ .name = "draw", .gpu_ns = 60 }});
3416 
3417     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
3418     defer out.deinit();
3419     try writeText(std.testing.allocator, &baseline, &run, &out.writer, .{
3420         .top = 8,
3421         .min_gpu_delta_ns = 20,
3422     });
3423     const text = out.written();
3424     try expectContains(text, "gpu status=changed evidence=complete name=\"draw\"");
3425     try expectContains(text, "delta_total_gpu_ns=0");
3426     try expectContains(text, "base_mean_gpu_ns=30 run_mean_gpu_ns=60 delta_mean_gpu_ns=30");
3427 }
3428 
3429 test "compare always surfaces partial GPU evidence" {
3430     var baseline = Analyzer.init(std.testing.allocator);
3431     defer baseline.deinit();
3432     try addGpuTrace(&baseline, &.{.{ .name = "draw", .gpu_ns = 20 }});
3433     var run = Analyzer.init(std.testing.allocator);
3434     defer run.deinit();
3435     try addGpuTrace(&run, &.{.{ .name = "draw", .gpu_ns = 30, .complete = false }});
3436 
3437     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
3438     defer out.deinit();
3439     try writeText(std.testing.allocator, &baseline, &run, &out.writer, .{
3440         .top = 8,
3441         .min_gpu_delta_ns = 1_000,
3442     });
3443     const text = out.written();
3444     try std.testing.expect(std.mem.indexOf(u8, text, "run_integrity=unbalanced_events") != null);
3445     try expectContains(
3446         text,
3447         "partial=1 baseline_incomplete_zones=0 run_incomplete_zones=1",
3448     );
3449     try expectContains(text, "gpu status=changed evidence=partial name=\"draw\"");
3450     try expectContains(text, "base_completed_zones=1 run_completed_zones=0");
3451 }
3452 
3453 test "compare GPU JSONL retains integrity and units" {
3454     var baseline = Analyzer.init(std.testing.allocator);
3455     defer baseline.deinit();
3456     try addGpuTrace(&baseline, &.{.{ .name = "draw", .gpu_ns = 60 }});
3457     var run = Analyzer.init(std.testing.allocator);
3458     defer run.deinit();
3459     try addGpuTrace(&run, &.{.{ .name = "draw", .gpu_ns = 100 }});
3460 
3461     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
3462     defer out.deinit();
3463     try writeJsonl(std.testing.allocator, &baseline, &run, &out.writer, .{ .top = 4 });
3464     const text = out.written();
3465     try std.testing.expect(std.mem.indexOf(u8, text, "\"gpu_changed\":1") != null);
3466     try expectContains(text, "\"baseline_capture_integrity\":\"complete\"");
3467     try std.testing.expect(std.mem.indexOf(u8, text, "\"kind\":\"gpu\"") != null);
3468     try std.testing.expect(std.mem.indexOf(u8, text, "\"delta_total_gpu_ns\":40") != null);
3469     try std.testing.expect(std.mem.indexOf(u8, text, "\"delta_mean_gpu_ns\":40") != null);
3470     try std.testing.expectEqual(2, try validJsonLineCount(text));
3471 }
3472 
3473 test "compare correlates plot names and preserves distributions" {
3474     var baseline = Analyzer.init(std.testing.allocator);
3475     defer baseline.deinit();
3476     try addPlotTrace(&baseline, &.{
3477         .{ .name = "latency", .unit = "nanoseconds", .values = &.{ 10, 20, 30 } },
3478         .{ .name = "removed", .unit = "count", .values = &.{1} },
3479         .{ .name = "steady", .unit = "count", .values = &.{5} },
3480     }, false);
3481     var run = Analyzer.init(std.testing.allocator);
3482     defer run.deinit();
3483     try addPlotTrace(&run, &.{
3484         .{ .name = "latency", .unit = "nanoseconds", .values = &.{ 20, 40, 60 } },
3485         .{ .name = "new", .unit = "count", .values = &.{1} },
3486         .{ .name = "steady", .unit = "count", .values = &.{5} },
3487     }, false);
3488 
3489     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
3490     defer out.deinit();
3491     try writeText(std.testing.allocator, &baseline, &run, &out.writer, .{ .top = 8 });
3492     const text = out.written();
3493     try expectContains(
3494         text,
3495         "tracy compare plots shared=1 changed=1 stable=0 new=1 removed=1 partial=0",
3496     );
3497     try expectContains(
3498         text,
3499         "plot status=changed evidence=complete unit_compatibility=matching name=\"latency\"",
3500     );
3501     try expectContains(text, "base_samples=3 run_samples=3 delta_samples=0");
3502     try expectContains(text, "base_p50=20 run_p50=40 delta_p50=20");
3503     try expectContains(text, "base_p99=30 run_p99=60 delta_p99=30");
3504     try expectContains(text, "base_mean=20 run_mean=40 delta_mean=20");
3505     try expectContains(
3506         text,
3507         "plot status=new evidence=complete unit_compatibility=not_applicable name=\"new\"",
3508     );
3509     try expectContains(
3510         text,
3511         "plot status=removed evidence=complete unit_compatibility=not_applicable " ++
3512             "name=\"removed\"",
3513     );
3514     try std.testing.expect(std.mem.indexOf(u8, text, "name=\"steady\"") == null);
3515 }
3516 
3517 test "compare suppresses plot value deltas when units conflict" {
3518     var baseline = Analyzer.init(std.testing.allocator);
3519     defer baseline.deinit();
3520     try addPlotTrace(
3521         &baseline,
3522         &.{.{ .name = "heap", .unit = "bytes", .values = &.{ 64, 96 } }},
3523         false,
3524     );
3525     var run = Analyzer.init(std.testing.allocator);
3526     defer run.deinit();
3527     try addPlotTrace(
3528         &run,
3529         &.{.{ .name = "heap", .unit = "nanoseconds", .values = &.{ 64, 96 } }},
3530         false,
3531     );
3532 
3533     var text_out = std.Io.Writer.Allocating.init(std.testing.allocator);
3534     defer text_out.deinit();
3535     try writeText(std.testing.allocator, &baseline, &run, &text_out.writer, .{});
3536     try expectContains(text_out.written(), "unit_mismatch=1 unit_unknown=0");
3537     try expectContains(text_out.written(), "unit_compatibility=mismatch name=\"heap\"");
3538     try expectContains(text_out.written(), "base_mean=80 run_mean=80 delta_mean=none");
3539 
3540     var json_out = std.Io.Writer.Allocating.init(std.testing.allocator);
3541     defer json_out.deinit();
3542     try writeJsonl(std.testing.allocator, &baseline, &run, &json_out.writer, .{});
3543     const json = json_out.written();
3544     try expectContains(json, "\"plot_unit_mismatch\":1");
3545     try expectContains(json, "\"unit_compatibility\":\"mismatch\"");
3546     try expectContains(json, "\"base_unit\":\"bytes\",\"run_unit\":\"nanoseconds\"");
3547     try expectContains(json, "\"delta_mean\":null");
3548     try std.testing.expectEqual(2, try validJsonLineCount(json));
3549 }
3550 
3551 test "compare always surfaces incomplete plot evidence" {
3552     var baseline = Analyzer.init(std.testing.allocator);
3553     defer baseline.deinit();
3554     try addPlotTrace(&baseline, &.{
3555         .{
3556             .name = "heap",
3557             .unit = "bytes",
3558             .values = &.{64},
3559             .conflict = "nanoseconds",
3560             .missing_value = true,
3561         },
3562         .{ .name = "legacy", .values = &.{1} },
3563     }, false);
3564     var run = Analyzer.init(std.testing.allocator);
3565     defer run.deinit();
3566     try addPlotTrace(&run, &.{
3567         .{ .name = "heap", .unit = "bytes", .values = &.{64} },
3568         .{ .name = "legacy", .values = &.{1} },
3569     }, true);
3570 
3571     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
3572     defer out.deinit();
3573     try writeText(std.testing.allocator, &baseline, &run, &out.writer, .{});
3574     const text = out.written();
3575     try expectContains(text, "run_integrity=sequence_gaps");
3576     try expectContains(
3577         text,
3578         "tracy compare plots shared=2 changed=0 stable=2 new=0 removed=0 partial=2",
3579     );
3580     try expectContains(text, "unit_mismatch=0 unit_unknown=1");
3581     try expectContains(text, "unit_compatibility=matching name=\"heap\"");
3582     try expectContains(
3583         text,
3584         "base_configuration_conflicts=1 run_configuration_conflicts=0",
3585     );
3586     try expectContains(text, "baseline_ignored_missing_values=1 run_ignored_missing_values=0");
3587     try expectContains(text, "unit_compatibility=unknown name=\"legacy\"");
3588 }
3589 
3590 test "compare releases assembled evidence on allocation failure" {
3591     try std.testing.checkAllAllocationFailures(
3592         std.testing.allocator,
3593         writeComparisonWithAllocator,
3594         .{},
3595     );
3596 }
3597 
3598 const TestGpuZone = struct {
3599     name: []const u8,
3600     gpu_ns: u64,
3601     complete: bool = true,
3602 };
3603 
3604 const TestZoneSet = struct {
3605     name: []const u8,
3606     durations_ns: []const u64,
3607     invalid_last: bool = false,
3608 };
3609 
3610 const TestFrameSet = struct {
3611     name: []const u8,
3612     durations_ns: []const u64,
3613 };
3614 
3615 const TestPlotSet = struct {
3616     name: []const u8,
3617     unit: ?[]const u8 = null,
3618     values: []const f64,
3619     conflict: ?[]const u8 = null,
3620     missing_value: bool = false,
3621 };
3622 
3623 fn expectContains(text: []const u8, needle: []const u8) !void {
3624     try std.testing.expect(std.mem.indexOf(u8, text, needle) != null);
3625 }
3626 
3627 fn expectMemoryStatus(rows: []const MemoryDelta, name: []const u8, expected: Status) !void {
3628     for (rows) |row| {
3629         if (!std.mem.eql(u8, row.name, name)) continue;
3630         try std.testing.expectEqual(expected, row.status);
3631         return;
3632     }
3633     return error.TestExpectedEqual;
3634 }
3635 
3636 fn validJsonLineCount(text: []const u8) !usize {
3637     var count: usize = 0;
3638     var lines = std.mem.splitScalar(u8, text, '\n');
3639     while (lines.next()) |line| {
3640         if (line.len == 0) continue;
3641         var parsed = try std.json.parseFromSlice(
3642             std.json.Value,
3643             std.testing.allocator,
3644             line,
3645             .{},
3646         );
3647         parsed.deinit();
3648         count += 1;
3649     }
3650     return count;
3651 }
3652 
3653 fn writeComparisonWithAllocator(allocator: std.mem.Allocator) !void {
3654     var baseline = Analyzer.init(std.testing.allocator);
3655     defer baseline.deinit();
3656     try addZone(&baseline, "phase", 10);
3657     try addMemory(&baseline, "arena", 16);
3658     try addGpuTrace(&baseline, &.{.{ .name = "draw", .gpu_ns = 20 }});
3659     try addPlotTrace(
3660         &baseline,
3661         &.{.{ .name = "latency", .unit = "nanoseconds", .values = &.{ 10, 20 } }},
3662         false,
3663     );
3664     try addFrameTrace(
3665         &baseline,
3666         &.{.{ .name = "render", .durations_ns = &.{ 10, 10 } }},
3667         false,
3668     );
3669     var run = Analyzer.init(std.testing.allocator);
3670     defer run.deinit();
3671     try addZone(&run, "phase", 20);
3672     try addMemory(&run, "arena", 32);
3673     try addGpuTrace(&run, &.{.{ .name = "draw", .gpu_ns = 40 }});
3674     try addPlotTrace(
3675         &run,
3676         &.{.{ .name = "latency", .unit = "nanoseconds", .values = &.{ 20, 40 } }},
3677         false,
3678     );
3679     try addFrameTrace(
3680         &run,
3681         &.{.{ .name = "render", .durations_ns = &.{ 10, 20 } }},
3682         false,
3683     );
3684 
3685     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
3686     defer out.deinit();
3687     try writeText(allocator, &baseline, &run, &out.writer, .{});
3688 }
3689 
3690 fn addPlotTrace(
3691     analyzer: *Analyzer,
3692     sets: []const TestPlotSet,
3693     sequence_gap: bool,
3694 ) !void {
3695     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
3696     defer trace.deinit();
3697     var sequence: u64 = 1;
3698     var time_ns: u64 = 100;
3699     try (event.TraceEvent{
3700         .seq = sequence,
3701         .kind = .start,
3702         .time_ns = time_ns,
3703         .thread = 1,
3704         .name = "plots",
3705     }).writeJsonLine(&trace.writer);
3706     sequence += 1;
3707     if (sequence_gap) sequence += 1;
3708     for (sets) |set| {
3709         try writeTestPlotSet(&trace.writer, &sequence, &time_ns, set);
3710     }
3711     time_ns += 10;
3712     try (event.TraceEvent{
3713         .seq = sequence,
3714         .kind = .stop,
3715         .time_ns = time_ns,
3716         .thread = 1,
3717     }).writeJsonLine(&trace.writer);
3718     try analyzer.ingestJsonlBytes(trace.written());
3719 }
3720 
3721 fn writeTestPlotSet(
3722     writer: *std.Io.Writer,
3723     sequence: *u64,
3724     time_ns: *u64,
3725     set: TestPlotSet,
3726 ) !void {
3727     if (set.unit) |unit| try writeTestPlotConfig(writer, sequence, time_ns, set.name, unit);
3728     if (set.conflict) |unit| {
3729         try writeTestPlotConfig(writer, sequence, time_ns, set.name, unit);
3730     }
3731     if (set.missing_value) {
3732         time_ns.* += 10;
3733         try (event.TraceEvent{
3734             .seq = sequence.*,
3735             .kind = .plot,
3736             .time_ns = time_ns.*,
3737             .thread = 1,
3738             .name = set.name,
3739         }).writeJsonLine(writer);
3740         sequence.* += 1;
3741     }
3742     for (set.values) |value| {
3743         time_ns.* += 10;
3744         try (event.TraceEvent{
3745             .seq = sequence.*,
3746             .kind = .plot,
3747             .time_ns = time_ns.*,
3748             .thread = 1,
3749             .name = set.name,
3750             .value_f64 = value,
3751             .plot_kind = "float",
3752         }).writeJsonLine(writer);
3753         sequence.* += 1;
3754     }
3755 }
3756 
3757 fn writeTestPlotConfig(
3758     writer: *std.Io.Writer,
3759     sequence: *u64,
3760     time_ns: *u64,
3761     name: []const u8,
3762     unit: []const u8,
3763 ) !void {
3764     time_ns.* += 10;
3765     try (event.TraceEvent{
3766         .seq = sequence.*,
3767         .kind = .plot_config,
3768         .time_ns = time_ns.*,
3769         .thread = 1,
3770         .name = name,
3771         .color = 7,
3772         .plot_unit = unit,
3773         .plot_step = true,
3774         .plot_fill = true,
3775     }).writeJsonLine(writer);
3776     sequence.* += 1;
3777 }
3778 
3779 fn addFrameTrace(
3780     analyzer: *Analyzer,
3781     sets: []const TestFrameSet,
3782     sequence_gap: bool,
3783 ) !void {
3784     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
3785     defer trace.deinit();
3786     var sequence: u64 = 1;
3787     try (event.TraceEvent{
3788         .seq = sequence,
3789         .kind = .start,
3790         .time_ns = 1,
3791         .thread = 1,
3792         .name = "frames",
3793     }).writeJsonLine(&trace.writer);
3794     sequence += 1;
3795     if (sequence_gap) sequence += 1;
3796     var time_ns: u64 = 100;
3797     for (sets) |set| {
3798         try writeTestFrameMark(&trace.writer, &sequence, time_ns, set.name);
3799         for (set.durations_ns) |duration_ns| {
3800             time_ns += duration_ns;
3801             try writeTestFrameMark(&trace.writer, &sequence, time_ns, set.name);
3802         }
3803         time_ns += 10;
3804     }
3805     try (event.TraceEvent{
3806         .seq = sequence,
3807         .kind = .stop,
3808         .time_ns = time_ns + 10,
3809         .thread = 1,
3810     }).writeJsonLine(&trace.writer);
3811     try analyzer.ingestJsonlBytes(trace.written());
3812 }
3813 
3814 fn addOutOfOrderFrameTrace(analyzer: *Analyzer) !void {
3815     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
3816     defer trace.deinit();
3817     try (event.TraceEvent{
3818         .seq = 1,
3819         .kind = .start,
3820         .time_ns = 1,
3821         .thread = 1,
3822         .name = "frames",
3823     }).writeJsonLine(&trace.writer);
3824     try (event.TraceEvent{
3825         .seq = 2,
3826         .kind = .frame,
3827         .time_ns = 100,
3828         .thread = 1,
3829         .name = "render",
3830     }).writeJsonLine(&trace.writer);
3831     try (event.TraceEvent{
3832         .seq = 3,
3833         .kind = .frame,
3834         .time_ns = 120,
3835         .thread = 1,
3836         .name = "render",
3837     }).writeJsonLine(&trace.writer);
3838     try (event.TraceEvent{
3839         .seq = 4,
3840         .kind = .frame,
3841         .time_ns = 110,
3842         .thread = 1,
3843         .name = "render",
3844     }).writeJsonLine(&trace.writer);
3845     try (event.TraceEvent{
3846         .seq = 5,
3847         .kind = .stop,
3848         .time_ns = 140,
3849         .thread = 1,
3850     }).writeJsonLine(&trace.writer);
3851     try analyzer.ingestJsonlBytes(trace.written());
3852 }
3853 
3854 fn writeTestFrameMark(
3855     writer: *std.Io.Writer,
3856     sequence: *u64,
3857     time_ns: u64,
3858     name: []const u8,
3859 ) !void {
3860     try (event.TraceEvent{
3861         .seq = sequence.*,
3862         .kind = .frame,
3863         .time_ns = time_ns,
3864         .thread = 1,
3865         .name = name,
3866     }).writeJsonLine(writer);
3867     sequence.* += 1;
3868 }
3869 
3870 fn addGpuTrace(analyzer: *Analyzer, zones: []const TestGpuZone) !void {
3871     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
3872     defer trace.deinit();
3873     var sequence: u64 = 1;
3874     try (event.TraceEvent{
3875         .seq = sequence,
3876         .kind = .start,
3877         .time_ns = 900,
3878         .thread = 1,
3879         .name = "gpu",
3880     }).writeJsonLine(&trace.writer);
3881     sequence += 1;
3882     try (event.TraceEvent{
3883         .seq = sequence,
3884         .kind = .gpu_context,
3885         .time_ns = 1_000,
3886         .thread = 10,
3887         .name = "render",
3888         .gpu_context = 2,
3889         .gpu_time = 100,
3890         .gpu_period = 1,
3891         .gpu_context_type = "vulkan",
3892     }).writeJsonLine(&trace.writer);
3893     sequence += 1;
3894     var cpu_time: u64 = 1_010;
3895     var gpu_time: i64 = 110;
3896     for (zones, 0..) |zone, index| {
3897         const begin_query: u32 = @intCast(index * 2 + 1);
3898         const end_query = begin_query + 1;
3899         try writeTestGpuZone(
3900             &trace.writer,
3901             &sequence,
3902             &cpu_time,
3903             &gpu_time,
3904             begin_query,
3905             end_query,
3906             zone,
3907         );
3908     }
3909     try (event.TraceEvent{
3910         .seq = sequence,
3911         .kind = .stop,
3912         .time_ns = cpu_time + 10,
3913         .thread = 1,
3914     }).writeJsonLine(&trace.writer);
3915     try analyzer.ingestJsonlBytes(trace.written());
3916 }
3917 
3918 fn writeTestGpuZone(
3919     writer: *std.Io.Writer,
3920     sequence: *u64,
3921     cpu_time: *u64,
3922     gpu_time: *i64,
3923     begin_query: u32,
3924     end_query: u32,
3925     zone: TestGpuZone,
3926 ) !void {
3927     try (event.TraceEvent{
3928         .seq = sequence.*,
3929         .kind = .gpu_zone_begin,
3930         .time_ns = cpu_time.*,
3931         .thread = 10,
3932         .name = zone.name,
3933         .gpu_context = 2,
3934         .gpu_query = begin_query,
3935     }).writeJsonLine(writer);
3936     sequence.* += 1;
3937     try (event.TraceEvent{
3938         .seq = sequence.*,
3939         .kind = .gpu_zone_end,
3940         .time_ns = cpu_time.* + 10,
3941         .thread = 10,
3942         .gpu_context = 2,
3943         .gpu_query = end_query,
3944     }).writeJsonLine(writer);
3945     sequence.* += 1;
3946     try (event.TraceEvent{
3947         .seq = sequence.*,
3948         .kind = .gpu_time,
3949         .time_ns = cpu_time.* + 11,
3950         .thread = 10,
3951         .gpu_context = 2,
3952         .gpu_query = begin_query,
3953         .gpu_time = gpu_time.*,
3954     }).writeJsonLine(writer);
3955     sequence.* += 1;
3956     if (zone.complete) {
3957         try (event.TraceEvent{
3958             .seq = sequence.*,
3959             .kind = .gpu_time,
3960             .time_ns = cpu_time.* + 12,
3961             .thread = 10,
3962             .gpu_context = 2,
3963             .gpu_query = end_query,
3964             .gpu_time = gpu_time.* + @as(i64, @intCast(zone.gpu_ns)),
3965         }).writeJsonLine(writer);
3966         sequence.* += 1;
3967     }
3968     gpu_time.* += @intCast(zone.gpu_ns + 10);
3969     cpu_time.* += 20;
3970 }
3971 
3972 fn addZone(analyzer: *Analyzer, name: []const u8, duration_ns: u64) !void {
3973     const durations = [_]u64{duration_ns};
3974     return addZoneTrace(analyzer, &.{.{
3975         .name = name,
3976         .durations_ns = &durations,
3977     }}, false);
3978 }
3979 
3980 fn addZoneTrace(
3981     analyzer: *Analyzer,
3982     sets: []const TestZoneSet,
3983     sequence_gap: bool,
3984 ) !void {
3985     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
3986     defer trace.deinit();
3987     var sequence: u64 = 1;
3988     var time_ns: u64 = 100;
3989     var zone_id: u64 = 1;
3990     try (event.TraceEvent{ .seq = sequence, .kind = .start, .time_ns = time_ns })
3991         .writeJsonLine(&trace.writer);
3992     sequence += 1;
3993     if (sequence_gap) sequence += 1;
3994     for (sets) |set| for (set.durations_ns, 0..) |duration_ns, duration_index| {
3995         time_ns += 10;
3996         try (event.TraceEvent{
3997             .seq = sequence,
3998             .kind = .zone_begin,
3999             .time_ns = time_ns,
4000             .thread = 1,
4001             .id = zone_id,
4002             .name = set.name,
4003         }).writeJsonLine(&trace.writer);
4004         sequence += 1;
4005         const invalid = set.invalid_last and duration_index + 1 == set.durations_ns.len;
4006         const end_ns = if (invalid) time_ns - 1 else time_ns + duration_ns;
4007         try (event.TraceEvent{
4008             .seq = sequence,
4009             .kind = .zone_end,
4010             .time_ns = end_ns,
4011             .thread = 1,
4012             .id = zone_id,
4013         }).writeJsonLine(&trace.writer);
4014         sequence += 1;
4015         zone_id += 1;
4016         if (!invalid) time_ns = end_ns;
4017     };
4018     time_ns += 10;
4019     try (event.TraceEvent{ .seq = sequence, .kind = .stop, .time_ns = time_ns })
4020         .writeJsonLine(&trace.writer);
4021     try analyzer.ingestJsonlBytes(trace.written());
4022 }
4023 
4024 fn addMemory(analyzer: *Analyzer, name: []const u8, high_water_bytes: u64) !void {
4025     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
4026     defer trace.deinit();
4027     try (event.TraceEvent{
4028         .seq = 1,
4029         .kind = .alloc,
4030         .time_ns = 100,
4031         .thread = 1,
4032         .name = name,
4033         .address = 4096,
4034         .size = high_water_bytes,
4035     }).writeJsonLine(&trace.writer);
4036     try (event.TraceEvent{
4037         .seq = 2,
4038         .kind = .free,
4039         .time_ns = 110,
4040         .thread = 1,
4041         .name = name,
4042         .address = 4096,
4043     }).writeJsonLine(&trace.writer);
4044     try analyzer.ingestJsonlBytes(trace.written());
4045 }
4046 
4047 const MemoryFixtureOptions = struct {
4048     right_censored: bool = false,
4049     size_mismatch: bool = false,
4050 };
4051 
4052 fn addMemoryLifetimes(
4053     analyzer: *Analyzer,
4054     name: []const u8,
4055     size: u64,
4056     lifetimes_ns: []const u64,
4057     options: MemoryFixtureOptions,
4058 ) !void {
4059     std.debug.assert(lifetimes_ns.len <= 16);
4060     std.debug.assert(size < std.math.maxInt(u64));
4061     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
4062     defer trace.deinit();
4063     var sequence: u64 = 1;
4064     var time_ns: u64 = 100;
4065     var address: u64 = 4096;
4066     try (event.TraceEvent{
4067         .seq = sequence,
4068         .kind = .start,
4069         .time_ns = time_ns,
4070         .thread = 1,
4071         .name = "memory-compare",
4072     }).writeJsonLine(&trace.writer);
4073     sequence += 1;
4074     for (lifetimes_ns, 0..) |lifetime_ns, index| {
4075         time_ns += 10;
4076         try writeTestAllocation(&trace.writer, sequence, time_ns, name, address, size);
4077         sequence += 1;
4078         time_ns += lifetime_ns;
4079         const reported_size = if (options.size_mismatch and index == 0) size + 1 else size;
4080         try writeTestFree(&trace.writer, sequence, time_ns, name, address, reported_size);
4081         sequence += 1;
4082         address += 4096;
4083     }
4084     if (options.right_censored) {
4085         time_ns += 10;
4086         try writeTestAllocation(&trace.writer, sequence, time_ns, name, address, size);
4087         sequence += 1;
4088     }
4089     time_ns += 10;
4090     try (event.TraceEvent{
4091         .seq = sequence,
4092         .kind = .stop,
4093         .time_ns = time_ns,
4094         .thread = 1,
4095         .name = "memory-compare",
4096     }).writeJsonLine(&trace.writer);
4097     try analyzer.ingestJsonlBytes(trace.written());
4098 }
4099 
4100 fn writeTestAllocation(
4101     writer: *std.Io.Writer,
4102     sequence: u64,
4103     time_ns: u64,
4104     name: []const u8,
4105     address: u64,
4106     size: u64,
4107 ) !void {
4108     try (event.TraceEvent{
4109         .seq = sequence,
4110         .kind = .alloc,
4111         .time_ns = time_ns,
4112         .thread = 1,
4113         .name = name,
4114         .address = address,
4115         .size = size,
4116     }).writeJsonLine(writer);
4117 }
4118 
4119 fn writeTestFree(
4120     writer: *std.Io.Writer,
4121     sequence: u64,
4122     time_ns: u64,
4123     name: []const u8,
4124     address: u64,
4125     size: u64,
4126 ) !void {
4127     try (event.TraceEvent{
4128         .seq = sequence,
4129         .kind = .free,
4130         .time_ns = time_ns,
4131         .thread = 1,
4132         .name = name,
4133         .address = address,
4134         .size = size,
4135     }).writeJsonLine(writer);
4136 }