IEEE Library Pipeline — Integration Guide
End-to-end walk-through of how a user's q <= to_integer(unsigned(addr)) line travels from raw VHDL text down to a real netlist cell graph. Written so that anyone wiring up a host (MAUI app, web dashboard, CLI runner) can understand which calls go where and what the contracts are.
Status: Core two-path model current as of 2026-09-14 (verified against code). The pipeline shape (LibraryCompiler seed →
DeferredFunctiontree → synth viaIEEEPrimitives.TryLoweror runtime viaDeferredFunction.Evaluate) is unchanged since the v1.19 (2 May 2026, #20–#29) write-up. Two later additions to fold in: (1) Phase 5 (2026-07-22) added a batch of runtime-only IEEE functions toStandardFunctions.EvaluateFunction— std_logic_arith aliases (shl,shr,ext,sxt,conv_unsigned,conv_signed), math_real (floor,ceil,round,sqrt), and std_logic_1164 meta-value (to_x01,is_x); these do not have synth-path primitives (see the note in Layer 4). (2) The unresolved-function default no longer returnsnullsilently — it emits anIEEE-STUBwarning and a"0"placeholder.
TL;DR
flowchart LR
UserVHDL["User VHDL<br/>q <= to_integer(unsigned(addr))"]
Parser[Parser tokenises]
LibCompiler[LibraryCompiler<br/>seeds FunctionRegistry<br/>with IEEE names]
OpParser["Operation.ParseFunctionCall<br/>(recurses on nested)"]
DF[DeferredFunction tree<br/>Outer DF: to_integer<br/> Args: [Inner DF: unsigned<br/> Args: [addr_var]]]
Synth[ConcurrentAssignSynthesizer]
Lookup[IEEEPrimitives.TryLower]
Cells["Cell graph:<br/>addr → BUF (cast=unsigned) → TO_INTEGER → q"]
Sim[DeferredFunction.Evaluate<br/>(simulator path)]
UserVHDL --> Parser
LibCompiler -.seeds.-> Parser
Parser --> OpParser
OpParser --> DF
DF --> Synth
Synth --> Lookup
Lookup --> Cells
DF --> Sim
Two paths from the same DeferredFunction tree:
- Synth path —
IEEEPrimitives.TryLoweremitsCellrecords. The netlist reflects real hardware shape. - Runtime path —
DeferredFunction.Evaluaterecurses through nested DFs and callsStandardFunctions.EvaluateFunctionto compute the simulated value.
Layer 1 — Host wires up the LibraryCompiler
Hosts construct LibraryCompiler via DI, supplying everything path-related through the options record:
// Kmila.Shared / MauiProgram / Web Program — host code, NOT lib code
builder.Services.AddSingleton<LibraryCompiler.Services.LibraryCompiler>(provider =>
new LibraryCompiler.Services.LibraryCompiler(new LibraryCompilerOptions(
OpenLibrarySource: (key, ct) =>
FileSystem.OpenAppPackageFileAsync(key) // MAUI host
.ContinueWith(t => (Stream?)t.Result, ct),
CacheStore: new MauiAppDataBlobStore(), // host's IBlobStore
AdditionalSources: provider.GetRequiredService<UserAssetStore>()
.ListUserLibrarySources(),
Policy: PickPolicyByFreeRam())));
For tests / CLI the simpler FromSyncOpener factory works:
var compiler = LibraryCompiler.Services.LibraryCompiler.FromSyncOpener(
openSync: _ => null, // null → embedded fallback
cacheStore: NullBlobStore.Instance,
policy: LibraryBuildPolicy.AlwaysProcessCached);
The lib's constructor throws ArgumentException if OpenLibrarySource is null. There's no silent default that probes the filesystem.
What the lib does on first call
LibraryResolverregisters the bundled IEEE builtins (std_logic_1164,numeric_std) plus any host-suppliedAdditionalSources.- On
CompileAsync(["std_logic_1164", "numeric_std"]), the resolver topologically sorts dependencies (std_logic_1164beforenumeric_std). BuildOrchestratorwalks the order, consults the policy, and either pulls from cache or runsLibraryBuilder.BuildAsyncfor each library.LibraryBuilderreads source bytes via the host callback (or falls back to embedded resource for IEEE), regex-extracts function/procedure/type names, returns aCompiledLibraryartifact.- Symbol names propagate into
FunctionRegistryso user code referencing them resolves cleanly during expression parsing.
Today the bodies aren't parsed yet — names are seeded as black-box FunctionDefinition stubs. The synthesizer emits primitives based on the name match (Layer 4 below).
Layer 2 — Parser sees the user's expression
The parser's expression-walker (Operation.ParseFunctionCall) hits to_integer and recognises it as a known function (via StandardFunctions.IsStandardFunction or FunctionRegistry.IsKnown).
q <= to_integer ( unsigned ( addr ) ) ;
Token stream:
q <= to_integer ( unsigned ( addr ) ) ;
ParseFunctionCall("to_integer") enters with _currentSyntetizeIndex at to_integer. It walks:
skip "to_integer" → index at "("
skip "(" → index at "unsigned", parenDepth = 1
loop iter 1:
token = "unsigned"
next-token == "(" AND IsStandardFunction("unsigned") → RECURSE
ParseFunctionCall("unsigned"):
skip "unsigned" → index at "("
skip "(" → index at "addr", parenDepth = 1 (LOCAL)
loop iter 1:
token = "addr"
next-token = ")", NOT "(", so don't recurse
addr matches a Variable in scope → args.Add(addr_variable)
loop iter 2:
token = ")", parenDepth → 0, break
ShouldDefer("unsigned") → true
return DeferredFunction("unsigned", [addr_variable], …)
args.Add(DeferredFunction("unsigned", …)) ← key: nested DF, not bare string
continue ← skip outer index++
loop iter 2:
token = ")", parenDepth → 0, break
ShouldDefer("to_integer") → true
return DeferredFunction("to_integer", [DeferredFunction("unsigned", [addr_var])], …)
The result is a structured tree:
DeferredFunction("to_integer", DATATYPES.INTEGER, [
DeferredFunction("unsigned", DATATYPES.STD_LOGIC_VECTOR, [
Variable("addr", "01010101", STD_LOGIC_VECTOR, width=8)
])
])
Pre-#29, the inner addr was silently dropped and the outer call carried Arguments = ["unsigned"] — a bare string. Now the operand is preserved and structured.
DeferredFunction.ShouldDefer table (v1.19)
"rising_edge" => true,
"falling_edge" => true,
"to_integer" => true,
"conv_integer" => true,
"to_unsigned" => true,
"to_signed" => true,
"resize" => true,
"shift_left" => true,
"shift_right" => true,
"rotate_left" => true,
"rotate_right" => true,
"unsigned" => true, // type-name overloads (v1.19)
"signed" => true,
"std_logic_vector" => true,
"conv_std_logic_vector" => true,
Functions returning true survive parsing as DeferredFunctions and reach both the synth path and the runtime path. Returning false (the default for everything else) means inline evaluation during parse — used for clog2, user functions, etc.
Layer 3 — Runtime path (delta-cycle simulation)
When the simulator executes q <= to_integer(unsigned(addr)), Operation.Execute() walks the items list. Hitting the outer DeferredFunction, it calls .Evaluate():
public Variable Evaluate()
{
var resolvedArgs = Arguments.Select(arg => arg switch
{
Variable v => (object)v,
DeferredFunction df => (object)df.Evaluate(), // RECURSE on nested
string s => (object)(LookupByName(s) ?? s),
_ => arg,
}).ToArray();
object result = StandardFunctions.EvaluateFunction(FunctionName, resolvedArgs, Variables);
// … wrap result in a Variable …
}
Trace for addr = "01010101" (= 85):
- Outer
to_integer.Evaluate():- Inner
unsigned.Evaluate():addr_variableis a Variable → returned as-is in resolvedArgsStandardFunctions.EvaluateFunction("unsigned", [addr_variable], …)case "unsigned": return GetStringValue(addr_variable, vars);→"01010101"
- Wrap as
Variable("DeferredFunction-unsigned", "01010101", STD_LOGIC_VECTOR)
resolvedArgs = [unsigned_result_variable]StandardFunctions.EvaluateFunction("to_integer", [unsigned_result_variable], …)case "to_integer": return ToInteger(GetStringValue(arg0));→ToInteger("01010101")- v1.19 fix: ToInteger checks "all 0/1 multi-char" first →
Convert.ToInt32("01010101", 2)= 85
- Wrap as
Variable("DeferredFunction-to_integer", 85, INTEGER)
- Inner
- Push 85 onto the operand stack
- Assignment completes:
q.Value = 85
Pre-#29, step 1's inner call received args = ["unsigned"] (the bare string), which GetStringValue returned as the literal "unsigned", which ToInteger("unsigned") failed to parse → returned 0. Every memory address indexed through to_integer(unsigned(addr)) was effectively a write to location 0.
Layer 4 — Synth path (netlist generation)
ConcurrentAssignSynthesizer.SynthesizeInto walks the same items list but to emit cells, not values:
foreach (var item in op.Items)
{
switch (item)
{
case Variable v:
stack.Push(NetForVariable(v, ctx));
break;
case DeferredFunction df:
var args = ResolveDeferredArgs(df, ctx); // recurses on nested DFs
var lower = IEEEPrimitives.TryLower(df.FunctionName,
new IEEEPrimitiveArgs(args, ctx, sourceLine));
if (lower is { } r) stack.Push(r.OutputNet);
else stack.Push(ctx.Constant($"@{df.FunctionName}", 1));
break;
case Operator opr:
HandleOperator(opr, stack, ctx, sourceLine);
break;
}
}
ResolveDeferredArgs is the recursive piece:
foreach (var arg in df.Arguments)
{
switch (arg)
{
case Variable v: nets.Add(NetForVariable(v, ctx)); break;
case Net n: nets.Add(n); break;
case DeferredFunction nested:
var nestedArgs = ResolveDeferredArgs(nested, ctx); // recursion
var nestedR = IEEEPrimitives.TryLower(nested.FunctionName, …);
nets.Add(nestedR is { } nr ? nr.OutputNet
: ctx.Constant($"@nested_{nested.FunctionName}", 1));
break;
case int i: nets.Add(ctx.Constant(i.ToString(), Math.Max(1, i))); break;
// string + default fallthrough …
}
}
Trace for our example:
- Walker sees the outer
DeferredFunction("to_integer", [DF unsigned, [addr_var]]). ResolveDeferredArgsis called on the outer DF:- For its single arg (the nested
DF unsigned):- Recurses:
ResolveDeferredArgs(inner)→[NetForVariable(addr_var)]=[addr_net] IEEEPrimitives.TryLower("unsigned", [addr_net])→ emits aBUFcell withCast="unsigned", output net =_n0- Returns
_n0
- Recurses:
- Outer args =
[_n0]
- For its single arg (the nested
IEEEPrimitives.TryLower("to_integer", [_n0])→ emits aTO_INTEGERcell with input pin A wired to_n0, output net =_n1_n1is pushed onto the operand stack.- The outer assignment
q <= ...emits aBUFcell drivingqfrom_n1.
Final cell graph:
addr (PortIn) ──→ BUF (Cast=unsigned, _n0) ──→ TO_INTEGER (_n1) ──→ BUF ──→ q (Signal)
Each cell carries source-line attribution so the schematic viewer can group cells back to their VHDL line.
IEEEPrimitives cell-kind table
| Function name | Cell emitted | Attrs | Output width |
|---|---|---|---|
rising_edge |
EDGE_DETECT |
Edge=rising | 1 |
falling_edge |
EDGE_DETECT |
Edge=falling | 1 |
to_integer (+ conv_integer) |
TO_INTEGER |
Width, Signed | 32 |
to_unsigned |
TO_VECTOR |
Signed=false, Width | declared |
to_signed (+ conv_std_logic_vector) |
TO_VECTOR |
Signed=true, Width | declared |
resize |
RESIZE |
FromWidth, ToWidth | requested |
shift_left / shift_right |
SHL / SHR |
— | preserves |
rotate_left / rotate_right |
ROL / ROR |
— | preserves |
unsigned, signed, std_logic_vector |
BUF |
Cast | preserves |
Synth vs. runtime coverage (2026-07 Phase 5). The table above is the complete synth-path registry (
IEEEPrimitives, seeded in its static ctor). The Phase 5 additions — std_logic_arithshl/shr/ext/sxt/conv_unsigned/conv_signed, math_realfloor/ceil/round/sqrt, std_logic_1164to_x01/is_x— exist only on the runtime path (StandardFunctions.EvaluateFunction). Calling one of them lowers to the constant-placeholder + diagnostic described in "When things don't lower" below: the simulation is correct, the netlist just shows a placeholder for that node.
Putting it together — full code sample
End-to-end host integration with all four layers:
// 1. HOST DI registration
builder.Services.AddSingleton<LibraryCompiler.Services.LibraryCompiler>(provider =>
new LibraryCompiler.Services.LibraryCompiler(new LibraryCompilerOptions(
OpenLibrarySource: (key, ct) => FileSystem.OpenAppPackageFileAsync(key)
.ContinueWith(t => (Stream?)t.Result, ct),
CacheStore: new MauiAppDataBlobStore(),
AdditionalSources: provider.GetRequiredService<UserAssetStore>().ListUserLibrarySources(),
Policy: LibraryBuildPolicy.AlwaysProcessCached)));
// 2. AT THE START OF A USER PROJECT BUILD
var compiler = provider.GetRequiredService<LibraryCompiler.Services.LibraryCompiler>();
var ctx = new ProjectContext(
Files: new[] { "uart.vhd", "spi.vhd", "top.vhd" },
UseClauses: new[] { "std_logic_1164", "numeric_std" });
var project = await compiler.CompileProjectAsync(ctx);
// 3. SEED FunctionRegistry FROM compiled libs (Interpreter side)
foreach (var lib in project.Libraries)
foreach (var sym in lib.Functions)
FunctionRegistry.Instance.Register(new FunctionDefinition
{
Name = sym.Name,
PackageName = lib.LibraryName,
IsPure = sym.Kind == LibrarySymbolKind.Function
});
// 4. PARSE + SYNTH user files (existing Interpreter pipeline)
foreach (var file in project.UserFiles)
{
var raw = File.ReadAllText(file);
var (entities, archCount) = ProcessVhdlCode(raw); // existing entry point
foreach (var entity in entities)
{
// entity.Architecture.Tasks contains Operations whose items
// include DeferredFunctions for the IEEE calls. The synthesizer
// emits real cells via IEEEPrimitives.TryLower.
}
}
The host owns steps 1 and 2 (path / policy / project shape). The Interpreter side handles 3 and 4 automatically — the existing ProcessVhdlCode already calls IEEELibraryLoader.Instance.EnsureLoaded() which delegates into LibraryCompiler, so even hosts that don't construct a LibraryCompiler directly get the IEEE seeds.
When things don't lower
If a user calls a function that has no primitive emitter (custom user function, vendor-specific function not yet registered), the synth emits:
stack.Push(ctx.Constant($"@{df.FunctionName}", 1));
ctx.AddDiagnostic("UnknownIEEEFunction",
$"No IEEE primitive registered for '{df.FunctionName}' — emitted a constant " +
"placeholder. The simulator's runtime evaluator handles the call but the " +
"netlist won't reflect the real hardware shape.", sourceLine);
The simulator still works (the runtime evaluator does its thing), the netlist just shows a placeholder constant. The diagnostic is surfaced through the existing Synthesis.Diagnostic channel into the netlist, which the schematic viewer can render as a yellow marker.
To register a new emitter, see ../LibraryCompiler/README.md#extending-ieeeprimitives.
Tests that exercise this pipeline
| Suite | What it proves |
|---|---|
Interpreter/Tests/LibraryCompilerTests.cs (17 cases) |
Module surface — resolver, builder, orchestrator, policy, blob stores, project compilation |
Interpreter/Tests/IEEELibraryLoaderTests.cs (6 cases) |
Legacy facade still seeds FunctionRegistry correctly |
Interpreter/Tests/IEEEPrimitivesTests.cs (18 cases) |
Each emitter produces the right CellKind / width / attrs |
Interpreter/Tests/IEEELoweringEndToEndTests.cs (6 cases) |
DeferredFunction → IEEEPrimitives → Cell graph |
Interpreter/Tests/NestedIEEECallTests.cs (7 cases) |
Parser nesting, runtime evaluation of nested calls, synth wiring topology |
Run all of them (xUnit):
cd Interpreter && dotnet test
These suites (LibraryCompiler, IEEELibraryLoader, IEEEPrimitives, IEEELoweringEndToEnd,
NestedIEEECall) cover the IEEE pipeline end-to-end and are part of the module's xUnit
regression suite (see ../Interpreter/README.md §7).
Recap of the contract
- Lib: owns names, types, interfaces, the primitive table, the orchestrator. Never decides paths.
- Host: owns the filesystem, the policy choice, the cache implementation, the user-plugin storage. Wires concretes into the lib through
LibraryCompilerOptions. - Synth: consumes
DeferredFunctiontrees from the parser and emitsCellrecords via the primitive table. - Runtime: consumes the same
DeferredFunctiontrees and computes simulated values viaEvaluate()+StandardFunctions.
Two paths from the same intermediate representation, both covered by tests, both producing the right answer.
Core write-up: 2 May 2026 (#20–#29). Verified against code and annotated with the
2026-07 Phase 5 additions on 2026-09-14.
Per-release detail: the old CHANGELOG_v1.16.md … CHANGELOG_v1.19.md now live under
_archive/changelogs/ and are merged into the consolidated
CHANGELOG.md.