lib/choir/src/core/interfaces/effects.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const ir = @import("../root.zig");
  3 const interfaces = @import("root.zig");
  4 
  5 /// Shared premise for value-level floating operations, independent of any dialect.
  6 pub const ArithmeticPolicy = struct {
  7     exceptions_masked: bool = true,
  8     default_rounding: bool = true,
  9     environment_observable: bool = false,
 10 
 11     pub fn permitsFloatingValues(self: ArithmeticPolicy) bool {
 12         return self.exceptions_masked and self.default_rounding and !self.environment_observable;
 13     }
 14 };
 15 
 16 pub const ContextPremise = enum { floating_environment };
 17 
 18 /// Subjects are local to the declaring operation. Different SSA values may alias.
 19 pub const Subject = union(enum) {
 20     operation,
 21     operand: usize,
 22     result: usize,
 23     global: []const u8,
 24     region: usize,
 25 };
 26 
 27 pub const Footprint = struct {
 28     offset: u64,
 29     length: u64,
 30 };
 31 
 32 pub const Scope = union(enum) {
 33     unknown,
 34     operation,
 35     region: usize,
 36     named: []const u8,
 37 };
 38 
 39 pub const Resource = struct {
 40     subject: Subject = .operation,
 41     address_space: ?u32 = null,
 42     bytes: ?Footprint = null,
 43     alias_scope: Scope = .unknown,
 44     lifetime: Scope = .unknown,
 45     allocator_domain: ?[]const u8 = null,
 46     ordering_scope: Scope = .unknown,
 47     state_key: ?[]const u8 = null,
 48 };
 49 
 50 pub const EventKind = enum {
 51     read,
 52     write,
 53     failure,
 54     trap,
 55     diverge,
 56     allocate,
 57     free,
 58     retain,
 59     release,
 60     borrow,
 61     move,
 62     foreign,
 63     io,
 64     state_observe,
 65     state_update,
 66     state_draw,
 67     state_memo,
 68     launch,
 69     synchronize,
 70 };
 71 
 72 pub const Event = struct {
 73     kind: EventKind,
 74     resource: Resource = .{},
 75     /// Atomic and volatile accesses are ordered even if their value is unused.
 76     ordered: bool = false,
 77     failure_name: ?[]const u8 = null,
 78 };
 79 
 80 pub const RequirementKind = enum {
 81     nonzero,
 82     quotient_representable,
 83     in_bounds,
 84     live,
 85     terminates,
 86     execution_context,
 87     conversion_representable,
 88     callee_contract,
 89 };
 90 
 91 /// A requirement is a premise to prove at the use, never a producer-supplied proof.
 92 pub const Requirement = struct {
 93     kind: RequirementKind,
 94     subject: Subject,
 95     related: ?Subject = null,
 96 };
 97 
 98 pub const Ownership = enum { none, owned, borrowed, transferred, unknown };
 99 
100 pub const ResultFact = struct {
101     index: usize,
102     fresh_identity: bool = false,
103     alias: ?Subject = null,
104     ownership: Ownership = .unknown,
105     lifetime: Scope = .unknown,
106 };
107 
108 pub const Execution = enum { immediate, conditional, repeated, latent, unknown };
109 
110 pub const RegionFact = struct {
111     index: usize,
112     execution: Execution = .unknown,
113     /// Repetition is not a termination proof, even when every child is total.
114     may_diverge: bool = true,
115     captures: bool = true,
116 };
117 
118 pub const Binding = struct {
119     region: usize,
120     argument: usize,
121     source: Subject,
122 };
123 
124 /// Order in this sequence is event order within the operation, not set membership.
125 pub const Fact = union(enum) {
126     event: Event,
127     requirement: Requirement,
128     result: ResultFact,
129     region: RegionFact,
130     binding: Binding,
131     premise: ContextPremise,
132 };
133 
134 pub const Facts = struct {
135     records: []const Fact = &.{},
136     complete: bool = false,
137 };
138 
139 /// Contract entries and arity determine storage before enumeration. No growth occurs.
140 pub const Capacity = struct {
141     entries: usize = 0,
142     per_operand: usize = 0,
143     per_result: usize = 0,
144     per_region: usize = 0,
145 
146     pub fn count(self: Capacity, operands: usize, results: usize, regions: usize) ?usize {
147         var record_count = self.entries;
148         const counts = [_]usize{ operands, results, regions };
149         const rates = [_]usize{ self.per_operand, self.per_result, self.per_region };
150         for (counts, rates) |n, rate| {
151             const increment = std.math.mul(usize, n, rate) catch return null;
152             record_count = std.math.add(usize, record_count, increment) catch return null;
153         }
154         return record_count;
155     }
156 };
157 
158 pub const Collector = struct {
159     storage: []Fact,
160     used: usize = 0,
161     complete: bool = false,
162     exhausted: bool = false,
163     observer: ?Observer = null,
164 
165     const Observer = struct {
166         context: *anyopaque,
167         limit: usize,
168         visit: *const fn (*anyopaque, Fact) void,
169     };
170 
171     pub fn append(self: *Collector, fact: Fact) void {
172         if (self.observer) |observer| {
173             if (self.used == observer.limit) {
174                 self.exhausted = true;
175                 return;
176             }
177             observer.visit(observer.context, fact);
178             self.used += 1;
179             return;
180         }
181         std.debug.assert(self.used <= self.storage.len);
182         if (self.used == self.storage.len) {
183             self.exhausted = true;
184             return;
185         }
186         self.storage[self.used] = fact;
187         self.used += 1;
188         std.debug.assert(self.used <= self.storage.len);
189     }
190 
191     pub fn view(self: *const Collector) Facts {
192         if (self.observer != null) return .{};
193         return .{
194             .records = self.storage[0..self.used],
195             .complete = self.complete and !self.exhausted,
196         };
197     }
198 
199     /// Scalar value results carry no identity or ownership. This does not assert completeness.
200     pub fn valueResults(self: *Collector, op: *const ir.Operation) void {
201         for (0..op.getNumResults()) |index| {
202             self.append(.{ .result = .{ .index = index, .ownership = .none } });
203         }
204     }
205 };
206 
207 pub const Spec = struct {
208     facts: []const Fact = &.{},
209     complete: bool = false,
210     capacity: Capacity = .{},
211     enumerate: ?*const fn (*const ir.Operation, *Collector) void = null,
212 };
213 
214 pub const EffectOpInterface = struct {
215     pub const interface_name = "ir.interface.effects";
216     pub const id = interfaces.interfaceId(interface_name);
217 
218     pub const VTable = struct {
219         capacity: Capacity,
220         collect: *const fn (*const anyopaque, *Collector) void,
221     };
222 
223     pub fn entry(vtable: *const VTable) interfaces.InterfaceEntry {
224         return .{ .id = id, .vtable = vtable };
225     }
226 
227     pub fn entryFor(comptime spec: Spec) interfaces.InterfaceEntry {
228         const Implementation = struct {
229             fn collect(raw: *const anyopaque, collector: *Collector) void {
230                 const op: *const ir.Operation = @ptrCast(@alignCast(raw));
231                 collector.complete = spec.complete;
232                 for (spec.facts) |fact| collector.append(fact);
233                 if (spec.enumerate) |enumerate| enumerate(op, collector);
234             }
235 
236             const vtable = VTable{
237                 .capacity = .{
238                     .entries = spec.facts.len + spec.capacity.entries,
239                     .per_operand = spec.capacity.per_operand,
240                     .per_result = spec.capacity.per_result,
241                     .per_region = spec.capacity.per_region,
242                 },
243                 .collect = collect,
244             };
245         };
246         return entry(&Implementation.vtable);
247     }
248 };
249 
250 pub const Declaration = struct {
251     storage: []Fact = &.{},
252     facts: Facts = .{},
253     exhausted: bool = false,
254 
255     pub fn deinit(self: *Declaration, allocator: std.mem.Allocator) void {
256         allocator.free(self.storage);
257         self.* = .{};
258     }
259 };
260 
261 pub fn inspect(allocator: std.mem.Allocator, op: *ir.Operation) !Declaration {
262     const vtable = op.getInterface(EffectOpInterface) orelse return .{};
263     const count = vtable.capacity.count(
264         op.getNumOperands(),
265         op.getNumResults(),
266         op.getNumRegions(),
267     ) orelse return .{ .exhausted = true };
268     const storage = try allocator.alloc(Fact, count);
269     errdefer allocator.free(storage);
270     var collector = Collector{ .storage = storage };
271     vtable.collect(op, &collector);
272     try validate(op, collector.view());
273     return .{ .storage = storage, .facts = collector.view(), .exhausted = collector.exhausted };
274 }
275 
276 /// Collect into caller-admitted storage. Recollect after every semantic edit.
277 pub fn collectInto(op: *ir.Operation, storage: []Fact) !Facts {
278     const vtable = op.getInterface(EffectOpInterface) orelse return .{};
279     var collector = Collector{ .storage = storage };
280     vtable.collect(op, &collector);
281     try validate(op, collector.view());
282     return collector.view();
283 }
284 
285 pub const ShapeError = error{
286     InvalidEffectPremise,
287     InvalidEffectSubject,
288     InvalidEffectFootprint,
289     InvalidEffectScope,
290     InvalidEffectResult,
291     InvalidEffectRegion,
292     InvalidEffectBinding,
293     DuplicateEffectResult,
294     DuplicateEffectRegion,
295     DuplicateEffectBinding,
296     MissingEffectResult,
297     MissingEffectRegion,
298     ContradictoryEffectResult,
299 };
300 
301 fn validateSubject(op: *const ir.Operation, subject: Subject) ShapeError!void {
302     const valid = switch (subject) {
303         .operation => true,
304         .operand => |index| index < op.getNumOperands(),
305         .result => |index| index < op.getNumResults(),
306         .global => |name| name.len > 0,
307         .region => |index| index < op.getNumRegions(),
308     };
309     if (!valid) return error.InvalidEffectSubject;
310 }
311 
312 fn validateScope(op: *const ir.Operation, scope: Scope) ShapeError!void {
313     switch (scope) {
314         .unknown, .operation => {},
315         .region => |index| if (index >= op.getNumRegions()) return error.InvalidEffectScope,
316         .named => |name| if (name.len == 0) return error.InvalidEffectScope,
317     }
318 }
319 
320 fn validateResource(op: *const ir.Operation, resource: Resource) ShapeError!void {
321     try validateSubject(op, resource.subject);
322     try validateScope(op, resource.alias_scope);
323     try validateScope(op, resource.lifetime);
324     try validateScope(op, resource.ordering_scope);
325     if (resource.bytes) |bytes| {
326         _ = std.math.add(u64, bytes.offset, bytes.length) catch return error.InvalidEffectFootprint;
327     }
328     if (resource.allocator_domain) |name| {
329         if (name.len == 0) return error.InvalidEffectScope;
330     }
331     if (resource.state_key) |name| {
332         if (name.len == 0) return error.InvalidEffectScope;
333     }
334 }
335 
336 fn validateResult(op: *const ir.Operation, fact: ResultFact) ShapeError!void {
337     if (fact.index >= op.getNumResults()) return error.InvalidEffectResult;
338     try validateScope(op, fact.lifetime);
339     if (fact.alias) |alias| {
340         try validateSubject(op, alias);
341         if (fact.fresh_identity) return error.ContradictoryEffectResult;
342         switch (alias) {
343             .result => |index| if (index == fact.index) return error.ContradictoryEffectResult,
344             .operand, .global => {},
345             .operation, .region => return error.ContradictoryEffectResult,
346         }
347     }
348     if (fact.fresh_identity and fact.ownership == .borrowed) {
349         return error.ContradictoryEffectResult;
350     }
351 }
352 
353 fn validateFact(op: *const ir.Operation, fact: Fact) ShapeError!void {
354     switch (fact) {
355         .premise => switch (fact.premise) {
356             .floating_environment => {
357                 if (!op.getContext().arithmetic_policy.permitsFloatingValues()) {
358                     return error.InvalidEffectPremise;
359                 }
360             },
361         },
362         .event => |event| try validateResource(op, event.resource),
363         .requirement => |requirement| {
364             try validateSubject(op, requirement.subject);
365             if (requirement.related) |related| try validateSubject(op, related);
366         },
367         .result => |result| try validateResult(op, result),
368         .region => |region| {
369             if (region.index >= op.getNumRegions()) return error.InvalidEffectRegion;
370         },
371         .binding => |binding| {
372             if (binding.region >= op.getNumRegions()) return error.InvalidEffectBinding;
373             const region = &op.regions.items[binding.region];
374             const block = region.blocks.head orelse return error.InvalidEffectBinding;
375             if (binding.argument >= block.arguments.items.len) return error.InvalidEffectBinding;
376             try validateSubject(op, binding.source);
377         },
378     }
379 }
380 
381 pub fn validate(op: *const ir.Operation, facts: Facts) ShapeError!void {
382     for (facts.records, 0..) |fact, index| {
383         try validateFact(op, fact);
384         for (facts.records[0..index]) |prior| {
385             try validatePair(fact, prior);
386         }
387     }
388     if (!facts.complete) return;
389     for (0..op.getNumResults()) |index| {
390         if (!hasResult(facts, index)) return error.MissingEffectResult;
391     }
392     for (0..op.getNumRegions()) |index| {
393         if (!hasRegion(facts, index)) return error.MissingEffectRegion;
394     }
395 }
396 
397 fn hasResult(facts: Facts, index: usize) bool {
398     for (facts.records) |fact| {
399         if (fact == .result and fact.result.index == index) return true;
400     }
401     return false;
402 }
403 
404 fn hasRegion(facts: Facts, index: usize) bool {
405     for (facts.records) |fact| {
406         if (fact == .region and fact.region.index == index) return true;
407     }
408     return false;
409 }
410 
411 fn validatePair(fact: Fact, prior: Fact) ShapeError!void {
412     if (fact == .result and prior == .result and fact.result.index == prior.result.index) {
413         return error.DuplicateEffectResult;
414     }
415     if (fact == .binding and prior == .binding and fact.binding.region == prior.binding.region and
416         fact.binding.argument == prior.binding.argument) return error.DuplicateEffectBinding;
417     if (fact == .region and prior == .region and fact.region.index == prior.region.index) {
418         return error.DuplicateEffectRegion;
419     }
420 }
421 
422 const Verification = struct {
423     op: *ir.Operation,
424     vtable: *const EffectOpInterface.VTable,
425     limit: usize,
426     seen: usize = 0,
427     results: usize = 0,
428     regions: usize = 0,
429     failure: ?ShapeError = null,
430 
431     fn visit(raw: *anyopaque, fact: Fact) void {
432         const self: *Verification = @ptrCast(@alignCast(raw));
433         if (self.failure != null) return;
434         self.check(fact) catch |err| {
435             self.failure = err;
436         };
437     }
438 
439     fn check(self: *Verification, fact: Fact) ShapeError!void {
440         try validateFact(self.op, fact);
441         var prior = PriorFacts{ .fact = fact };
442         var collector = Collector{
443             .storage = &.{},
444             .observer = .{ .context = &prior, .limit = self.seen, .visit = PriorFacts.visit },
445         };
446         self.vtable.collect(self.op, &collector);
447         if (prior.failure) |err| return err;
448         if (fact == .result) self.results += 1;
449         if (fact == .region) self.regions += 1;
450         self.seen += 1;
451         std.debug.assert(self.seen <= self.limit);
452     }
453 };
454 
455 const PriorFacts = struct {
456     fact: Fact,
457     failure: ?ShapeError = null,
458 
459     fn visit(raw: *anyopaque, prior: Fact) void {
460         const self: *PriorFacts = @ptrCast(@alignCast(raw));
461         validatePair(self.fact, prior) catch |err| {
462             self.failure = err;
463         };
464     }
465 };
466 
467 /// Shape checking replays bounded, read-only enumeration without allocating.
468 /// Each record is checked against its preceding records; no shared IR scratch is touched.
469 pub fn verify(op: *ir.Operation) ShapeError!void {
470     const vtable = op.getInterface(EffectOpInterface) orelse return;
471     const limit = vtable.capacity.count(
472         op.getNumOperands(),
473         op.getNumResults(),
474         op.getNumRegions(),
475     ) orelse return;
476     var verification = Verification{ .op = op, .vtable = vtable, .limit = limit };
477     var collector = Collector{
478         .storage = &.{},
479         .observer = .{ .context = &verification, .limit = limit, .visit = Verification.visit },
480     };
481     vtable.collect(op, &collector);
482     if (verification.failure) |err| return err;
483     if (!collector.complete or collector.exhausted) return;
484     if (verification.results != op.getNumResults()) return error.MissingEffectResult;
485     if (verification.regions != op.getNumRegions()) return error.MissingEffectRegion;
486 }
487 
488 /// T: complete, total, terminating, with only ordinary memory events.
489 pub fn total(facts: Facts) bool {
490     if (!facts.complete) return false;
491     for (facts.records) |fact| switch (fact) {
492         .event => |event| {
493             if (event.ordered) return false;
494             if (event.kind != .read and event.kind != .write) return false;
495         },
496         .requirement => return false,
497         .result => |result| {
498             if (result.fresh_identity or result.ownership != .none) return false;
499         },
500         .region => |region| {
501             if (region.execution != .latent) return false;
502         },
503         .binding, .premise => {},
504     };
505     return true;
506 }
507 
508 /// R: no writes or other observable events. Completeness and totality are separate.
509 pub fn readOnly(facts: Facts) bool {
510     if (!facts.complete) return false;
511     for (facts.records) |fact| switch (fact) {
512         .event => |event| if (event.kind != .read or event.ordered) return false,
513         else => {},
514     };
515     return true;
516 }
517 
518 pub fn memoryFree(facts: Facts) bool {
519     if (!facts.complete) return false;
520     for (facts.records) |fact| {
521         if (fact == .event) return false;
522     }
523     return true;
524 }
525 
526 pub const Stability = struct {
527     read_values: bool = false,
528     execution_context: bool = false,
529 };
530 
531 /// S: memory-free total expressions need no read proof; reads require both premises.
532 pub fn stable(facts: Facts, proof: Stability) bool {
533     if (!total(facts)) return false;
534     return memoryFree(facts) or (proof.read_values and proof.execution_context);
535 }
536 
537 pub fn discard(facts: Facts) bool {
538     return total(facts) and readOnly(facts);
539 }
540 
541 pub fn duplicate(facts: Facts, proof: Stability) bool {
542     return discard(facts) and stable(facts, proof);
543 }
544 
545 pub fn speculate(facts_at_destination: Facts, operands_available: bool) bool {
546     return operands_available and discard(facts_at_destination);
547 }
548 
549 pub fn repeatableExpression(facts: Facts) bool {
550     return discard(facts) and memoryFree(facts);
551 }
552 
553 pub const Crossing = struct {
554     no_dependencies: bool = false,
555     concurrency_exclusive: bool = false,
556 };
557 
558 /// Caller proves disjointness. Resource labels and different SSA names prove nothing.
559 pub fn accessConflict(a: Event, b: Event, proven_disjoint: bool) bool {
560     if (a.ordered or b.ordered) return true;
561     const a_access = a.kind == .read or a.kind == .write;
562     const b_access = b.kind == .read or b.kind == .write;
563     if (!a_access or !b_access) return true;
564     if (a.kind == .read and b.kind == .read) return false;
565     return !proven_disjoint;
566 }
567 
568 pub fn reorder(a: Facts, b: Facts, crossing: Crossing) bool {
569     if (!crossing.no_dependencies or !total(a) or !total(b)) return false;
570     if ((!memoryFree(a) or !memoryFree(b)) and !crossing.concurrency_exclusive) return false;
571     for (a.records) |left| {
572         if (left != .event) continue;
573         for (b.records) |right| {
574             if (right != .event) continue;
575             if (accessConflict(left.event, right.event, false)) return false;
576         }
577     }
578     return true;
579 }
580 
581 fn expectNoPermissions(facts: Facts) !void {
582     try std.testing.expect(!discard(facts));
583     try std.testing.expect(!duplicate(facts, .{ .read_values = true, .execution_context = true }));
584     try std.testing.expect(!speculate(facts, true));
585     try std.testing.expect(!repeatableExpression(facts));
586     try std.testing.expect(!reorder(facts, .{ .complete = true }, .{
587         .no_dependencies = true,
588         .concurrency_exclusive = true,
589     }));
590 }
591 
592 test "EffectOpInterface unknown and unresolved requirements deny every permission" {
593     try expectNoPermissions(.{});
594     try expectNoPermissions(.{ .records = &.{.{ .event = .{ .kind = .read } }} });
595     for (std.enums.values(RequirementKind)) |kind| {
596         try expectNoPermissions(.{
597             .complete = true,
598             .records = &.{.{ .requirement = .{ .kind = kind, .subject = .operation } }},
599         });
600     }
601 }
602 
603 test "EffectOpInterface total expressions and safe reads have distinct permissions" {
604     const expression = Facts{ .complete = true };
605     try std.testing.expect(discard(expression));
606     try std.testing.expect(duplicate(expression, .{}));
607     try std.testing.expect(speculate(expression, true));
608     try std.testing.expect(!speculate(expression, false));
609     try std.testing.expect(repeatableExpression(expression));
610     const read = Facts{ .complete = true, .records = &.{.{ .event = .{ .kind = .read } }} };
611     try std.testing.expect(discard(read));
612     try std.testing.expect(speculate(read, true));
613     try std.testing.expect(!duplicate(read, .{}));
614     try std.testing.expect(!repeatableExpression(read));
615     try std.testing.expect(duplicate(read, .{ .read_values = true, .execution_context = true }));
616     try std.testing.expect(!duplicate(read, .{ .read_values = true }));
617 }
618 
619 test "EffectOpInterface failure ownership allocation state and ordering remain barriers" {
620     for (std.enums.values(EventKind)) |kind| {
621         if (kind == .read or kind == .write) continue;
622         try expectNoPermissions(.{ .complete = true, .records = &.{.{
623             .event = .{ .kind = kind },
624         }} });
625     }
626     const ordered_read = Facts{ .complete = true, .records = &.{.{
627         .event = .{ .kind = .read, .ordered = true },
628     }} };
629     try expectNoPermissions(ordered_read);
630     const balanced = Facts{ .complete = true, .records = &.{
631         .{ .event = .{ .kind = .retain } },
632         .{ .event = .{ .kind = .release } },
633     } };
634     try expectNoPermissions(balanced);
635     try expectNoPermissions(.{ .complete = true, .records = &.{.{
636         .result = .{ .index = 0, .ownership = .none, .fresh_identity = true },
637     }} });
638 }
639 
640 test "EffectOpInterface access conflict is symmetric and ignores resource names" {
641     const accesses = [_]Event{
642         .{ .kind = .read, .resource = .{ .subject = .{ .operand = 0 } } },
643         .{ .kind = .write, .resource = .{ .subject = .{ .operand = 1 } } },
644         .{ .kind = .read, .resource = .{ .subject = .{ .global = "different" } } },
645     };
646     for (accesses) |a| for (accesses) |b| {
647         const expected = a.kind == .write or b.kind == .write;
648         try std.testing.expectEqual(expected, accessConflict(a, b, false));
649         try std.testing.expectEqual(accessConflict(a, b, false), accessConflict(b, a, false));
650         try std.testing.expect(!accessConflict(a, b, true));
651     };
652     try std.testing.expect(accessConflict(.{ .kind = .release }, accesses[0], true));
653     const read = Facts{ .complete = true, .records = &.{.{ .event = accesses[0] }} };
654     try std.testing.expect(!reorder(read, read, .{ .no_dependencies = true }));
655     try std.testing.expect(reorder(read, read, .{
656         .no_dependencies = true,
657         .concurrency_exclusive = true,
658     }));
659 }
660 
661 test "EffectOpInterface collector derives capacity and preserves event order on overflow" {
662     const capacity = Capacity{ .entries = 2, .per_operand = 2, .per_result = 1, .per_region = 3 };
663     try std.testing.expectEqual(@as(?usize, 16), capacity.count(3, 2, 2));
664     try std.testing.expectEqual(@as(?usize, null), capacity.count(std.math.maxInt(usize), 0, 0));
665     var storage: [2]Fact = undefined;
666     var collector = Collector{ .storage = &storage, .complete = true };
667     collector.append(.{ .event = .{ .kind = .write } });
668     collector.append(.{ .event = .{ .kind = .failure } });
669     try std.testing.expect(collector.view().complete);
670     try std.testing.expectEqual(EventKind.write, collector.view().records[0].event.kind);
671     try std.testing.expectEqual(EventKind.failure, collector.view().records[1].event.kind);
672     collector.append(.{ .event = .{ .kind = .read } });
673     try std.testing.expect(collector.exhausted);
674     try std.testing.expectEqual(@as(usize, 2), collector.view().records.len);
675     try expectNoPermissions(collector.view());
676     var empty = Collector{ .storage = &.{}, .complete = true };
677     empty.append(.{ .event = .{ .kind = .failure } });
678     try expectNoPermissions(empty.view());
679 }
680 
681 fn testEnumerateValues(op: *const ir.Operation, collector: *Collector) void {
682     collector.valueResults(op);
683     collector.complete = op.getAttr("incomplete") == null;
684 }
685 
686 test "EffectOpInterface registers static and dynamic facts through loadDialectSpec" {
687     var ctx = try ir.Context.init(std.testing.allocator, ir.Context.Limits.testing);
688     defer ctx.deinit(std.testing.allocator);
689     try ir.dialects.loadDialectSpec(&ctx, .{
690         .name = "effects_fixture",
691         .operations = &.{
692             .{ .name = "effects_fixture.static", .interfaces = &.{EffectOpInterface.entryFor(.{
693                 .complete = true,
694             })} },
695             .{ .name = "effects_fixture.dynamic", .interfaces = &.{EffectOpInterface.entryFor(.{
696                 .capacity = .{ .per_result = 1 },
697                 .enumerate = testEnumerateValues,
698             })} },
699         },
700     });
701     const static = try ctx.createOperation(ir.Operation.State.init(
702         "effects_fixture.static",
703         .unknown,
704     ));
705     const dynamic = try ctx.createOperation(ir.Operation.State.init(
706         "effects_fixture.dynamic",
707         .unknown,
708     ));
709     for ([_]*ir.Operation{ static, dynamic }) |op| {
710         var declaration = try inspect(std.testing.allocator, op);
711         defer declaration.deinit(std.testing.allocator);
712         try std.testing.expect(discard(declaration.facts));
713         try ir.verifyOperation(op, .{});
714     }
715     try dynamic.setAttr("incomplete", try ctx.getBoolAttr(true));
716     var incomplete = try inspect(std.testing.allocator, dynamic);
717     defer incomplete.deinit(std.testing.allocator);
718     try expectNoPermissions(incomplete.facts);
719     try std.testing.expectEqual(
720         interfaces.interfaceId("ir.interface.effects"),
721         EffectOpInterface.id,
722     );
723 }
724 
725 fn testResultOperation(ctx: *ir.Context) !*ir.Operation {
726     try ctx.allowUnregistered();
727     _ = try ctx.registerOperation("effect_result", .{});
728     _ = try ctx.registerType("effect_value");
729     const typ = try ctx.getDialectTypeFromName("effect_value");
730     var state = ir.Operation.State.init("effect_result", .unknown);
731     state.addTypes(&.{typ});
732     state.addRegion();
733     return ctx.createOperation(state);
734 }
735 
736 test "EffectOpInterface validates result and region coverage and contradictions" {
737     var ctx = try ir.Context.init(std.testing.allocator, ir.Context.Limits.testing);
738     defer ctx.deinit(std.testing.allocator);
739     const op = try testResultOperation(&ctx);
740     const block = try op.getRegion(0).?.addBlock();
741     _ = try block.addArgument(op.getResult(0).?.type, .unknown);
742     const binding = Fact{ .binding = .{
743         .region = 0,
744         .argument = 0,
745         .source = .{ .global = "argument" },
746     } };
747     try std.testing.expectError(
748         error.DuplicateEffectBinding,
749         validate(op, .{ .records = &.{ binding, binding } }),
750     );
751     const result = Fact{ .result = .{ .index = 0, .ownership = .none } };
752     const region = Fact{ .region = .{ .index = 0, .execution = .latent } };
753     try validate(op, .{ .complete = true, .records = &.{ result, region } });
754     try std.testing.expectError(error.MissingEffectResult, validate(op, .{ .complete = true }));
755     try std.testing.expectError(error.MissingEffectRegion, validate(op, .{
756         .complete = true,
757         .records = &.{result},
758     }));
759     try std.testing.expectError(error.DuplicateEffectResult, validate(op, .{
760         .records = &.{ result, result },
761     }));
762     try std.testing.expectError(error.InvalidEffectSubject, validate(op, .{ .records = &.{.{
763         .event = .{ .kind = .read, .resource = .{ .subject = .{ .operand = 0 } } },
764     }} }));
765     try std.testing.expectError(error.ContradictoryEffectResult, validate(op, .{ .records = &.{.{
766         .result = .{ .index = 0, .fresh_identity = true, .alias = .{ .global = "escaped" } },
767     }} }));
768     try expectNoPermissions(.{ .complete = true, .records = &.{
769         result, .{ .region = .{ .index = 0, .execution = .repeated } },
770     } });
771 }
772 
773 test "EffectOpInterface normal verifier reports malformed facts and OOM stays an error" {
774     var ctx = try ir.Context.init(std.testing.allocator, ir.Context.Limits.testing);
775     defer ctx.deinit(std.testing.allocator);
776     try ctx.registerOperationInterface("effect_bad", EffectOpInterface.entryFor(.{
777         .complete = true,
778         .facts = &.{.{ .event = .{
779             .kind = .read,
780             .resource = .{ .subject = .{ .operand = 4 } },
781         } }},
782     }));
783     const op = try ctx.createOperation(ir.Operation.State.init("effect_bad", .unknown));
784     try std.testing.expectError(error.InvalidEffectSubject, ir.verifyOperation(op, .{}));
785     var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 0 });
786     try std.testing.expectError(error.OutOfMemory, inspect(failing.allocator(), op));
787 }
788 
789 fn testVerifyWithoutScratch(ctx: *ir.Context) !void {
790     try ctx.allowUnregistered();
791     _ = try ctx.registerOperation("effects.no_scratch", .{});
792     try ctx.registerOperationInterface("effects.no_scratch", EffectOpInterface.entryFor(.{
793         .complete = true,
794         .facts = &.{.{ .event = .{ .kind = .write } }},
795     }));
796     const op = try ctx.createOperation(ir.Operation.State.init("effects.no_scratch", .unknown));
797     const original = op.allocator;
798     defer op.allocator = original;
799     var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 0 });
800     op.allocator = failing.allocator();
801     try verify(op);
802     try std.testing.expectEqual(@as(usize, 0), failing.allocations);
803 }
804 
805 test "EffectOpInterface verification uses no shared operation scratch" {
806     var ctx = try ir.Context.init(std.testing.allocator, ir.Context.Limits.testing);
807     defer ctx.deinit(std.testing.allocator);
808     try testVerifyWithoutScratch(&ctx);
809 }