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 }