lib/memtrace/src/stack/budget.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const pretty_json = @import("pretty").json;
  3 const sys = @import("sys");
  4 
  5 const Allocator = std.mem.Allocator;
  6 const schema = "tiny.memtrace.causal-budget/v1";
  7 const max_budget_bytes: usize = 64 * 1024;
  8 const max_report_line_bytes: usize = 256 * 1024;
  9 
 10 pub const Format = enum {
 11     text,
 12     jsonl,
 13 };
 14 
 15 pub const Outcome = struct {
 16     passed: bool,
 17     checks: u32,
 18     failures: u32,
 19 };
 20 
 21 const Values = struct {
 22     roots: u128 = 0,
 23     root_requested_bytes: u128 = 0,
 24     backing_requested_bytes: u128 = 0,
 25     high_water_live_bytes: u128 = 0,
 26 };
 27 
 28 const Limits = struct {
 29     roots: ?u128 = null,
 30     root_requested_bytes: ?u128 = null,
 31     backing_requested_bytes: ?u128 = null,
 32     high_water_live_bytes: ?u128 = null,
 33 };
 34 
 35 const SourceRule = struct {
 36     name: []const u8,
 37     site_function_contains: ?[]const u8,
 38     caller_function_contains: ?[]const u8,
 39     caller_owner_function_contains: ?[]const u8,
 40     min_source_groups: u32,
 41     max_source_groups: u32,
 42     limits: Limits,
 43 };
 44 
 45 const Budget = struct {
 46     name: []const u8,
 47     totals: Limits,
 48     sources: []const SourceRule,
 49 };
 50 
 51 const SourceResult = struct {
 52     values: Values = .{},
 53     matched_groups: u32 = 0,
 54 };
 55 
 56 const Evaluation = struct {
 57     allocator: Allocator,
 58     budget: Budget,
 59     totals: ?Values = null,
 60     source_results: []SourceResult,
 61     summary_status_complete: bool = false,
 62     sources_complete: bool = false,
 63 
 64     fn init(allocator: Allocator, budget: Budget) !Evaluation {
 65         const results = try allocator.alloc(SourceResult, budget.sources.len);
 66         @memset(results, .{});
 67         return .{
 68             .allocator = allocator,
 69             .budget = budget,
 70             .source_results = results,
 71         };
 72     }
 73 
 74     fn ingest(self: *Evaluation, line: []const u8) !void {
 75         var parsed = try std.json.parseFromSlice(
 76             std.json.Value,
 77             self.allocator,
 78             line,
 79             .{},
 80         );
 81         defer parsed.deinit();
 82         const row = try object(parsed.value);
 83         const kind = try requiredString(row, "kind");
 84         if (std.mem.eql(u8, kind, "causal_root_summary")) {
 85             if (self.totals != null) return error.DuplicateCausalRootSummary;
 86             const status = try requiredString(row, "status");
 87             self.summary_status_complete = std.mem.eql(
 88                 u8,
 89                 status,
 90                 "process_memory_operation_complete",
 91             );
 92             const source_groups = try requiredUnsigned(row, "source_groups");
 93             const displayed_sources = try requiredUnsigned(
 94                 row,
 95                 "displayed_sources",
 96             );
 97             self.sources_complete = source_groups == displayed_sources;
 98             self.totals = try values(row);
 99             return;
100         }
101         if (!std.mem.eql(u8, kind, "causal_root_source")) return;
102         const site = try requiredString(row, "site_function");
103         const caller = optionalString(row, "caller_function");
104         const caller_owner = optionalString(row, "caller_owner_function");
105         const actual = try values(row);
106         for (self.budget.sources, self.source_results) |rule, *result| {
107             if (rule.site_function_contains) |expected| {
108                 if (!std.mem.containsAtLeast(
109                     u8,
110                     site,
111                     1,
112                     expected,
113                 )) {
114                     continue;
115                 }
116             }
117             if (rule.caller_function_contains) |expected| {
118                 const actual_caller = caller orelse continue;
119                 if (!std.mem.containsAtLeast(
120                     u8,
121                     actual_caller,
122                     1,
123                     expected,
124                 )) {
125                     continue;
126                 }
127             }
128             if (rule.caller_owner_function_contains) |expected| {
129                 const actual_owner = caller_owner orelse continue;
130                 if (!std.mem.containsAtLeast(
131                     u8,
132                     actual_owner,
133                     1,
134                     expected,
135                 )) {
136                     continue;
137                 }
138             }
139             result.matched_groups = try std.math.add(
140                 u32,
141                 result.matched_groups,
142                 1,
143             );
144             try addValues(&result.values, actual);
145         }
146     }
147 
148     fn assess(
149         self: *const Evaluation,
150         writer: *std.Io.Writer,
151         format: Format,
152         report_path: []const u8,
153     ) !Outcome {
154         const totals = self.totals orelse
155             return error.MissingCausalRootSummary;
156         if (!self.sources_complete) return error.TruncatedCausalRootSources;
157         var outcome = Outcome{
158             .passed = self.summary_status_complete,
159             .checks = 1,
160             .failures = @intFromBool(!self.summary_status_complete),
161         };
162         try writeStatusCheck(
163             writer,
164             format,
165             "capture",
166             "complete",
167             @intFromBool(self.summary_status_complete),
168             1,
169             self.summary_status_complete,
170         );
171         try assessLimits(
172             writer,
173             format,
174             "total",
175             totals,
176             self.budget.totals,
177             &outcome,
178         );
179         for (
180             self.budget.sources,
181             self.source_results,
182         ) |rule, result| {
183             const matched = result.matched_groups >= rule.min_source_groups and
184                 result.matched_groups <= rule.max_source_groups;
185             outcome.checks = try std.math.add(u32, outcome.checks, 1);
186             if (!matched) {
187                 outcome.passed = false;
188                 outcome.failures = try std.math.add(
189                     u32,
190                     outcome.failures,
191                     1,
192                 );
193             }
194             try writeRangeCheck(
195                 writer,
196                 format,
197                 rule.name,
198                 "matched_source_groups",
199                 result.matched_groups,
200                 rule.min_source_groups,
201                 rule.max_source_groups,
202                 matched,
203             );
204             try assessLimits(
205                 writer,
206                 format,
207                 rule.name,
208                 result.values,
209                 rule.limits,
210                 &outcome,
211             );
212         }
213         try writeOutcome(
214             writer,
215             format,
216             self.budget.name,
217             report_path,
218             outcome,
219         );
220         return outcome;
221     }
222 };
223 
224 pub fn checkFromPaths(
225     allocator: Allocator,
226     report_path: []const u8,
227     budget_path: []const u8,
228     writer: *std.Io.Writer,
229     format: Format,
230 ) !Outcome {
231     var arena_state = std.heap.ArenaAllocator.init(allocator);
232     defer arena_state.deinit();
233     const arena = arena_state.allocator();
234     const budget_bytes = try sys.fs.readFileAlloc(
235         arena,
236         budget_path,
237         max_budget_bytes,
238     );
239     const budget = try parseBudget(arena, budget_bytes);
240     var evaluation = try Evaluation.init(arena, budget);
241     try ingestPath(&evaluation, report_path);
242     return try evaluation.assess(writer, format, report_path);
243 }
244 
245 fn ingestPath(evaluation: *Evaluation, path: []const u8) !void {
246     var file = try sys.fs.cwd().openFile(sys.fs.debugIo(), path, .{});
247     defer file.close(sys.fs.debugIo());
248     var buffer: [max_report_line_bytes]u8 = undefined;
249     var reader = file.reader(sys.fs.debugIo(), &buffer);
250     while (true) {
251         const line = reader.interface.takeDelimiter('\n') catch |err| switch (err) {
252             error.ReadFailed => return reader.err.?,
253             else => return err,
254         };
255         const actual = line orelse break;
256         const trimmed = std.mem.trim(u8, actual, " \t\r");
257         if (trimmed.len != 0) try evaluation.ingest(trimmed);
258     }
259 }
260 
261 fn parseBudget(allocator: Allocator, bytes: []const u8) !Budget {
262     const parsed = try std.json.parseFromSliceLeaky(
263         std.json.Value,
264         allocator,
265         bytes,
266         .{},
267     );
268     const root = try object(parsed);
269     const actual_schema = try requiredString(root, "schema");
270     if (!std.mem.eql(u8, actual_schema, schema)) {
271         return error.UnsupportedCausalBudgetSchema;
272     }
273     const source_values = try array(root.get("sources") orelse
274         return error.InvalidCausalBudget);
275     const sources = try allocator.alloc(SourceRule, source_values.items.len);
276     for (source_values.items, sources) |value, *source| {
277         const row = try object(value);
278         source.* = .{
279             .name = try requiredString(row, "name"),
280             .site_function_contains = try optionalBudgetString(
281                 row,
282                 "site_function_contains",
283             ),
284             .caller_function_contains = try optionalBudgetString(
285                 row,
286                 "caller_function_contains",
287             ),
288             .caller_owner_function_contains = try optionalBudgetString(
289                 row,
290                 "caller_owner_function_contains",
291             ),
292             .min_source_groups = try sourceGroupLimit(
293                 row,
294                 "min_source_groups",
295                 1,
296             ),
297             .max_source_groups = try sourceGroupLimit(
298                 row,
299                 "max_source_groups",
300                 1,
301             ),
302             .limits = try parseLimits(row),
303         };
304         if (source.name.len == 0 or !hasSourceSelector(source.*)) {
305             return error.InvalidCausalBudget;
306         }
307         if (source.min_source_groups > source.max_source_groups) {
308             return error.InvalidCausalBudget;
309         }
310     }
311     return .{
312         .name = try requiredString(root, "name"),
313         .totals = try parseLimits(try object(root.get("totals") orelse
314             return error.InvalidCausalBudget)),
315         .sources = sources,
316     };
317 }
318 
319 fn optionalBudgetString(
320     row: std.json.ObjectMap,
321     key: []const u8,
322 ) !?[]const u8 {
323     const value = row.get(key) orelse return null;
324     const string = switch (value) {
325         .string => |actual| actual,
326         else => return error.InvalidCausalBudget,
327     };
328     if (string.len == 0) return error.InvalidCausalBudget;
329     return string;
330 }
331 
332 fn hasSourceSelector(source: SourceRule) bool {
333     return source.site_function_contains != null or
334         source.caller_function_contains != null or
335         source.caller_owner_function_contains != null;
336 }
337 
338 fn sourceGroupLimit(
339     row: std.json.ObjectMap,
340     key: []const u8,
341     default: u32,
342 ) !u32 {
343     const value = try optionalUnsigned(row, key) orelse return default;
344     return std.math.cast(u32, value) orelse error.InvalidCausalBudget;
345 }
346 
347 fn parseLimits(row: std.json.ObjectMap) !Limits {
348     return .{
349         .roots = try optionalUnsigned(row, "max_roots"),
350         .root_requested_bytes = try optionalUnsigned(
351             row,
352             "max_root_requested_bytes",
353         ),
354         .backing_requested_bytes = try optionalUnsigned(
355             row,
356             "max_backing_requested_bytes",
357         ),
358         .high_water_live_bytes = try optionalUnsigned(
359             row,
360             "max_high_water_live_bytes",
361         ),
362     };
363 }
364 
365 fn values(row: std.json.ObjectMap) !Values {
366     return .{
367         .roots = try requiredUnsigned(row, "roots"),
368         .root_requested_bytes = try requiredUnsigned(
369             row,
370             "root_requested_bytes",
371         ),
372         .backing_requested_bytes = try requiredUnsigned(
373             row,
374             "backing_requested_bytes",
375         ),
376         .high_water_live_bytes = try requiredUnsigned(
377             row,
378             "high_water_live_bytes",
379         ),
380     };
381 }
382 
383 fn addValues(target: *Values, next: Values) !void {
384     target.roots = try std.math.add(u128, target.roots, next.roots);
385     target.root_requested_bytes = try std.math.add(
386         u128,
387         target.root_requested_bytes,
388         next.root_requested_bytes,
389     );
390     target.backing_requested_bytes = try std.math.add(
391         u128,
392         target.backing_requested_bytes,
393         next.backing_requested_bytes,
394     );
395     target.high_water_live_bytes = try std.math.add(
396         u128,
397         target.high_water_live_bytes,
398         next.high_water_live_bytes,
399     );
400 }
401 
402 fn assessLimits(
403     writer: *std.Io.Writer,
404     format: Format,
405     scope: []const u8,
406     actual: Values,
407     limits: Limits,
408     outcome: *Outcome,
409 ) !void {
410     try assessLimit(
411         writer,
412         format,
413         scope,
414         "roots",
415         actual.roots,
416         limits.roots,
417         outcome,
418     );
419     try assessLimit(
420         writer,
421         format,
422         scope,
423         "root_requested_bytes",
424         actual.root_requested_bytes,
425         limits.root_requested_bytes,
426         outcome,
427     );
428     try assessLimit(
429         writer,
430         format,
431         scope,
432         "backing_requested_bytes",
433         actual.backing_requested_bytes,
434         limits.backing_requested_bytes,
435         outcome,
436     );
437     try assessLimit(
438         writer,
439         format,
440         scope,
441         "high_water_live_bytes",
442         actual.high_water_live_bytes,
443         limits.high_water_live_bytes,
444         outcome,
445     );
446 }
447 
448 fn assessLimit(
449     writer: *std.Io.Writer,
450     format: Format,
451     scope: []const u8,
452     metric: []const u8,
453     actual: u128,
454     maybe_maximum: ?u128,
455     outcome: *Outcome,
456 ) !void {
457     const maximum = maybe_maximum orelse return;
458     const passed = actual <= maximum;
459     outcome.checks = try std.math.add(u32, outcome.checks, 1);
460     if (!passed) {
461         outcome.passed = false;
462         outcome.failures = try std.math.add(u32, outcome.failures, 1);
463     }
464     try writeStatusCheck(
465         writer,
466         format,
467         scope,
468         metric,
469         actual,
470         maximum,
471         passed,
472     );
473 }
474 
475 fn writeRangeCheck(
476     writer: *std.Io.Writer,
477     format: Format,
478     scope: []const u8,
479     metric: []const u8,
480     actual: u128,
481     minimum: u128,
482     maximum: u128,
483     passed: bool,
484 ) !void {
485     switch (format) {
486         .text => try writer.print(
487             "causal_budget_check scope={s} metric={s} actual={d} min={d} " ++
488                 "max={d} status={s}\n",
489             .{
490                 scope,
491                 metric,
492                 actual,
493                 minimum,
494                 maximum,
495                 if (passed) "pass" else "fail",
496             },
497         ),
498         .jsonl => {
499             var stream = pretty_json.Writer.init(writer, .minified);
500             const row = try stream.object();
501             try row.field("kind", "causal_budget_check");
502             try row.field("scope", scope);
503             try row.field("metric", metric);
504             try row.field("actual", actual);
505             try row.field("min", minimum);
506             try row.field("max", maximum);
507             try row.field("status", if (passed) "pass" else "fail");
508             try row.endLine();
509         },
510     }
511 }
512 
513 fn writeStatusCheck(
514     writer: *std.Io.Writer,
515     format: Format,
516     scope: []const u8,
517     metric: []const u8,
518     actual: u128,
519     maximum: u128,
520     passed: bool,
521 ) !void {
522     switch (format) {
523         .text => try writer.print(
524             "causal_budget_check scope={s} metric={s} actual={d} max={d} " ++
525                 "status={s}\n",
526             .{
527                 scope,
528                 metric,
529                 actual,
530                 maximum,
531                 if (passed) "pass" else "fail",
532             },
533         ),
534         .jsonl => {
535             var stream = pretty_json.Writer.init(writer, .minified);
536             const row = try stream.object();
537             try row.field("kind", "causal_budget_check");
538             try row.field("scope", scope);
539             try row.field("metric", metric);
540             try row.field("actual", actual);
541             try row.field("max", maximum);
542             try row.field("status", if (passed) "pass" else "fail");
543             try row.endLine();
544         },
545     }
546 }
547 
548 fn writeOutcome(
549     writer: *std.Io.Writer,
550     format: Format,
551     name: []const u8,
552     report_path: []const u8,
553     outcome: Outcome,
554 ) !void {
555     switch (format) {
556         .text => try writer.print(
557             "causal_budget name={s} report={s} checks={d} failures={d} " ++
558                 "status={s}\n",
559             .{
560                 name,
561                 report_path,
562                 outcome.checks,
563                 outcome.failures,
564                 if (outcome.passed) "pass" else "fail",
565             },
566         ),
567         .jsonl => {
568             var stream = pretty_json.Writer.init(writer, .minified);
569             const row = try stream.object();
570             try row.field("kind", "causal_budget");
571             try row.field("name", name);
572             try row.field("report", report_path);
573             try row.field("checks", outcome.checks);
574             try row.field("failures", outcome.failures);
575             try row.field("status", if (outcome.passed) "pass" else "fail");
576             try row.endLine();
577         },
578     }
579 }
580 
581 fn object(value: std.json.Value) !std.json.ObjectMap {
582     return switch (value) {
583         .object => |actual| actual,
584         else => error.InvalidCausalBudget,
585     };
586 }
587 
588 fn array(value: std.json.Value) !std.json.Array {
589     return switch (value) {
590         .array => |actual| actual,
591         else => error.InvalidCausalBudget,
592     };
593 }
594 
595 fn requiredString(
596     row: std.json.ObjectMap,
597     key: []const u8,
598 ) ![]const u8 {
599     return optionalString(row, key) orelse error.InvalidCausalBudget;
600 }
601 
602 fn optionalString(
603     row: std.json.ObjectMap,
604     key: []const u8,
605 ) ?[]const u8 {
606     const value = row.get(key) orelse return null;
607     return switch (value) {
608         .string => |actual| actual,
609         else => null,
610     };
611 }
612 
613 fn requiredUnsigned(
614     row: std.json.ObjectMap,
615     key: []const u8,
616 ) !u128 {
617     return try optionalUnsigned(row, key) orelse
618         error.InvalidCausalBudget;
619 }
620 
621 fn optionalUnsigned(
622     row: std.json.ObjectMap,
623     key: []const u8,
624 ) !?u128 {
625     const value = row.get(key) orelse return null;
626     return switch (value) {
627         .integer => |actual| if (actual < 0)
628             error.InvalidCausalBudget
629         else
630             @intCast(actual),
631         else => error.InvalidCausalBudget,
632     };
633 }
634 
635 test "causal budget enforces totals and one exact source group" {
636     var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
637     defer arena_state.deinit();
638     const arena = arena_state.allocator();
639     const budget = try parseBudget(arena,
640         \\{
641         \\  "schema":"tiny.memtrace.causal-budget/v1",
642         \\  "name":"fixture",
643         \\  "totals":{"max_roots":4,"max_high_water_live_bytes":64},
644         \\  "sources":[{
645         \\    "name":"tree",
646         \\    "site_function_contains":"Tree.create",
647         \\    "caller_function_contains":"compile",
648         \\    "caller_owner_function_contains":"compileDecodedOnce",
649         \\    "max_roots":2,
650         \\    "max_root_requested_bytes":32,
651         \\    "max_backing_requested_bytes":48,
652         \\    "max_high_water_live_bytes":16
653         \\  }]
654         \\}
655     );
656     var evaluation = try Evaluation.init(arena, budget);
657     try evaluation.ingest(
658         \\{"kind":"causal_root_summary","status":"process_memory_operation_complete","roots":4,"root_requested_bytes":64,"backing_requested_bytes":48,"high_water_live_bytes":64,"source_groups":1,"displayed_sources":1}
659     );
660     try evaluation.ingest(
661         \\{"kind":"causal_root_source","roots":2,"root_requested_bytes":32,"backing_requested_bytes":48,"high_water_live_bytes":16,"site_function":"Tree.create","caller_function":"Compiler.compile","caller_owner_function":"Compiler.compileDecodedOnce"}
662     );
663     var output = std.Io.Writer.Allocating.init(std.testing.allocator);
664     defer output.deinit();
665     const outcome = try evaluation.assess(
666         &output.writer,
667         .jsonl,
668         "fixture.jsonl",
669     );
670     try std.testing.expect(outcome.passed);
671     try std.testing.expectEqual(@as(u32, 0), outcome.failures);
672     try std.testing.expect(std.mem.indexOf(
673         u8,
674         output.written(),
675         "\"status\":\"pass\"",
676     ) != null);
677 }
678 
679 test "causal budget rejects a duplicated source match" {
680     var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
681     defer arena_state.deinit();
682     const arena = arena_state.allocator();
683     const budget = try parseBudget(arena,
684         \\{"schema":"tiny.memtrace.causal-budget/v1","name":"fixture","totals":{},"sources":[{"name":"source","site_function_contains":"alloc"}]}
685     );
686     var evaluation = try Evaluation.init(arena, budget);
687     try evaluation.ingest(
688         \\{"kind":"causal_root_summary","status":"process_memory_operation_complete","roots":1,"root_requested_bytes":1,"backing_requested_bytes":1,"high_water_live_bytes":1,"source_groups":2,"displayed_sources":2}
689     );
690     const source =
691         \\{"kind":"causal_root_source","roots":1,"root_requested_bytes":1,"backing_requested_bytes":1,"high_water_live_bytes":1,"site_function":"alloc"}
692     ;
693     try evaluation.ingest(source);
694     try evaluation.ingest(source);
695     var output = std.Io.Writer.Allocating.init(std.testing.allocator);
696     defer output.deinit();
697     const outcome = try evaluation.assess(
698         &output.writer,
699         .text,
700         "fixture.jsonl",
701     );
702     try std.testing.expect(!outcome.passed);
703     try std.testing.expectEqual(@as(u32, 1), outcome.failures);
704 }
705 
706 test "causal budget aggregates a bounded source group family" {
707     var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
708     defer arena_state.deinit();
709     const arena = arena_state.allocator();
710     const budget = try parseBudget(arena,
711         \\{
712         \\  "schema":"tiny.memtrace.causal-budget/v1",
713         \\  "name":"fixture",
714         \\  "totals":{},
715         \\  "sources":[{
716         \\    "name":"located",
717         \\    "site_function_contains":"LocatedCloner",
718         \\    "min_source_groups":2,
719         \\    "max_source_groups":3,
720         \\    "max_roots":5,
721         \\    "max_high_water_live_bytes":48
722         \\  }]
723         \\}
724     );
725     var evaluation = try Evaluation.init(arena, budget);
726     try evaluation.ingest(
727         \\{"kind":"causal_root_summary","status":"process_memory_operation_complete","roots":5,"root_requested_bytes":80,"backing_requested_bytes":64,"high_water_live_bytes":48,"source_groups":2,"displayed_sources":2}
728     );
729     try evaluation.ingest(
730         \\{"kind":"causal_root_source","roots":2,"root_requested_bytes":32,"backing_requested_bytes":16,"high_water_live_bytes":16,"site_function":"LocatedCloner.cloneExpr"}
731     );
732     try evaluation.ingest(
733         \\{"kind":"causal_root_source","roots":3,"root_requested_bytes":48,"backing_requested_bytes":48,"high_water_live_bytes":32,"site_function":"LocatedCloner.cloneSite"}
734     );
735     var output = std.Io.Writer.Allocating.init(std.testing.allocator);
736     defer output.deinit();
737     const outcome = try evaluation.assess(
738         &output.writer,
739         .jsonl,
740         "fixture.jsonl",
741     );
742     try std.testing.expect(outcome.passed);
743     try std.testing.expectEqual(@as(u32, 0), outcome.failures);
744     try std.testing.expect(std.mem.indexOf(
745         u8,
746         output.written(),
747         "\"actual\":2,\"min\":2,\"max\":3",
748     ) != null);
749 }
750 
751 test "causal budget selects a complete caller family without a site filter" {
752     var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
753     defer arena_state.deinit();
754     const arena = arena_state.allocator();
755     const budget = try parseBudget(arena,
756         \\{
757         \\  "schema":"tiny.memtrace.causal-budget/v1",
758         \\  "name":"fixture",
759         \\  "totals":{},
760         \\  "sources":[{
761         \\    "name":"owned-authorship",
762         \\    "caller_function_contains":"value.own.Cloner.cloneExpr",
763         \\    "min_source_groups":2,
764         \\    "max_source_groups":2,
765         \\    "max_roots":5,
766         \\    "max_high_water_live_bytes":48
767         \\  }]
768         \\}
769     );
770     var evaluation = try Evaluation.init(arena, budget);
771     try evaluation.ingest(
772         \\{"kind":"causal_root_summary","status":"process_memory_operation_complete","roots":6,"root_requested_bytes":96,"backing_requested_bytes":64,"high_water_live_bytes":64,"source_groups":3,"displayed_sources":3}
773     );
774     try evaluation.ingest(
775         \\{"kind":"causal_root_source","roots":2,"root_requested_bytes":32,"backing_requested_bytes":16,"high_water_live_bytes":16,"site_function":"LocatedCloner.cloneExpr","caller_function":"value.own.Cloner.cloneExpr"}
776     );
777     try evaluation.ingest(
778         \\{"kind":"causal_root_source","roots":3,"root_requested_bytes":48,"backing_requested_bytes":48,"high_water_live_bytes":32,"site_function":"LocatedCloner.cloneSite","caller_function":"value.own.Cloner.cloneExpr"}
779     );
780     try evaluation.ingest(
781         \\{"kind":"causal_root_source","roots":1,"root_requested_bytes":16,"backing_requested_bytes":0,"high_water_live_bytes":16,"site_function":"LocatedCloner.cloneExpr","caller_function":"other.Cloner.cloneExpr"}
782     );
783     var output = std.Io.Writer.Allocating.init(std.testing.allocator);
784     defer output.deinit();
785     const outcome = try evaluation.assess(
786         &output.writer,
787         .jsonl,
788         "fixture.jsonl",
789     );
790     try std.testing.expect(outcome.passed);
791     try std.testing.expectEqual(@as(u32, 0), outcome.failures);
792     try std.testing.expect(std.mem.indexOf(
793         u8,
794         output.written(),
795         "\"actual\":2,\"min\":2,\"max\":2",
796     ) != null);
797 }
798 
799 test "causal budget rejects an inverted source group range" {
800     var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
801     defer arena_state.deinit();
802     const arena = arena_state.allocator();
803     try std.testing.expectError(
804         error.InvalidCausalBudget,
805         parseBudget(arena,
806             \\{
807             \\  "schema":"tiny.memtrace.causal-budget/v1",
808             \\  "name":"fixture",
809             \\  "totals":{},
810             \\  "sources":[{
811             \\    "name":"located",
812             \\    "site_function_contains":"LocatedCloner",
813             \\    "min_source_groups":3,
814             \\    "max_source_groups":2
815             \\  }]
816             \\}
817         ),
818     );
819 }
820 
821 test "causal budget rejects a source rule without a selector" {
822     var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
823     defer arena_state.deinit();
824     const arena = arena_state.allocator();
825     try std.testing.expectError(
826         error.InvalidCausalBudget,
827         parseBudget(arena,
828             \\{
829             \\  "schema":"tiny.memtrace.causal-budget/v1",
830             \\  "name":"fixture",
831             \\  "totals":{},
832             \\  "sources":[{"name":"everything"}]
833             \\}
834         ),
835     );
836     try std.testing.expectError(
837         error.InvalidCausalBudget,
838         parseBudget(arena,
839             \\{
840             \\  "schema":"tiny.memtrace.causal-budget/v1",
841             \\  "name":"fixture",
842             \\  "totals":{},
843             \\  "sources":[{
844             \\    "name":"everything",
845             \\    "caller_function_contains":""
846             \\  }]
847             \\}
848         ),
849     );
850 }