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 → DeferredFunction tree → synth via IEEEPrimitives.TryLower or runtime via DeferredFunction.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 to StandardFunctions.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 returns null silently — it emits an IEEE-STUB warning and a "0" placeholder.


TL;DR

flowchart LR
    UserVHDL["User VHDL<br/>q &lt;= 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 pathIEEEPrimitives.TryLower emits Cell records. The netlist reflects real hardware shape.
  • Runtime pathDeferredFunction.Evaluate recurses through nested DFs and calls StandardFunctions.EvaluateFunction to 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

  1. LibraryResolver registers the bundled IEEE builtins (std_logic_1164, numeric_std) plus any host-supplied AdditionalSources.
  2. On CompileAsync(["std_logic_1164", "numeric_std"]), the resolver topologically sorts dependencies (std_logic_1164 before numeric_std).
  3. BuildOrchestrator walks the order, consults the policy, and either pulls from cache or runs LibraryBuilder.BuildAsync for each library.
  4. LibraryBuilder reads source bytes via the host callback (or falls back to embedded resource for IEEE), regex-extracts function/procedure/type names, returns a CompiledLibrary artifact.
  5. Symbol names propagate into FunctionRegistry so 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):

  1. Outer to_integer.Evaluate():
    • Inner unsigned.Evaluate():
      • addr_variable is a Variable → returned as-is in resolvedArgs
      • StandardFunctions.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)
  2. Push 85 onto the operand stack
  3. 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:

  1. Walker sees the outer DeferredFunction("to_integer", [DF unsigned, [addr_var]]).
  2. ResolveDeferredArgs is 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 a BUF cell with Cast="unsigned", output net = _n0
      • Returns _n0
    • Outer args = [_n0]
  3. IEEEPrimitives.TryLower("to_integer", [_n0]) → emits a TO_INTEGER cell with input pin A wired to _n0, output net = _n1
  4. _n1 is pushed onto the operand stack.
  5. The outer assignment q <= ... emits a BUF cell driving q from _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_arith shl/shr/ext/sxt/conv_unsigned/conv_signed, math_real floor/ceil/round/sqrt, std_logic_1164 to_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 DeferredFunction trees from the parser and emits Cell records via the primitive table.
  • Runtime: consumes the same DeferredFunction trees and computes simulated values via Evaluate() + 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.mdCHANGELOG_v1.19.md now live under _archive/changelogs/ and are merged into the consolidated CHANGELOG.md.