Timing boundaries

Contexts & Clock Domains

Use contexts to make timing boundaries explicit, and use sync where components communicate across those boundaries.

Livt components run in a context. A context describes the timing environment of that component: the clock and reset that define when its clocked behavior moves forward. Most small designs use one context everywhere. Larger designs often need more than one: a fast system side, a slower peripheral side, a sensor side, or a test-only worker context.

Livt makes those boundaries visible in the source. A component can share the same context as its parent, or it can be placed in a different context. When one component reads, writes, or calls across that boundary, the access is marked with sync.

This chapter explains how to model contexts, when to use sync, and how to keep cross-domain communication easy to review.

The Default Context

Every component has a context. If you construct a subcomponent without saying otherwise, it uses the same context as the component that owns it:

livt
component Counter
{
    public value: int

    process Count()
    {
        this.value = this.value + 1
    }
}

component App
{
    counter: Counter

    new()
    {
        this.counter = new Counter()
    }

    public fn ReadCounter() int
    {
        return this.counter.value
    }
}

App and Counter share one timing environment here. The public field access is ordinary component communication, so no sync marker is needed.

Creating a Separate Context

A subcomponent can also receive a different context. Tests commonly do this to exercise a component boundary that is intentionally timed differently:

livt
component Worker
{
    public value: int

    public fn GetValue() int
    {
        return this.value
    }
}

@Test
@Context(ClockFrequency=100MHz)
component WorkerTest
{
    @Context(ClockFrequency=50MHz)
    workerContext: IContext;

    worker: Worker

    new()
    {
        this.worker = new Worker()
        this.worker.context = this.workerContext
    }
}

The test component has a default 100 MHz context. workerContext is a separate 50 MHz context. The worker is assigned to that separate context, so accesses from the test component to the worker cross a context boundary.

A context is identified by the actual context reference, not only by its numeric frequency. Two contexts can both be 100MHz and still be separate timing domains. If two components use the same context reference, they are in the same domain.

Timing Metadata and Tick Counts

A context also exposes timing values that routines can read like stable constants. This keeps timing-sensitive code tied to the context it actually runs in, rather than to separate manual constants:

livt
component TimeoutCounter
{
    public fn TimeoutTicks() uint
    {
        return this.context.TicksFor(1us)
    }

    public fn ClockRate() uint
    {
        return this.context.ticksPerSecond
    }
}

Use this.context.TicksFor(duration) when you need a duration expressed as a number of clock ticks. The result is rounded up, so the requested duration is not shortened. For example, in a 100 MHz context, this.context.TicksFor(1us) is 100. In a 50 MHz context, the same source expression is 50.

10ns, 1us, and similar values have the semantic type Time. Values such as 50MHz and 100MHz have the semantic type Frequency; they are not strings. Use time literals with timing APIs such as TicksFor(...), and use frequency literals in context declarations. Their source spelling is formatted as text only when used in a report or string interpolation.

Prefer typed duration literals for timing code:

livt
var setupDelay = this.context.TicksFor(250ns)
var byteTimeout = this.context.TicksFor(10us)

The context exposes these timing fields when direct access is useful:

  • this.context.ticksPerSecond is the context rate in ticks per second.
  • this.context.periodNs is the full period in nanoseconds.
  • this.context.highTimeNs is the high time in nanoseconds.
  • this.context.lowTimeNs is the low time in nanoseconds.

Most application code should prefer TicksFor(...) over deriving tick counts by hand. Direct fields are helpful for diagnostics, assertions, and reusable components that need to expose their timing assumptions.

livt
@Test
@Context(ClockFrequency=100MHz)
component TimingTest
{
    counter: TimeoutCounter

    new()
    {
        this.counter = new TimeoutCounter()
    }

    @Test
    fn OneMicrosecondIsOneHundredTicks()
    {
        assert this.counter.TimeoutTicks() == 0n100
    }
}

The same component type can be instantiated in another context and observe a different tick count without changing the component source. That is the main reason to keep timing calculations attached to this.context.

The sync Marker

Use sync exactly where a cross-context access happens:

livt
var value = sync this.worker.GetValue()
var status = sync this.worker.status
sync this.worker.command = nextCommand

The marker belongs at the access site because the same component can be used in same-context and cross-context designs. Worker.GetValue() is not inherently a cross-domain function. It becomes a cross-domain access only when this caller and that worker use different contexts.

If an access crosses contexts and is missing sync, Livt reports it. If an access stays in the same context and still uses sync, Livt can tell you the marker is redundant.

What Can Be Synchronized

Use sync for public component-boundary communication:

  • calling a public function on another component;
  • reading a public primitive field from another component;
  • writing a public primitive field on another component.

These forms keep the boundary explicit and small:

livt
public fn ReadWorker() int
{
    return sync this.worker.GetValue()
}

public fn SendCommand(command: byte)
{
    sync this.worker.command = command
}

Do not use sync around local arithmetic, literals, or private implementation details. Synchronization is about communication between components, not about ordinary expressions inside one component.

Example: Status Reader

The following example keeps the crossing at one clear boundary. StatusReader knows that worker may live in a different context, so the public API makes the synchronized access obvious:

livt
component StatusWorker
{
    public ready: logic
    public status: byte

    public fn GetStatus() byte
    {
        return this.status
    }
}

component StatusReader
{
    worker: StatusWorker

    new(worker: StatusWorker)
    {
        this.worker = worker
    }

    public fn IsReady() bool
    {
        var ready = sync this.worker.ready
        return ready == 0b1
    }

    public fn ReadStatus() byte
    {
        return sync this.worker.GetStatus()
    }
}

This style is easy to review: all cross-context communication is visible where it happens.

Example: Separate Context in a Test

A test can create two contexts and prove that a component still behaves when a worker runs in another timing environment:

livt
component Worker
{
    public fn Double(value: int) int
    {
        return value + value
    }
}

@Test
@Context(ClockFrequency=100MHz)
component CrossContextTest
{
    @Context(ClockFrequency=25MHz)
    workerContext: IContext;

    worker: Worker

    new()
    {
        this.worker = new Worker()
        this.worker.context = this.workerContext
    }

    @Test
    fn CallsAcrossTheContextBoundary()
    {
        assert (sync this.worker.Double(21)) == 42
    }
}

Use additional test contexts when the timing boundary is part of the behavior you want to verify. Keep the fixture small: one default context, one named additional context, and one or two synchronized accesses are often enough.

Design Guidance

Prefer clear context names such as sensorContext, busContext, or workerContext. The name should explain why the context is separate.

Keep synchronized access at component boundaries. A small status value, command field, or function result is easier to reason about than a large bundle of unrelated state.

Do not sprinkle sync through a design as a general safety habit. If two components are intentionally in the same context, write ordinary component access. Reserve sync for the places where a timing boundary is part of the architecture.

For continuous high-throughput data flow, consider a dedicated buffering or streaming component. sync is excellent for clear boundary crossings, but a larger protocol often deserves its own component and tests.

What sync Guarantees

Supported public function calls and primitive public field reads and writes use a one-transaction-at-a-time request/acknowledge transfer. Multi-bit values remain stable while control tokens cross the boundary, so the destination observes one coherent value. The caller resumes after acknowledgement, preserving source ordering.

sync is not a streaming FIFO, does not make every complex payload valid, and does not replace CDC review. Simulation verifies protocol behavior but cannot prove metastability immunity. Both domains should be reset before transactions begin; resetting one side during an active transfer requires an explicit system policy.

Common Mistakes

  • Assuming equal clock frequencies mean equal clock domains.
  • Synchronizing each bit of a multi-bit value independently.
  • Adding sync to local calculations or same-context calls as a general safety marker.
  • Using transaction-style synchronization for a continuous high-throughput stream.

Summary

A context is the timing environment of a component. Components normally share the owning context, but tests and larger systems can assign separate contexts where needed. Use sync at the exact public component access that crosses from one context to another. That makes the design boundary visible, reviewable, and easy to test.