Lowent Manual←↑→

31 Building and testing — packages, configuration, tests, cross-checks

What to know first

chapter 2, A first program · the modes of lowentc and its two back ends
chapter 21, Modules · there is no search path
chapter 26, Tasks and channels · schedule explore_interleavings tests

Looking back

In chapter 2, whose defect was it when the VM and native code give different output? And what does a quiet --check not guarantee?

A. The compiler’s, because the two back ends start from the same intermediate representation. A quiet --check goes only as far as the head’s promises and the body not disagreeing; it does not guarantee that tests pass or that the program is right. This chapter covers the tools filling the gap — tests, configuration and cross-checks — and how to build a project.

The need for this chapter, and its context

This is the last chapter of Part VIII. Earlier chapters ran --check and --run on one file. Real projects have a manifest, configurations that differ per build, tests and dependencies. Lowent’s tools apply the same principle to all of them — what is written is checked, defaults are visible in the source, and nothing changes quietly. And the compiler itself is verified by the same principle.

By the end of this chapter

You will learn to build a project with the pkg.low manifest and lowentc run and build. You will see that build option and config build different programs per build while switched-off branches are still checked. You will also see what test blocks look like when they pass and fail, how cross-checking the two back ends and contracts verifies the compiler, and how dependencies are pinned by content hash.

The questions this chapter answers

  1. If there are tests, must contracts be written too?

31.1 The manifest and the project#

Put pkg.low at the project root and the entry point in src/main.low.

package name "greeter" .
package version "0.1.0" .
package license "MIT" .

The manifest is a few flat declarations. The keys are a closed set and version is checked against semantic versioning. This file exists so that tools and people can know a project’s identity from it alone without reading all the source, so here too what is written is checked. If it cannot be trusted when read, it may as well not exist.

$ lowentc run
hello from a package
main() = 0
$ lowentc build
built …/out/greeter
$ ./out/greeter main
hello from a package

lowentc run finds the manifest and runs the entry point on the VM, and lowentc build emits C and builds an executable in out/. Builds are cached by the content hash of the emitted C, so unchanged code is not compiled again. The manifest is found by walking upwards from the first file’s folder.

 pkg.low ──▶ entry src/main.low
                  │
                  ▼
             the same checks as --check ──▶ rejected: stop here (neither run nor built)
                  │
        ┌─────────┴──────────┐
        ▼                    ▼
   lowentc run          lowentc build
   run on the VM        emit C ──▶ same hash: cache ──▶ out/greeter
                               └─▶ first time: cc ───▶ out/greeter

Both commands pass the same checks as --check before running or building. A program the language rejects neither runs nor becomes an executable. run has done so since 2026-09-16, build since 2026-09-26 — before that, build skipped the checks and built even a program with a mistake like return true . (found by measuring while this chapter was revised).

31.2 Dependencies are pinned by hash#

lowentc add <name> <place> writes a dependency into the manifest with a hash pin. --lock-write records in a lock file what is being built against now, and --lock <file> refuses to build if the bytes differ from that hash. Even with the same version number, changed bytes are a different dependency.

Checking authenticity is a separate layer. lowentc key new, sign and verify make and check ed25519 detached signatures. verify splits its answer into four layers — integrity (do hash and pin match), authenticity (signed with a trusted key), transport and access — because green in one layer does not mean green in another. The only environment variable read to find settings is HOME, and the precedence of settings (command line > project > user > global) is written in the help. This is to reduce places where behaviour is changed secretly outside the source. A few other switches whose names start with LOW exist, all off by default and meant for testing and migration — the fault injector LOW_HOST_FAULT (chapter 28), the rollback door LOWENT_ALLOW_GLUED_FIELD that briefly re-admits the removed glued-dot field access, and the step limit LOWENT_ORACLE_BUDGET of the oracle tools. There is no everyday reason to set them, and setting one shows on the command line.

This is how verify reports its four layers separately (measured on an unsigned file).

 [3 integrity]     BLAKE3 a19e7c93…      are these bytes the pinned ones
 [2 authenticity]  unsigned — LOCAL       who published it --- a trusted key's signature
 [1 transport]     TLS (curl's job)       who the server is --- not what the bytes are
 [4 access]        tokens in tool config  may you get in --- separate from integrity

Each layer is looked at on its own even when another is green. A successful TLS connection means the server is the right one, not that the bytes received are.

31.3 A different program per build — build option and config#

examples/ch31/knobs.low

module knobs .
rem run: tick_rate
rem run: cpus

build option smp bool default true .
build option hz choice 100 250 1000 default 250 .
build option maxcpu int default 64 .

fn tick_rate output u64 .
do
  if config smp . do
    return config hz .
  end
  return 100 .
end

fn cpus output u64 .
do
  return config maxcpu .
end

Output

$ lowentc --run tick_rate knobs.low
tick_rate() = 250
$ lowentc --run cpus knobs.low
cpus() = 64

build option <name> <kind> … declares a knob. There are three kinds: bool, int and choice. config <name> reads its value as a translation-time constant. Without a configuration file, the declaration’s default is used.

The same source is built with a different configuration. A configuration file is a set of name value lines.

smp false
maxcpu 8

examples/ch31/knobs_small.low

module knobs_small .
rem flags: --config small.config
rem run: tick_rate
rem run: cpus

build option smp bool default true .
build option hz choice 100 250 1000 default 250 .
build option maxcpu int default 64 .

fn tick_rate output u64 .
do
  if config smp . do
    return config hz .
  end
  return 100 .
end

fn cpus output u64 .
do
  return config maxcpu .
end

Output

$ lowentc --config small.config --run tick_rate knobs_small.low
tick_rate() = 100
$ lowentc --config small.config --run cpus knobs_small.low
cpus() = 8

With smp switched off, tick_rate gives 100 and cpus gives 8. Once the value is fixed, switched-off branches do not remain in the output. The cost is zero.

 build option smp bool default true .       ← the source declares the knob
 small.config:  smp false                   ← the config file gives a value (default if absent)

 if config smp . do return config hz . end  ← the branch when smp is on
 return 100 .                               ← the branch when it is off

 translation  both branches are parsed and type-checked
 output       only the chosen branch stays (the off branch costs 0)

There is a decisive difference from C’s #ifdef. Switched-off branches are still parsed and type-checked. In C, code for combinations nobody switches on rots unread, and in kernel-scale projects “that option combination does not even build” happens. Here what folds away is code emission, not checking.

A knob declared but read by no code is rejected.

examples/ch31/unused.low

module unused_opt .
rem expect: E-OPT-UNUSED

build option trace bool default false .

fn answer output u64 .
do
  return 42 .
end

Output

$ lowentc --check unused.low
unused.low:4:0 E-OPT-UNUSED: this build option is declared and NO code reads it (`config <name>`). It shows up in the configuration, the user turns it off — and nothing happens. A knob that does nothing is worse than no knob: it is a decision that LOOKS like it was made

Such a knob appears in the configuration, a user switches it off, and nothing happens. It looks like a decision but is not one. A configuration file giving a value not among the choices (E-CONFIG-TYPE) or naming a knob that does not exist (E-CONFIG-UNDEF) is rejected too.

In practice. A knob that could not be switched off

The specification’s example makes the hz knob depend on smp (depends smp). The rule is that if the dependent is on while what it depends on is off, no such build exists, so it is rejected. Running that example while writing this book, a choice knob always had a value and read as “on”, so every configuration switching smp off was rejected with E-CONFIG-DEPENDS. The development repository’s test looked only at running with that configuration, not at checking, and missed the discrepancy. That is why this chapter’s example leaves out depends. The lesson that a green light says nothing when what the test sees differs from what users do applies here too.

31.4 Tests#

expect inside a test <name> do … end block is an assertion. --test runs every test.

examples/ch31/tests_clause.low

module tests_clause .
rem test

fn clamp8 input v u64 . output u8 .
do
  return narrow_sat u8 v .
end

test clamp_keeps_small
do
  expect eq (clamp8 7) 7 .
end

test clamp_saturates
do
  expect eq (clamp8 1000) 255 .
end

Output

$ lowentc --test tests_clause.low
  [PASS] clamp_keeps_small
  [PASS] clamp_saturates
== tests: 2 run, 2 passed, 0 FAILED ==

When a test fails, it looks like this.

examples/ch31/failing.low

module failing .
rem test-fail

fn clamp8 input v u64 . output u8 .
do
  return narrow_wrap u8 v .
end

test clamp_saturates
do
  expect eq (clamp8 1000) 255 .
end

Output

$ lowentc --test failing.low
  [FAIL] clamp_saturates
         E-TEST-FAIL: an `expect` in this test is FALSE — the test failed (this is not a contract violation: it is the test telling you the code is wrong)
== tests: 1 run, 0 passed, 1 FAILED ==

E-TEST-FAIL is a different diagnostic from a contract violation. A contract violation is code breaking its own promise; a test failure is a test saying the code is wrong. What to fix differs. Here narrow_wrap must become narrow_sat. And the processor does not optimise expect away. A vanished test is a test that did not run.

Concurrent code uses test <name> schedule explore_interleavings . do … end to run every possible ordering of flows and see whether the answers agree (chapter 26). When there are many cases, limit <number> sets a ceiling.

An op head also has clauses for documentation and tests.

examples/ch31/clauses.low

module clauses .
rem run: first_two [65,66]
rem test

enum parse_error do
  too_short .
end

fn first_two
  rem lowdoc is a description for people; tools extract it as documentation
  lowdoc "Add the first two bytes; refuses input shorter than two." .
  input data slice u8 .
  output result u64 parse_error .
  errors too_short lt (len data) 2 .
  rem tests names the ops that test this op; the tool checks that those names exist
  tests first_two_ok first_two_short .
do
  guard ge (len data) 2 . else return error too_short .
  return ok (add (widen u64 (index data 0)) (widen u64 (index data 1))) .
end

fn first_two_ok output bool . do
  let r result u64 parse_error be first_two "AB" .
  return is_ok r .
end

fn first_two_short output bool . do
  let r result u64 parse_error be first_two "A" .
  return is_error r .
end

rem the test block calls the testing ops; lowentc --test runs it
test first_two_cases do
  expect first_two_ok .
  expect first_two_short .
end

Output

$ lowentc --run first_two clauses.low [65,66]
first_two([65,66]) = ok 131
  arg0 (written) = [65,66]
$ lowentc --test clauses.low
  [PASS] first_two_cases
== tests: 1 run, 1 passed, 0 FAILED ==

Q. If there are tests, must contracts be written too?

A. They catch different things. A test checks answers on inputs the author picked; a contract checks that promises hold on every call. And contracts become material for making tests. The development repository’s contract cross-check tool uses requires, ensures and errors as the verdict without writing expected output separately, and generates boundary-value inputs. Write contracts honestly and tests grow for free.

31.5 How the compiler is verified#

The proofs (Part X) are about a model. Whether the actual compiler follows that model must be confirmed separately. There is one principle — make the same thing in two ways, and treat a different answer as a defect.

MethodWhat is compared with whatWhat it catches
Back-end cross-checkVM run ↔ native runPlaces where the two back ends answer differently
Contract cross-checkWritten contracts ↔ actual run resultsPlaces where contracts differ from the facts
Analysis self-checkIndices believed “safe” ↔ actual indicesPlaces where the analysis is wrong (E-VM-ANALYSIS)
Certificate recheckGrounds for removing a check ↔ an independent checker’s arithmeticPlaces where rules were misapplied
One-line changesA program ↔ the program with one fact removedBroken relations, even without knowing the right answer

Table 31.1 — Ways of checking the compiler against something

Such checks show that defects exist but cannot show that they do not. Two implementations giving the same answer are not thereby both right. Absence is the job of proofs. What you can run yourself in the public repository is make check in impl/. It covers unit tests, checking the whole standard library, VM/native cross-checks over many ops, and the diagnostic codes of programs that must be rejected, all at once. This book’s verification script applies the same cross-check to every example — and while this book was written, that cross-check exposed several discrepancies in the compiler.

A common misconception. A green light means it was checked

A green light speaks only about what it saw. An operation not on the cross-check list shows neither green nor red. So the development repository makes the cross-check count and report opcodes it has never seen. “The knob that could not be switched off” above is the same: the test looked at running, not checking, so it was green. What is not counted is not managed.

31.6 Asking where it is slow#

Native emission lowers ops by two paths. Ops whose types are settled lower naturally to C integers and arrays; the rest stay on a slow path running on tagged values. --why-slow names the ops left on the slow path and why.

$ lowentc --why-slow bounds.low
why-slow: 0 / 1 op(s) still on the tagged path

A model whose performance is not visible in signatures becomes knowledge outside the source. So the tool says it. --no-fast is a contrast switch lowering every op by the slow path, used to see whether both paths give the same answer.

31.7 Common mistakes#

Counter-example. Misspelling a knob name in the config file

examples/ch31/mistake_configtypo.low

module mistake_configtypo .
rem flags: --config typo.config
rem expect: E-CONFIG-UNDEF

rem ✘ the config file spells `maxcpu` as `maxcpus`

build option smp bool default true .
build option hz choice 100 250 1000 default 250 .
build option maxcpu int default 64 .

fn tick_rate output u64 .
do
  if config smp . do
    return config hz .
  end
  return 100 .
end

fn cpus output u64 .
do
  return config maxcpu .
end

Output

$ lowentc --check --config typo.config mistake_configtypo.low
0:0 E-CONFIG-UNDEF: the config selects an option that is not declared. A setting nobody reads changes nothing — and it LOOKS like it does

maxcpus 8 is a setting nobody reads. Passing over it quietly would leave maxcpu at its default of 64 while the user believes the build used 8. So it stops with E-CONFIG-UNDEF. The line number 0:0 is because the mistake is in the config file, not in the source.

Counter-example. Giving a choice knob a value that is not one of its choices

examples/ch31/mistake_configvalue.low

module mistake_configvalue .
rem flags: --config badvalue.config
rem expect: E-CONFIG-TYPE

rem ✘ the config file gives `hz` the value 300, which is not one of its choices

build option smp bool default true .
build option hz choice 100 250 1000 default 250 .
build option maxcpu int default 64 .

fn tick_rate output u64 .
do
  if config smp . do
    return config hz .
  end
  return 100 .
end

fn cpus output u64 .
do
  return config maxcpu .
end

Output

$ lowentc --check --config badvalue.config mistake_configvalue.low
8:0 E-CONFIG-TYPE: the config picks a value this `choice` option does not offer

hz is one of 100, 250 and 1000. Given 300, the tool neither rounds to the nearest nor falls back to the default; it stops with E-CONFIG-TYPE. The choices also mean “tested with these values”. If you need a new value, add it to the declaration in the source.

A common misconception. An op that takes a capability cannot be called with --run

examples/ch31/runcap.low

module runcap .
rem run: say 5
rem run: say 0 5

rem `--run` FILLS capability positions when you leave them out (2026-09-20). The old placeholder `0` still works too
proc say input out cap io . input n u64 . output u64 . effects io .
  requires le n 100 .
do
  return write_out out 1 "hi\n" .
end

Output

$ lowentc --run say runcap.low 5
hi
say(5) = 3
$ lowentc --run say runcap.low 0 5
hi
say(0, 5) = 3

That was true until 2026-09-20: capability positions counted among the arguments, so say 5 was E-VM-ARITY and only say 0 5, with a placeholder 0, ran. The cost was quiet: witness programs taking cap io or cap allocator ran on the native back end only, so the two-back-end oracle this book relies on was broken exactly there. The tool now FILLS capability positions — but only when arguments are missing — so both say 5 and say 0 5 run. The placeholder is still only a convenience of the tool: inside a program, passing a number where a capability belongs is refused with E-CAP-FORGE (chapter 16).

A common misconception. Branches switched off by the configuration are not checked

examples/ch31/dead_branch.low

module dead_branch .
rem flags: --config smp_off.config
rem expect: E-TYPE-RETURN

build option smp bool default true .

fn rate output u64 .
do
  if config smp . do
    rem this branch is switched off in this configuration --- it is checked all the same
    return true .
  end
  return 0 .
end

Output

$ lowentc --check --config smp_off.config dead_branch.low
dead_branch.low:11:0 E-TYPE-RETURN: the returned value does not match the op's `output` — expected `u64`, found `bool`

With smp switched off, return true . never runs. Even so, this line, which returns a bool where a u64 belongs, is rejected with E-TYPE-RETURN. Code inside C’s #ifdef goes unread in combinations that are not enabled, but a switched-off config branch is parsed and type-checked; it is only left out of the output once the value is known. So whichever configuration you build, the other combinations do not rot.

31.8 This chapter’s syntax at a glance#

ShapeMeaningWhy
package name "greeter" . · package version "0.1.0" .the pkg.low manifesthere, what is written is checked — a closed set of keys
lowentc run · lowentc buildrun the project on the VM · build into out/the manifest is found by walking upward
lowentc add <name> <place> · --lock-write · --lockpin dependencies with hashessame version, different bytes: a different dependency
build option smp bool default true .a build knob — bool·int·choicea knob nobody reads is E-OPT-UNUSED
config smp · --config small.configread a knob as a translation-time constant · a config fileswitched-off branches are checked too
test <name> do expect <condition> . end · --testtest blocks and assertionsfailure is E-TEST-FAIL — not a contract violation
test … schedule explore_interleavings limit <n> . do … enda test that runs every orderbugs of rare orders
lowentc --run <op> <file> <args…>run one op on the VMthe tool fills capability positions — a rejected unit does not run
--why-slow · --no-fastops left on the slow path and why · everything on the slow paththe tool speaks about performance
lowdoc "…" . · tests op1 op2 .documentation attached to the op · names of ops testing itdocs move with the op, and a missing test is reported by the head

Table 31.2 — Build and test syntax — shape · meaning · why it looks this way

Recap

pkg.low is a checked manifest, and lowentc run and build run and build the project. Dependencies are pinned by content hash, and authenticity is checked separately by signatures. build option and config build different programs per build while switched-off branches are still checked, and unread knobs are rejected. A failing test block is a different diagnostic from a contract violation. The compiler is verified against two back ends, contracts, analysis, certificates and one-line changes, and --why-slow says where it is slow.