Skip to content

Commit 4e821a3

Browse files
feat(resolver): parametric built-in macros with recursive specialization
Rewrite the five built-in macros (or, xor, nand, nor, xnor) with <W>[W] annotations. The two macros that compose other macros internally (nor uses or, xnor uses xor) now propagate the parent W through the inner call site with or inner[W] / xor inner[W] syntax. Existing scalar callers stay byte-identical via S7.1's default-W-to-1 rule. Three pieces of new resolver machinery let this happen end to end: 1. Worklist-based specialization. specializeCallSites now iterates a queue that includes newly-created spec modules, so width bindings propagate through nested parametric calls of arbitrary depth. The width-arg resolver looks up parameter references against the caller spec's bindings, which is what makes `or inner[W]` inside nor's body bind correctly when nor is specialized at W=8. 2. Alias rewrite on specs. The unresolved_name -> sub_circuit_ref rewrite the topo loop performs for original files also runs on freshly-created spec modules, otherwise nested macro call sites stayed unresolved and tripped E001 (visible on fixtures like alu_4bit that transitively use xor -> or, nand). 3. ir.Module.source_file_id. Distinguishes "where this module lives in project.files" (synthetic id for specs) from "what AST it was resolved from" (the original .circ file). Topology serializer's import_table lookup and OriginFrame.target_file both follow the source_file_id, so tooling sees real source paths and the bench topology hash stays byte-identical to pre-S8 for every fixture. Bench reports golden matches on all 56 fixtures with zero topology drift; existing scalar usages expand to the same component graph as pre-S8 even though they now go through specialization.
1 parent ddc68b0 commit 4e821a3

15 files changed

Lines changed: 257 additions & 56 deletions

lib/ir/types.zig

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,16 @@ pub const Module = struct {
120120
components: []const Component,
121121
connections: []const Connection,
122122
imports: []const UnresolvedImport,
123+
/// File id of the AST this module was resolved from. For original
124+
/// files, equals `file_id`. For specializations, points at the
125+
/// parametric callee's original file id so import_table lookups and
126+
/// origin-frame tracking can resolve against the user-written source
127+
/// instead of the synthetic specialization path.
128+
source_file_id: ?FileId = null,
129+
130+
pub fn effectiveSourceFileId(self: Module) FileId {
131+
return self.source_file_id orelse self.file_id;
132+
}
123133
};
124134

125135
pub const Project = struct {
Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
// parametric in width W (default 1)
12
// NAND(a,b) = NOT(AND(a,b))
2-
input a, b
3-
and inner(a=a, b=b)
4-
not n(in=inner.out)
5-
output out(in=n.out)
3+
input<W>[W] a, b
4+
and[W] inner(a=a, b=b)
5+
not[W] n(in=inner.out)
6+
output[W] out(in=n.out)

lib/resolver/builtin_circ/nor.circ

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
// parametric in width W (default 1)
12
// NOR(a,b) = NOT(OR(a,b))
2-
input a, b
3-
or inner(a=a, b=b)
4-
not n(in=inner.out)
5-
output out(in=n.out)
3+
input<W>[W] a, b
4+
or inner[W](a=a, b=b)
5+
not[W] n(in=inner.out)
6+
output[W] out(in=n.out)

lib/resolver/builtin_circ/or.circ

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
// parametric in width W (default 1)
12
// OR(a,b) = NOT(AND(NOT(a), NOT(b)))
2-
input a, b
3-
not na(in=a)
4-
not nb(in=b)
5-
and inner(a=na.out, b=nb.out)
6-
not n(in=inner.out)
7-
output out(in=n.out)
3+
input<W>[W] a, b
4+
not[W] na(in=a)
5+
not[W] nb(in=b)
6+
and[W] inner(a=na.out, b=nb.out)
7+
not[W] n(in=inner.out)
8+
output[W] out(in=n.out)
Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
// parametric in width W (default 1)
12
// XNOR(a,b) = NOT(XOR(a,b))
2-
input a, b
3-
xor inner(a=a, b=b)
4-
not n(in=inner.out)
5-
output out(in=n.out)
3+
input<W>[W] a, b
4+
xor inner[W](a=a, b=b)
5+
not[W] n(in=inner.out)
6+
output[W] out(in=n.out)

lib/resolver/builtin_circ/xor.circ

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
// parametric in width W (default 1)
12
// XOR(a,b) = AND(OR(a,b), NAND(a,b))
2-
input a, b
3-
or o(a=a, b=b)
4-
nand n(a=a, b=b)
5-
and gate(a=o.out, b=n.out)
6-
output out(in=gate.out)
3+
input<W>[W] a, b
4+
or o[W](a=a, b=b)
5+
nand n[W](a=a, b=b)
6+
and[W] gate(a=o.out, b=n.out)
7+
output[W] out(in=gate.out)

lib/resolver/resolve_bodies.zig

Lines changed: 113 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,32 @@ fn buildSpecializationKey(
164164
return buf.toOwnedSlice(allocator);
165165
}
166166

167+
// One unit of specialization work. Original-file callers enter with empty
168+
// bindings; each specialization enters with the bindings that produced it
169+
// and the original-AST file_id it was specialized from (for import lookups
170+
// and to find the same AST the resolver was driven by).
171+
const SpecWorkItem = struct {
172+
module_file_id: u32,
173+
ast_source_file_id: u32,
174+
ast_file: ast.File,
175+
bindings: []single_resolver.WidthBinding,
176+
};
177+
178+
fn resolveWidthArg(
179+
spec_arg: ast.WidthSpec,
180+
caller_bindings: []const single_resolver.WidthBinding,
181+
) ?u8 {
182+
return switch (spec_arg) {
183+
.literal => |n| n,
184+
.parameter => |name| blk: {
185+
for (caller_bindings) |b| {
186+
if (std.mem.eql(u8, b.name, name)) break :blk b.value;
187+
}
188+
break :blk null;
189+
},
190+
};
191+
}
192+
167193
fn specializeCallSites(
168194
allocator: std.mem.Allocator,
169195
modules_list: *std.ArrayList(ir.Module),
@@ -185,24 +211,49 @@ fn specializeCallSites(
185211
}
186212

187213
const original_file_count = file_paths.len;
188-
for (asts, 0..) |maybe_caller_ast, caller_file_id_usize| {
189-
if (caller_file_id_usize >= original_file_count) break;
214+
var worklist: std.ArrayList(SpecWorkItem) = .{};
215+
defer {
216+
for (worklist.items) |item| {
217+
if (item.bindings.len > 0) allocator.free(item.bindings);
218+
}
219+
worklist.deinit(allocator);
220+
}
221+
222+
// Seed the worklist with non-parametric original callers; parametric
223+
// callees are never directly resolved (the stub module fills their slot
224+
// and downstream lookups follow specialized_target_file instead).
225+
for (asts, 0..) |maybe_caller_ast, file_id_usize| {
226+
if (file_id_usize >= original_file_count) break;
190227
const caller_ast = maybe_caller_ast orelse continue;
191-
const caller_file_id: u32 = @intCast(caller_file_id_usize);
228+
if (fileIsParametric(caller_ast)) continue;
229+
try worklist.append(allocator, .{
230+
.module_file_id = @intCast(file_id_usize),
231+
.ast_source_file_id = @intCast(file_id_usize),
232+
.ast_file = caller_ast,
233+
.bindings = &.{},
234+
});
235+
}
236+
237+
while (worklist.items.len > 0) {
238+
const item = worklist.orderedRemove(0);
239+
defer if (item.bindings.len > 0) allocator.free(item.bindings);
192240

193-
const input_pin_count = countInputPins(caller_ast);
194-
const caller_module = &modules_list.items[caller_file_id];
241+
const input_pin_count = countInputPins(item.ast_file);
242+
const caller_module = &modules_list.items[item.module_file_id];
195243
const caller_components = @constCast(caller_module.components);
196244

197-
for (caller_ast.components, 0..) |ast_inst, ast_idx| {
245+
for (item.ast_file.components, 0..) |ast_inst, ast_idx| {
198246
const ir_comp_idx = input_pin_count + @as(u32, @intCast(ast_idx));
199247
if (ir_comp_idx >= caller_components.len) continue;
200248
const ir_comp = &caller_components[ir_comp_idx];
201249
if (ir_comp.kind != .sub_circuit_ref) continue;
202250

251+
// Import lookup uses the ORIGINAL file_id the AST came from, not
252+
// the spec's synthetic module_file_id (which the import_table
253+
// doesn't know about).
203254
const target_file_id_opt: ?u32 = blk: {
204255
for (import_table) |entry| {
205-
if (entry.importing_file != caller_file_id) continue;
256+
if (entry.importing_file != item.ast_source_file_id) continue;
206257
if (!std.mem.eql(u8, entry.alias, ast_inst.type_name.text)) continue;
207258
break :blk entry.target_file;
208259
}
@@ -237,21 +288,23 @@ fn specializeCallSites(
237288
} else {
238289
var ok = true;
239290
for (declared_params, supplied) |name, spec_arg| {
240-
switch (spec_arg) {
241-
.literal => |n| try bindings.append(allocator, .{ .name = name, .value = n }),
242-
.parameter => |passed_name| {
243-
const message = try std.fmt.allocPrint(
244-
allocator,
245-
"parameter pass-through is not yet supported; call site supplies parameter '{s}'",
246-
.{passed_name},
247-
);
248-
var d = diagnostics.makeDiagnostic(.E016, diagnosticSpan(ast_inst.span));
249-
d.message = message;
250-
try diagnostic_list.append(allocator, d);
251-
ok = false;
252-
break;
253-
},
254-
}
291+
const value = resolveWidthArg(spec_arg, item.bindings) orelse {
292+
const passed_name = switch (spec_arg) {
293+
.parameter => |n| n,
294+
else => "?",
295+
};
296+
const message = try std.fmt.allocPrint(
297+
allocator,
298+
"unbound parameter '{s}' at call site of '{s}'",
299+
.{ passed_name, ast_inst.type_name.text },
300+
);
301+
var d = diagnostics.makeDiagnostic(.E016, diagnosticSpan(ast_inst.span));
302+
d.message = message;
303+
try diagnostic_list.append(allocator, d);
304+
ok = false;
305+
break;
306+
};
307+
try bindings.append(allocator, .{ .name = name, .value = value });
255308
}
256309
if (!ok) continue;
257310
}
@@ -264,7 +317,7 @@ fn specializeCallSites(
264317
}
265318

266319
const spec_file_id: u32 = @intCast(modules_list.items.len);
267-
const specialized = single_resolver.resolveWithBindings(
320+
var specialized = single_resolver.resolveWithBindings(
268321
allocator,
269322
target_ast,
270323
spec_file_id,
@@ -274,6 +327,32 @@ fn specializeCallSites(
274327
std.debug.print("specialization failed for '{s}': {s}\n", .{ ast_inst.type_name.text, @errorName(err) });
275328
continue;
276329
};
330+
// Record the original AST source so downstream lookups
331+
// (topology origin frames, project-level import resolution)
332+
// can resolve against the user-written file path rather than
333+
// the spec's synthetic file id.
334+
specialized.source_file_id = .{ .value = target_file_id };
335+
336+
// Apply the same alias rewrite the topo loop performs on original
337+
// file resolutions: any component whose kind is .unresolved_name
338+
// and whose name matches an import alias for the spec's source
339+
// file becomes a sub_circuit_ref. Without this, nested call sites
340+
// inside a spec (e.g. `or inner[W]` in nor.circ) would stay
341+
// unresolved and trip E001.
342+
const spec_components = @constCast(specialized.components);
343+
for (spec_components) |*c| {
344+
for (import_table) |entry| {
345+
if (entry.importing_file != target_file_id) continue;
346+
if (!componentMatchesAlias(c.*, entry.alias)) continue;
347+
c.kind = .{
348+
.sub_circuit_ref = .{
349+
.name = entry.alias,
350+
.span = c.span,
351+
},
352+
};
353+
break;
354+
}
355+
}
277356

278357
try modules_list.append(allocator, specialized);
279358
const synthetic_path = try std.fmt.allocPrint(
@@ -287,6 +366,17 @@ fn specializeCallSites(
287366
try spec_cache.put(cache_key, spec_file_id);
288367

289368
ir_comp.kind.sub_circuit_ref.specialized_target_file = .{ .value = spec_file_id };
369+
370+
// Enqueue the new spec for recursive specialization of any call
371+
// sites in its body. Duplicate the bindings into the worklist
372+
// entry so the original `bindings` ArrayList can be reused/freed.
373+
const owned_bindings = try allocator.dupe(single_resolver.WidthBinding, bindings.items);
374+
try worklist.append(allocator, .{
375+
.module_file_id = spec_file_id,
376+
.ast_source_file_id = target_file_id,
377+
.ast_file = target_ast,
378+
.bindings = owned_bindings,
379+
});
290380
}
291381
}
292382
}

lib/topology/full_serializer.zig

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -254,17 +254,28 @@ fn expandModule(
254254
.sub_circuit_ref => |ref| {
255255
var child_module: ?*const ir.Module = null;
256256
var target_file_id: u32 = 0;
257+
// The import_table lookup resolves the alias to the source
258+
// file the user wrote the import for (e.g. or.circ for `or`).
259+
// That is the file id we want in the OriginFrame so tooling
260+
// sees real source paths in stack traces. The specialized
261+
// child_module, when present, is the post-substitution body
262+
// the topology walks; it lives at a synthetic file id that
263+
// wouldn't make sense for diagnostics.
264+
//
265+
// The lookup uses `effectiveSourceFileId` so that nested call
266+
// sites inside a specialization match against the original
267+
// callee's imports rather than the spec's synthetic file id
268+
// (the import_table only has entries for original file ids).
269+
const lookup_file_id = module.effectiveSourceFileId();
270+
for (state.project.import_table) |imp| {
271+
if (imp.importing_file.value == lookup_file_id.value and std.mem.eql(u8, imp.alias, ref.name)) {
272+
target_file_id = imp.target_file.value;
273+
child_module = &state.project.files[imp.target_file.value];
274+
break;
275+
}
276+
}
257277
if (ref.specialized_target_file) |spec| {
258278
child_module = &state.project.files[spec.value];
259-
target_file_id = spec.value;
260-
} else {
261-
for (state.project.import_table) |imp| {
262-
if (imp.importing_file.value == module.file_id.value and std.mem.eql(u8, imp.alias, ref.name)) {
263-
child_module = &state.project.files[imp.target_file.value];
264-
target_file_id = imp.target_file.value;
265-
break;
266-
}
267-
}
268279
}
269280
if (child_module == null) return error.ModuleNotFound;
270281

lib/topology/serializer.zig

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,8 +210,9 @@ fn expandModule(
210210
if (ref.specialized_target_file) |spec| {
211211
child_module = &state.project.files[spec.value];
212212
} else {
213+
const lookup_file_id = module.effectiveSourceFileId();
213214
for (state.project.import_table) |imp| {
214-
if (imp.importing_file.value == module.file_id.value and std.mem.eql(u8, imp.alias, ref.name)) {
215+
if (imp.importing_file.value == lookup_file_id.value and std.mem.eql(u8, imp.alias, ref.name)) {
215216
child_module = &state.project.files[imp.target_file.value];
216217
break;
217218
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
input[4] a, b
2+
nand g[4](a=a, b=b)
3+
output[4] out(in=g.out)

0 commit comments

Comments
 (0)