Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Overview

This guide walks you through spec mechanization in P4-SpecTec, using its core constructs to describe the semantics of Nano-P4, an educational dialect of P4.

Audience and Scope

Audience. This tutorial is written for the P4 community. It focuses on the practical use of P4-SpecTec for specifying a P4-like language, so a basic familiarity with P4 is recommended. A background in programming-language theory is helpful, but no background in formal methods or proof assistants is required.

What is covered.

  • Syntax definition for Nano-P4
  • Type-checking and evaluation specifications for Nano-P4
  • Prose specification generation
  • Hands-on exercises at the end of each section

What is not covered.

  • A complete walkthrough of P4-SpecTec. The basics are covered, but P4-SpecTec is treated as a tool throughout, not as the subject of study.
  • The full P4 language. Nano-P4 is a deliberately small educational subset, and the tutorial stays within that boundary throughout.

By the end, you will have a full mechanized specification, a reference type checker and interpreter, and natural-language documentation for Nano-P4. Most importantly, you will gain hands-on experience writing a mechanized specification in P4-SpecTec, a skill transferable to any project using the toolchain, including the full P4 language mechanization.

P4-SpecTec

P4-SpecTec is a mechanization toolchain for the P4 programming language. It provides a domain-specific language for writing formal specifications in the form of algorithmic inference rules (Lee et al., 2026).

By writing typing rules with P4-SpecTec, you get a reference type checker. By writing dynamic semantics rules, you get a reference interpreter. The prose backend also generates human-readable documentation from a specification written in P4-SpecTec.

In this chapter

  • Installation: how to build and install P4-SpecTec
  • Basics: the core syntax and constructs of P4-SpecTec
  • Toolchain Pipeline: how the toolchain compiles a spec and a program, then runs one against the other
  • Commands: the nano-p4spectec subcommands and their flags
  • Standard Library: the built-in utility functions used throughout the spec

Installation

This section shows how to build the nano-p4spectec binary from the P4-SpecTec source. You will use it along with make throughout the tutorial.

Cloning the Repository

$ git clone https://github.com/kaist-plrg/p4-spectec.git
$ cd p4-spectec
$ git checkout gsoc-nano-spec

Note: All exercises and the nano-p4spectec binary used throughout this tutorial are on the gsoc-nano-spec branch. Make sure you are on that branch before building.

Building from Source

Prerequisites

Linux

$ apt-get install opam
$ opam init

macOS

Install opam version 2.0.5 or higher following the instructions at ocaml.org.

You may also need libgmp-dev and pkg-config depending on your system.

NixOS

If you use Nix, a flake.nix is provided that sets up the full development environment automatically:

$ nix develop
$ make release

This drops you into a shell with OCaml 5.1 and all required packages available, without needing to manage opam manually.

OCaml Compiler and Packages

$ opam switch create 5.1.0
$ eval $(opam env)
$ opam install dune bignum 'menhir=20240715' 'menhirLib=20240715' core core_unix bisect_ppx yojson ppx_deriving_yojson

Building

$ make release

This creates the nano-p4spectec executable in the project root.

Basics

P4-SpecTec specifications are written in .watsup files using the P4-SpecTec language. The examples here come from the SpecTecX tutorial, which specifies Typed Imp, a small typed imperative language. Where Typed Imp does not cover a construct, examples from the nano-p4 specification are used.

Comments

Single-line comments start with ;;. Additional semicolons (;;;, ;;;;, …) are used by convention to indicate heading levels in the document structure, but have no semantic difference.

;;--------------------
;; Syntax

;;--- Contexts ---

Primitive Data Types

P4-SpecTec has the following primitive types:

TypeDescription
boolBoolean (true or false)
intArbitrary-precision integer
natNon-negative integer
textString

These are the types used in metavariable declarations and function signatures, and are distinct from the types of the object language being specified. For example, INT in the Typed Imp spec is a syntax constructor defined with syntax, not the primitive int of the DSL itself.

Options and Lists

Option types are written by appending ? to a type. The absent case is eps, and a present value is written directly:

dec $lookup<K, V>(map<K, V>, K) : V?

def $lookup<K, V>(eps, K_query) = eps    ;; absent: key not found
def $lookup<K, V>((K_h -> V_h)::_, K_query) = V_h
  -- if K_h = K_query                    ;; present: return the value

An if premise can match on an option result:

rule Check_expr/id:
  tenv |- x : t
  -- if $lookup<id, type>(tenv, x) = t   ;; binds t if present, fails if eps

List types are written by appending * to a type. The empty list is eps, and :: is the cons operator:

syntax map<K,V> = (pair<K,V>)*

Pattern matching on lists follows the same :: notation in def cases:

def $lookup<K, V>(eps, K_query) = eps
def $lookup<K, V>((K_h -> V_h)::(K_t -> V_t)*, K_query) = V_h
  -- if K_h = K_query

A single-element list is written [ x ], and lists are concatenated with ++. Here’s one example from the Nano-P4 spec:

def $flatten_parameterList(parameter) = [ parameter ]
def $flatten_parameterList(nonEmptyParameterList ',' parameter)
  = $flatten_parameterList(nonEmptyParameterList) ++ [ parameter ]

A multi-element list literal [ a, b, c ] is also valid.

Lists also appear in rule conclusions and premises to prepend an entry to a context:

rule Check_command/decl:
  tenv |- (t x '=' e) -| (x -> t)::tenv
  -- Check_expr: tenv |- e : t

Here, (x -> t)::tenv constructs a new typing context with the binding x -> t prepended to tenv.

Tuples

A tuple is an anonymous product of two or more values, written with parentheses and commas: (A, B). Tuples do not need to be declared as a named syntax; they can appear inline wherever a type or value is expected.

The most common use is as the element type of a list. For example, $assoc_ in the stdlib takes a list of pairs as its second argument:

builtin dec $assoc_<X, Y>(X, (X, Y)*) : Y?

Here (X, Y)* is a list of (X, Y) tuples.

Tuples also appear as values constructed on the fly in premises. The member access rules in the Nano-P4 spec build a list of (id, typeIR) pairs to pass to $assoc_:

-- if typeIR = $assoc_<id, typeIR>(id_member, (id_field, typeIR_field)*)

The iteration (id_field, typeIR_field)* zips two parallel lists into a list of pairs.

Records

A record is a product type with named, labeled fields. It is declared with braces and comma-separated LABEL value pairs:

syntax globalTypingLayer =
  { TYPE     typeDefEnv,
    CALLABLE callableTypeDefEnv,
    FRAME    typeFrame }

Record values are constructed with the same brace notation:

-- if globalTypingLayer
    = { TYPE $empty_typeDefEnv,
        CALLABLE $empty_callableTypeDefEnv,
        FRAME $empty_typeFrame }

Fields are accessed with dot notation:

-- if typeFrame = TC.GLOBAL.FRAME

Paths can be chained (TC.GLOBAL.FRAME) to reach fields of nested records.

Records are updated functionally with bracket notation. x[.FIELD = v] produces a copy of x with the named field replaced:

def $enter_t(TC)
  = TC[ .LOCAL.FRAMES = $empty_typeFrame :: TC.LOCAL.FRAMES ]

Nested fields can be updated in a single expression as well:

-- if TC' = TC[.GLOBAL.FRAME = typeFrame']

Variant Definitions

The syntax keyword defines a named type. The most common use is to define a grammar production as a set of variants, which forms the abstract syntax of the language being specified. It can also alias an existing type or name a record type.

syntax type =
  | INT
  | BOOL
  | type '->' type

syntax literal =
  | _NUM int
  | _BOOL bool

syntax expr =
  | literal
  | id
  | '!' expr
  | expr '+' expr
  | expr '<=' expr

Each alternative is prefixed with |. Names starting with a lowercase letter refer to built-in types or non-terminals, which are built-in or previously declared syntax types. Everything else is a terminal, an atom of the object language. Atoms come in a few kinds, each with its own marker:

  • Keywords are bare uppercase words, like INT and BOOL. They are written without any marker.
  • Silent tags are prefixed with an underscore, like _NUM and _BOOL. These are constructor labels that disambiguate alternatives but do not render in the generated output.
  • Operators are glyphs wrapped in single quotes, like '->', '+', and '<='. These are the concrete symbols of the object language.
  • Target brackets are backtick-paired and symmetric: `( … `), `{ … `}, and `< … `>. These are the object language’s own brackets. Note that the closing bracket also carries a backtick.

Metavariables

The var keyword declares metavariables and their types. These act as shorthands: wherever e appears unbound in a rule, it is implicitly typed as expr.

var i : int
var b : bool
var x : id
var e : expr
var c : command
var t : type

A variable’s type is inferred from its name by stripping a trailing suffix that begins at the first _ or ' character. For example, given var e : expr, the following names are all valid expr variables:

rule Check_expr/add:
  tenv |- e_l '+' e_r : INT   ;; e_l, e_r -> strip at '_' -> e : expr
  -- Check_expr: tenv |- e_l : INT
  -- Check_expr: tenv |- e_r : INT

rule Check_expr/not:
  tenv |- '!' e' : BOOL        ;; e' -> strip at '\'' -> e : expr
  -- Check_expr: tenv |- e' : BOOL

A suffix of only underscores (e__) or a purely alphanumeric suffix without a separator (eLeft) does not strip, so those would not be recognized as expr variables and would cause an error:

rule Check_expr/not:
  tenv |- '!' eLeft : BOOL     ;; ERROR: eLeft does not resolve to a known variable
  -- Check_expr: tenv |- eLeft : BOOL

Meta-level Operators

P4-SpecTec provides a set of operators that work on meta-level values (the bool, int, and nat primitives), distinct from the operators of the target language being specified.

Boolean operators work on bool meta-values:

OperatorMeaning
~blogical not
b1 /\ b2logical and
b1 \/ b2logical or
b1 = b2equality

These appear in function bodies and if premises:

def $bin_op('&&', _B b_l, _B b_r) = _B (b_l /\ b_r)
def $bin_op('||', _B b_l, _B b_r) = _B (b_l \/ b_r)
def $bin_op('!=', value_l, value_r) = _B (~$bin_eq(value_l, value_r))

-- if direction = OUT \/ direction = INOUT

Note the distinction: '&&' and '||' are target-language terminals (P4 operators in the syntax tree), while /\ and \/ are meta-level boolean operators used to write the spec itself.

Arithmetic expressions on int and nat meta-values are written inside $( ... ):

-- if i' = $($pow2(w) - i - 1)
-- if n'  = $(n - 1)

The $( ... ) delimiter signals that the expression inside uses meta-level arithmetic rather than the target language’s expression grammar.

List length is written with | ... |:

-- if |fieldValue_a*| = |fieldValue_b*|

Set membership is tested with the <- premise, which succeeds if a value matches any element of a list used as a set:

rule Expr_ok/integer:
  scope TC |- unop expression : integerTypeIR
  -- if unop <- [ '~', '-', '+' ]
  -- Expr_ok: scope TC |- expression : integerTypeIR

Function Declarations and Definitions

Functions are declared with dec and defined with def. The declaration gives the name, argument types, and return type. Definitions provide pattern-matched cases. Each of those cases are called clauses.

dec $lookup<K, V>(map<K, V>, K) : V?

;; If map is empty, return empty
def $lookup<K, V>(eps, K_query) = eps

;; If head entry's key matches query, return its value
def $lookup<K, V>((K_h -> V_h)::(K_t -> V_t)*, K_query) = V_h
  -- if K_h = K_query

;; If head entry's key does not match query, recursively call on the rest
def $lookup<K, V>((K_h -> V_h)::(K_t -> V_t)*, K_query)
  = $lookup<K, V>((K_t -> V_t)*, K_query)
  -- otherwise

Key points about function definitions:

  • Function names are prefixed with $.
  • Angle brackets introduce type parameters (e.g. <K, V>). They must be made explicit in function calls.
  • The return type V? means an optional value (eps represents the absent case).
  • Each def case can have side conditions introduced with if, or a catch-all otherwise.
  • When a function is called, clauses are tried from top to bottom. If pattern match fails or if premises are not satisfied, the clause fails and the next clause is tried.

The builtin modifier marks functions whose implementation is provided by the toolchain rather than defined in the spec:

builtin dec $sum_nat(nat*) : nat

Relations and Rules

A relation defines the signature for a set of rules.

;; Type-check `expr` under context `tenv`
relation Check_expr:
  tenv |- expr : type
  hint(input %0 %1)
  hint(prose_in "typechecking" %1 "under context" %0)

This declares a judgment tenv |- expr : type, meaning “expression expr has type type under typing context tenv.” The |- symbol (turnstile) is conventional notation borrowed from inference rules. In P4-SpecTec, it is just a separator between the context and the subject.

%0, %1, etc. refer to the positional components of the judgment. hint(input ...) specifies which components are inputs to the relation. Here, %0 (tenv) and %1 (expr) are inputs, and %2 (type) is the output.

Rules define when a relation holds. Each rule has a conclusion (the judgment being established) and zero or more premises (the conditions that must hold), introduced with --:

;; If expression is integer literal, it has type INT
rule Check_expr/num:
  tenv |- (_NUM i) : INT

;; If expression is logical not,
;;   Check if the operand is BOOL, then it has type BOOL
rule Check_expr/not:
  tenv |- '!' e : BOOL
  -- Check_expr: tenv |- e : BOOL

;; If expression is binary addition,
;;   Check if both operands are INT, then it has type INT
rule Check_expr/add:
  tenv |- e_l '+' e_r : INT
  -- Check_expr: tenv |- e_l : INT
  -- Check_expr: tenv |- e_r : INT

The first rule has no premises; integer literals always have type INT. The second and third rules invoke Check_expr recursively as premises.

Unlike traditional declarative inference rules where premises are unordered and existential witnesses may be guessed non-deterministically, P4-SpecTec rules are algorithmic: premises are executed in order, from top to bottom, and every value must be computed from already-known inputs. This makes rules directly executable as a type checker or interpreter.

An if premise introduces a boolean-valued side condition:

rule Check_expr/id:
  tenv |- x : t
  -- if $lookup<id, type>(tenv, x) = t

The = in an if premise is overloaded: if the right-hand side is already known, it is a check; if it is an unbound metavariable, it becomes a binding that computes the value from the left-hand side. Here, t is unbound, so $lookup is called and its result is bound to t, which is then used in the conclusion tenv |- x : t. If $lookup returns eps (absent), the rule fails and the next rule is tried.

Rules can mix if and relation premises freely:

rule Check_command/assign:
  tenv |- (x '=' e) -| tenv
  -- Check_expr: tenv |- e : t
  -- if $lookup<id, type>(tenv, x) = t

Here the relation premise Check_expr runs first and binds t, then the if premise checks that x is already declared with that same type.

How Rules are Executed

When the inputs to a relation arrive, the interpreter works through the candidate rules one by one until one succeeds. Understanding this dispatch process matters for writing correct specs and for debugging when something goes wrong:

  • Rules are tried in declaration order. When a relation or function has multiple rules or clauses, P4-SpecTec tries them from top to bottom. The first one whose premises all hold wins. This means that the order of clauses in the spec matters.
  • A failed premise aborts the current clause. If a pattern match fails or an if condition is not satisfied, that clause is abandoned immediately and the interpreter moves on to the next one. No error is reported at this point; the failure is silent.
  • If no clause matches, the relation fails. When every candidate clause has been tried and none succeeded, the interpreter reports a runtime error. A missing rule and a wrong pattern produce the same symptom, which is why the debugging techniques in Chapter 6 focus on narrowing down which clause was the last to be attempted.

Rule Groups

When several rules share the same conclusion shape but differ only in their premises, they can be grouped under a single rulegroup heading. This is purely organizational: the toolchain treats each case inside a rulegroup as an independent rule.

For example, the typing rules for unary expressions in the nano-p4 spec are written as a rule group:

rulegroup Expr_ok/unaryExpression {

  rule Expr_ok/boolean:
    scope TC |- '!' expression : BOOL
    -- Expr_ok: scope TC |- expression : BOOL

  rule Expr_ok/integer:
    scope TC |- unop expression : integerTypeIR
    -- if unop <- [ '~', '-', '+' ]
    -- Expr_ok: scope TC |- expression : integerTypeIR

}

The outer rulegroup Expr_ok/unaryExpression { ... } names the group but adds no semantics. Each rule inside is a full, independent rule with its own name and premises.

Iteration

The (pattern)* syntax applies a pattern element-wise over a list.

For example, the following clause (from the Nano-P4 spec) extracts the third component of every element in parameterIR* into a new list nameIR*:

def $distinct_params(parameterIR*)
  = $distinct_<nameIR>(nameIR*)
  -- if (_ _ nameIR = parameterIR)*

The if premise runs the pattern _ _ nameIR = parameterIR once per element, binding nameIR at each position and collecting the results into nameIR*.

More advanced usages of iteration are to be demonstrated further into the tutorial.

Prose Hints

Prose hints are metadata annotations that guide the prose backend when generating human-readable documentation.

relation Eval_expr:
  env |- expr ==> value
  hint(input %0 %1)
  hint(prose_in "evaluating" %1 "in environment" %0)

hint(prose_in ...) controls the generated prose description.

Individual rules can also carry hints:

syntax literal =
  | _NUM int   hint(prose "the integer" %0)
  | _BOOL bool hint(prose "the boolean" %0)

The prose backend is covered in more detail in Generating Prose Specification.

Exercise

For a more hands-on introduction, we strongly recommend the SpecTecX tutorial.

Toolchain Pipeline

When you run nano-p4spectec, two separate artifacts are at play: a specification (your .watsup files) and a program (a .p4 source file).

Overview

                      .watsup files                    .p4 file
                           │                              │
                           ▼                              ▼
                      ┌─────────┐                    ┌─────────┐
                      │  elab   │                    │  parse  │
                      └────┬────┘                    └────┬────┘
                           │                              │
                           ▼                              │
                       spec (IL)                          │
                           │                              │
                           ▼                              │
                      ┌─────────┐                         │
                      │  algo   │                         │
                      └────┬────┘                         │
                           │                              │
                           ▼                              ▼
                       spec (AL)                   program (meta-value)
                           │                              │
                           └───────────────┬──────────────┘
                                           │
                                           ▼
                                    ┌─────────────┐
                                    │ interpreter │
                                    └──────┬──────┘
                                           │
                                           ▼
                                     relation result
                                  (pass / fail / packets)

There are two independent compilation steps, and the interpreter joins their outputs.

Step 1: Elaborate the spec

The .watsup files are parsed and elaborated into IL (Internal Language), a type-checked and desugared representation of the spec.

Elaboration checks that the spec itself is well-formed: syntax definitions are consistent, rule conclusions match their relation signatures, function clauses are well-typed, and so on.

$ ./nano-p4spectec elab nano-p4/spec

Step 2: Check algorithmic executability

The elaborated IL is analyzed to verify that every rule is algorithmically executable: each variable in a rule’s conclusion and premises must be computable from the declared inputs, with no existential guessing required. The output of this step is AL (Algorithmic Language), the representation that the interpreter actually runs.

$ ./nano-p4spectec algo nano-p4/spec

If any rule cannot be made algorithmic, an AlgoError is reported here before the interpreter ever runs.

Step 3: Parse the program

The .p4 source file is parsed by the Nano-P4 parser into a P4-SpecTec meta-value (the given P4 program is represented as a value in the P4-SpecTec meta-language). It is a tree of constructor tags and nested values that directly mirrors the syntax definitions in the spec. Because it is a value in the same language that the spec is written in, the interpreter can pass it directly to spec relations.

You can inspect this value with:

$ ./nano-p4spectec parse -p <file> -i nano-p4/include

The -t flag prints it as an indented tree, which is easier to read:

$ ./nano-p4spectec parse -t -p <file> -i nano-p4/include
Example output
$ cat action.p4
action MyAction() {
    bit<8> x = 8w42;
}

$ ./nano-p4spectec parse \
    -i nano-p4/include \
    -p action.p4 \
    -t
program % %
├── declarationList /* empty */
└── actionDeclaration ACTION % (%) %
    ├── identifier `ID %
    │   └── "MyAction"
    ├── parameterList /* empty */
    └── blockStatement {%}
        └── statementList % %
            ├── statementList /* empty */
            └── variableDeclaration % % % ;
                ├── baseType BIT <%>
                │   └── +8
                ├── identifier `ID %
                │   └── "x"
                └── initializer = %
                    └── integerLiteral % W %
                        ├── 8
                        └── +42

Step 4: Interpret

The interpreter takes the elaborated spec and the program value and executes a relation against the program. The check command runs the Program_ok relation:

$ ./nano-p4spectec check nano-p4/spec \
    -i nano-p4/include \
    -p <file>

This command converts a .p4 program to an IL value and passes it to the Program_ok relation, which type-checks the entire program.

What this means in practice

  • A spec error (syntax, type, or rule error in .watsup) surfaces during elaboration, before the program is touched.
  • An algo error means a rule cannot be made algorithmically executable: some variable is not computable from the declared inputs.
  • A parse error means the .p4 file is not valid Nano-P4 syntax.
  • A runtime error means the interpreter got stuck executing the spec against the program. This is typically due to a rule that has no matching case for the given input.
  • A test failure from eval means the spec’s dynamic semantics produced different output packets than the STF file expected.

Commands

All commands assume you are running from the root of the nano-spec repository with nano-p4spectec already built (see Installation). The spec files live in nano-p4/spec and example Nano-P4 programs live in nano-p4/testdata/.

elab

Elaborates the spec and prints the resulting IL.

$ ./nano-p4spectec elab nano-p4/spec

Tip: During active spec development, run elab in a watch loop so errors surface immediately:

$ watchexec -w nano-p4/spec ./nano-p4spectec elab nano-p4/spec

algo

Elaborates the spec and checks that every rule is algorithmically executable, then prints the resulting AL (Algorithmic Language) representation.

$ ./nano-p4spectec algo nano-p4/spec

A rule is algorithmic if every variable in its conclusion and premises can be computed from the declared inputs — no existential guessing is required. If any rule violates this, algo reports an AlgoError. Passing algo is a prerequisite for the interpreter to run the spec.

parse

Parses a Nano-P4 source file and prints its IL value. Does not load the spec.

$ ./nano-p4spectec parse -t \
    -p nano-p4/testdata/positive/action-call.p4 \
    -i nano-p4/include
FlagDescription
-p <file>Path to the Nano-P4 program
-i <dir>Include path for Nano-P4 headers (can be repeated)
-tPrint the IL value as an indented tree

check

Elaborates the spec and type-checks a Nano-P4 program against the Program_ok relation.

$ ./nano-p4spectec check nano-p4/spec \
    -i nano-p4/include \
    -p nano-p4/testdata/positive/action-call.p4
FlagDescription
-p <file>Path to the Nano-P4 program
-i <dir>Include path for Nano-P4 headers (can be repeated)
-trace-fullEmit a full execution trace (useful for debugging)

On success, prints passed. On failure, prints an error message.

eval

Runs an end-to-end simulation against a program and an STF test file.

$ ./nano-p4spectec eval nano-p4/spec \
    -i nano-p4/include \
    -p nano-p4/testdata/positive/action-call.p4 \
    -stf nano-p4/testdata/positive/action-call.stf
FlagDescription
-p <file>Path to the Nano-P4 program
-stf <file>Path to the STF test file
-i <dir>Include path for Nano-P4 headers (can be repeated)

On success, prints passed.

STF file format

STF (Simple Test Framework) describes packets to inject and the expected output. Nano-P4 supports two directives:

packet <port> <hex-payload>: send a packet on the given port.

expect <port> <hex-payload>: assert a packet is emitted on the given port. A packet with no following expect is expected to be dropped.

packet 0 010000
expect 0 010000

packet 0 020000

packet 0 030000
expect 0 030000

test-check

Batch-typechecks all .p4 files in one or more directories against Program_ok and prints a per-file PASS/FAIL summary.

$ ./nano-p4spectec test-check nano-p4/spec \
    -i nano-p4/include \
    -p4-dir nano-p4/testdata/positive
FlagDescription
-p4-dir <dir>Directory of .p4 files to test (can be repeated)
-i <dir>Include path for Nano-P4 headers (can be repeated)
-negNegative testing mode. Expect all programs to fail typechecking

Use -neg with a directory of intentionally invalid programs to verify that your spec correctly rejects them:

$ ./nano-p4spectec test-check nano-p4/spec \
    -i nano-p4/include \
    -neg \
    -p4-dir nano-p4/testdata/negative

test-eval

Batch-runs all .p4/.stf pairs found in one or more directories and prints a per-test PASS/FAIL summary.

$ ./nano-p4spectec test-eval nano-p4/spec \
    -i nano-p4/include \
    -p4-dir nano-p4/testdata/positive
FlagDescription
-p4-dir <dir>Directory containing .p4/.stf pairs (can be repeated)
-i <dir>Include path for Nano-P4 headers (can be repeated)

Only .p4 files with a matching .stf file (same base name) are run; others are silently skipped.

Quick reference

GoalCommand
Check spec syntax./nano-p4spectec elab nano-p4/spec
Check executability./nano-p4spectec algo nano-p4/spec
Parse a P4 file./nano-p4spectec parse -p <file> -t -i nano-p4/include
Type-check a program./nano-p4spectec check nano-p4/spec -i nano-p4/include -p <file>
Execute with packets./nano-p4spectec eval nano-p4/spec -i nano-p4/include -p <file> -stf <stf>
Batch typecheck test./nano-p4spectec test-check nano-p4/spec -i nano-p4/include -p4-dir <dir>
Batch execution test./nano-p4spectec test-eval nano-p4/spec -i nano-p4/include -p4-dir <dir>

Standard Library

The spec files make heavy use of a set of utility functions defined in 0-stdlib.watsup. These are not specific to Nano-P4; they are generic building blocks for working with sequences, sets, and maps in P4-SpecTec.

Note: This page is a reference you can return to as you read through Sections 3 and 5. You do not need to memorize everything here; skim it once so the names are familiar, then come back when you need a reminder.

Sequences

A sequence (written X*) is an ordered list of elements. The empty sequence is eps, and :: is the cons operator.

builtin dec $rev_<X>(X*) : X*

$rev_ reverses a sequence.

builtin dec $distinct_<K>(K*) : bool

$distinct_ returns true if all elements in the sequence are unique. It is used to enforce no-duplicate-name constraints, for example checking that a parameter list does not repeat a name.

builtin dec $assoc_<X, Y>(X, (X, Y)*) : Y?

$assoc_ looks up a key in an association list (a sequence of pairs) and returns the associated value, or eps if not found.

dec $repeat_<X>(X, nat) : X*

$repeat_ produces a sequence of nat copies of a value.

dec $exists_(bool*) : bool
dec $forall_(bool*) : bool

$exists_ returns true if at least one element is true. $forall_ returns true if all elements are true.

Sets

A set is written as `{ K* }, a collection of unique keys with no ordering.

dec $empty_set<K> : set<K>
dec $in_set<K>(set<K>, K) : bool

$empty_set produces an empty set. $in_set checks whether a key is a member of a set.

builtin dec $intersect_set<K>(set<K>, set<K>) : set<K>
builtin dec $union_set<K>(set<K>, set<K>)     : set<K>
builtin dec $unions_set<K>(set<K>*)            : set<K>
builtin dec $diff_set<K>(set<K>, set<K>)       : set<K>
builtin dec $sub_set<K>(set<K>, set<K>)        : bool
builtin dec $eq_set<K>(set<K>, set<K>)         : bool

Standard set operations: intersection, union, union of a sequence of sets, difference, subset check, and equality.

Maps

A map is a set of key-value pairs, written { (K : V)* }.

dec $empty_map<K, V> : map<K, V>

$empty_map produces an empty map.

dec $dom_map<K, V>(map<K, V>) : set<K>
dec $codom_map<K, V>(map<K, V>) : set<V>

$dom_map returns the set of keys. $codom_map returns the set of values. $dom_map appears frequently in the spec to check whether a name is already bound before adding it.

builtin dec $find_map<K, V>(map<K, V>, K) : V?
builtin dec $find_maps<K, V>(map<K, V>*, K) : V?

$find_map looks up a key in a single map and returns the value, or eps if not found. $find_maps searches a sequence of maps from left to right, returning the first match. This is used for variable lookup across a stack of frames.

builtin dec $add_map<K, V>(map<K, V>, K, V) : map<K, V>
builtin dec $update_map<K, V>(map<K, V>, K, V) : map<K, V>

$add_map inserts a new key-value pair. The spec always checks that the key is not already present before calling $add_map, so it is effectively a no-overwrite insert. $update_map updates the value for an existing key. One use is writing to a variable already in scope.

Nano-P4

Nano-P4 is an educational dialect of P4 designed for this tutorial. P4 in its entirety has a large surface area, so mechanizing it from scratch would be an overwhelming first project. Nano-P4 intentionally strips away the more complex corners of the language and keeps only enough to illustrate every step of the spec-authoring workflow end to end.

By the end of this chapter, you will have a clear picture of what you are specifying before writing a single line of P4-SpecTec.

In this chapter

Scope of Nano-P4

This section describes what Nano-P4 includes and what it leaves out. For a complete grammar reference, see Appendix A: Nano-P4 Grammar.

What Nano-P4 includes

Types

Nano-P4 supports the following types:

  • Boolean : bool
  • Fixed-width integers : bit<N> and int<N> where N is a compile-time constant
  • match_kind : the built-in kind used to annotate table key fields
  • Struct types : declared with struct
  • Header types : declared with header

Named types (structs and headers) can be used anywhere a type is expected.

Expressions

Nano-P4 supports a standard set of expressions:

  • Boolean and integer literals
  • Variable references
  • Unary operators: !, ~, -, +
  • Binary operators: arithmetic (*, +, -), comparison (<, <=, >, >=, ==, !=), and bitwise/logical (&, |, ^, &&, ||)
  • Member access: expr.field
  • Function and extern calls: f(args)
  • Parenthesized expressions

Statements

Inside parser states, control apply blocks, and action bodies, the following statements are available:

  • Empty statement (;)
  • Variable declaration with mandatory initializer: type name = expr;
  • Assignment: lvalue = expr;
  • Call statement: lvalue(args);
  • Block: { ... }
  • Conditional: if (expr) { ... } else { ... } : both branches are required

Actions

Top-level action declarations are supported:

action drop(inout Header hdr) {
    hdr.nanonet.drop = true;
}

Actions must be declared at the top level of the program. Action declarations inside a control block are not supported.

Tables

Tables are supported in a limited form. A table must have a key property and an actions property, and may optionally have a const entries property:

table t {
    key = { hdr.nanonet.drop : exact; }
    actions = { drop(hdr); }
    const entries = {
        (true) : drop(hdr);
    }
}

Restrictions compared to full P4:

  • Exactly one key field (the key block takes a single expr : match_kind entry)
  • Control plane operations are not supported, so table entries cannot be extended at runtime.
  • No default_action, size, or other table properties
  • Tables can only be declared inside a control block as a local declaration, not at the top level

Parser block

Parser declarations are fully supported, including multiple named states and select expressions for branching:

parser MyParser(packet_in pkt, out Header hdr) {
    state start {
        pkt.extract(hdr.nanonet);
        transition select(hdr.nanonet.drop) {
            true : drop_state;
            false : accept;
        }
    }
    state drop_state {
        transition accept;
    }
}

A parser state body consists of zero or more variable declarations followed by a transition statement.

Control block

Control declarations are supported. A control may contain local variable declarations and table declarations, followed by an apply block:

control MyControl(inout Header hdr, out bool pass) {
    table t { ... }
    apply {
        t.apply();
    }
}

Extern declarations

Extern object types (used to declare things like packet_in) can be declared with method prototypes, but not constructors:

extern packet_in {
    void extract(out Nanonet hdr);
}

This is how the architecture model exposes built-in operations to the program.

Instantiation

Top-level instantiation is supported and is how the main package is assembled:

NanoSwitch(MyParser(), MyControl()) main;

What Nano-P4 excludes

These features are absent to keep the spec tractable and the type-checking rules focused. The following P4 features are intentionally absent from Nano-P4.

Types

  • Header stacks (header[N])
  • enum types
  • header_union
  • list and tuple types
  • Generic types
  • Arbitrary-width integer literals

Statements and control flow

  • for loops
  • switch statement
  • if without an else

Declarations and scoping

  • Type aliases (typedef, type)
  • Constructor parameters on parsers/controls
  • Nested action declarations

Expressions and operators

  • Implicit type casting
  • Explicit type casting ((T) expr)
  • Method overloading
  • Dot-prefix notation (.field without a receiver)

Table features

  • The return value of table.apply() (.hit, .action_run)
  • default_action
  • size and other table properties
  • Multiple key fields

Header built-in methods

  • isValid(), setValid(), setInvalid(), and similar header methods

Syntax Definition in P4-SpecTec

With the scope of Nano-P4 in mind, let us see how its syntax is expressed in P4-SpecTec. The full syntax is defined in 1-syntax.watsup. Reading it gives a feel for what P4-SpecTec syntax definitions look like at scale, before the type-checking and evaluation rules in later chapters.

Terminals and Non-terminals

Every syntax production in P4-SpecTec is built from two kinds of atoms.

Non-terminals are references to other syntax productions. They appear as lowercase names, for example expression, type, or name.

Terminals are concrete tokens of the language being specified. They appear in two forms:

  • Keyword terminals are written in ALL_CAPS: IF, ELSE, STRUCT, PARSER, CONTROL, and so on. These correspond to reserved keywords in Nano-P4.
  • Punctuation terminals are written with a leading backtick: `(, `), `{, `}, `;, `., `=, and so on. The backtick distinguishes a literal token from the syntax of P4-SpecTec.

For example, the production for an assignment statement:

syntax assignmentStatement = lvalue '=' expression ';'

reads as: an assignment is an lvalue non-terminal, followed by the literal = token, followed by an expression non-terminal, followed by the literal ; token.

Literals

The two literal forms in Nano-P4 are booleans and integers.

syntax booleanLiteral =
  | TRUE
  | FALSE

syntax integerLiteral =
  | nat W int
  | nat S int

TRUE and FALSE are terminals. Integer literals carry two pieces of metadata: a width nat and a value int. The W terminal marks an unsigned bit-string and S a signed integer. These correspond to bit<N> and int<N> literals in source Nano-P4.

Identifiers

Nano-P4 uses two distinct identifier categories:

syntax identifier = _ID text
syntax typeIdentifier = _TID text

identifier carries the ID tag and a text payload; typeIdentifier uses TID. This split mirrors the official P4 grammar, where the lexer distinguishes regular identifiers from type names already declared.

From these two primitives, several name non-terminals are derived:

syntax nonTypeName =
  | identifier
  | APPLY | KEY | ACTIONS | STATE

syntax typeName = typeIdentifier

syntax name = nonTypeName

nonTypeName adds the contextual keywords APPLY, KEY, ACTIONS, and STATE as valid identifiers: they are reserved in some positions but can appear as plain names in others. typeName is just a typeIdentifier wrapped for clarity. name collapses to nonTypeName, which is what most of the spec refers to.

Types

syntax integerType =
  | BIT `< int `>
  | INT `< int `>

syntax baseType =
  | integerType
  | BOOL
  | MATCH_KIND

syntax type =
  | baseType
  | namedType

integerType captures bit<N> and int<N> with the width stored as an int meta-value directly in the syntax tree. baseType bundles integer types with the two keyword types BOOL and MATCH_KIND. type is the union of base types and named types (structs and headers resolved by name).

Parameters

syntax parameter =
  direction type name

syntax direction = _EMPTY | IN | OUT | INOUT

A parameter is a direction, a type, and a name, laid out in sequence. direction has four cases: the three P4 keywords and _EMPTY for the directionless case (parameters with no direction annotation).

The parameterList production handles the empty-or-nonempty split:

syntax parameterList =
  | _EMPTY
  | nonEmptyParameterList

The _EMPTY here is a P4-SpecTec internal sentinel, not a P4 keyword. It is a terminal token in the grammar, but never appears in a real Nano-P4 source file.

syntax nonEmptyParameterList =
  | parameter
  | nonEmptyParameterList ',' parameter

Any sequence production such as nonEmptyParameterList uses left-recursive form, matching the Yacc/Bison style of the P4 grammar.

However, the spec must convert these to right-recursive lists to access elements in order. Therefore, alongside the syntax, the spec defines a helper function to flatten a parameterList into a flat list parameter*:

dec $flatten_parameterList(parameterList) : parameter*
def $flatten_parameterList(_EMPTY) = eps
def $flatten_parameterList(parameter) = [ parameter ]
def $flatten_parameterList(nonEmptyParameterList ',' parameter)
  = $flatten_parameterList(nonEmptyParameterList) ++ [ parameter ]

This pattern, a dec / def pair that recursively accumulates elements into a list, appears throughout 1-syntax.watsup for every list-valued production: nameList, argumentList, statementList, and so on. The type checker and evaluator call these helpers instead of pattern-matching on the recursive list syntax directly.

Expressions

Expressions form the most layered part of the grammar. The spec defines them in named groups before assembling them under expression:

syntax expression =
  | literalExpression
  | referenceExpression
  | unaryExpression
  | binaryExpression
  | memberAccessExpression
  | callExpression
  | parenthesizedExpression

A few sub-productions are worth noting.

Unary and binary expressions encode their operators as separate syntax productions:

syntax unop = '!' | '~' | '-' | '+'

syntax binop =
  | '*' | '+' | '-'
  | '<=' | '>=' | '<' | '>' | '!=' | '=='
  | '&' | '^' | '|' | '&&' | '||'

Member access and call expressions break a mutual recursion problem: expression needs to refer to memberAccessBase, but memberAccessBase must itself refer back to expression. P4-SpecTec resolves this with forward declarations:

syntax memberAccessBase           ;; forward declaration

syntax memberAccessExpression = memberAccessBase '.' member
syntax callExpression = callTarget `( argumentList `)

;; ... expression is now fully defined ...

syntax memberAccessBase = expression
syntax callTarget = namedType

P4-SpecTec requires a syntax declaration before first use, so these are declared with no alternatives and then given their full definition later in the file after expression itself is complete.

L-values

syntax lvalue =
  | referenceExpression
  | lvalue '.' member
  | `( lvalue `)

L-values are a strict subset of expressions: a variable reference, a member access rooted at an l-value, or a parenthesized l-value. The spec keeps lvalue separate from expression so the typechecker can restrict what appears on the left-hand side of an assignment without inspecting expression structure at every assignment site.

Statements

syntax statement =
  | emptyStatement
  | variableDeclaration
  | assignmentStatement
  | callStatement
  | blockStatement
  | conditionalStatement

conditionalStatement requires both branches:

syntax conditionalStatement =
  IF `( expression `) blockStatement ELSE blockStatement

This directly encodes the Nano-P4 restriction from the Scope section: if without else is not allowed.

Unlike P4, variableDeclaration requires an initializer:

syntax initializer = '=' expression

syntax variableDeclaration =
  type name initializer ';'

Declarations

Nano-P4 has the following top-level declaration forms, each with its own production:

syntax declaration =
  | instantiation
  | actionDeclaration
  | matchKindDeclaration
  | externDeclaration
  | parserDeclaration
  | controlDeclaration
  | typeDeclaration

A few representative examples:

syntax actionDeclaration =
  ACTION name `( parameterList `) blockStatement

syntax externObjectDeclaration =
  EXTERN name `{ externMethodPrototypeList `}

syntax controlDeclaration =
  CONTROL name
    `( parameterList `)
    `{ controlLocalDeclarationList APPLY controlBody `}

controlDeclaration places APPLY inside the body braces, which matches the actual P4 syntax.

Parser and control declarations follow the split-body pattern seen in the scope section: local declarations come first, then states (for parsers) or the apply body (for controls).

Putting It Together

At the top level, a Nano-P4 program is a sequence of declarations:

syntax program =
  | _EMPTY
  | program declaration

As with all list productions, a $flatten_program helper converts it to a flat declaration* that the rest of the spec consumes.

With this syntax definition in hand, the typechecker and evaluator can refer to every Nano-P4 construct by name, and the spec stays readable as the rules grow more complex in later chapters.

NanoSwitch Architecture

NanoSwitch is the target architecture for Nano-P4. It is a minimal packet-filtering pipeline heavily inspired by the eBPF architecture.

Package declaration

The NanoSwitch architecture is declared in nano_model.p4:

parser parse(packet_in packet, out Header hdr);
control filter(inout Header hdr, out bool accept);

package NanoSwitch(parse p, filter f);

A Nano-P4 program must instantiate this package at the top level, providing a concrete parser and filter:

NanoSwitch(MyParser(), MyFilter()) main;

Pipeline

                 ┌──────────────────────────────────────────────┐
                 │               NanoSwitch                     │
                 │                                              │
  packet_in      │   ┌────────┐   accept   ┌────────┐           │
─────────────────┼──►│ Parser ├───────────►│ Filter ├───────┐   │
                 │   └────────┘            └────────┘       │   │
                 │        │                    │            │   │
                 │        │ reject             │            ▼   │
                 │        │                   accept?  forward/drop
                 │        ▼                             │       │
                 │       DROP                           └───────┼──► packet_out
                 │                                              │    (or dropped)
                 └──────────────────────────────────────────────┘

The pipeline has two stages:

  1. Parser: Reads the packet and extracts the Header.
  2. Filter: Receives the parsed header and an accept flag (initialized to false). The control block sets accept based on header fields and table lookups. After the filter runs, the pipeline reads accept and either forwards or drops the packet.

Unlike more complex P4 architectures, NanoSwitch has no inter-block behaviors or shared logic between stages.

Core definitions

The architecture also provides a fixed set of core definitions in nano_core.p4.

The Nanonet header

header Nanonet {
    bool     drop;
    bit<7>   packetType;
    bit<8>   src;
    bit<8>   dst;
}

This is the only header type in NanoSwitch. It is 24 bits (3 bytes) wide. The drop field is the primary signal used by filtering programs, though the actual forwarding decision is controlled by the accept flag output from the filter.

The Header struct

struct Header {
    Nanonet nanonet;
}

The top-level header struct holds exactly one Nanonet header. This is what the parser extracts into and what the filter operates on.

The packet_in extern

extern packet_in {
    void extract(out Nanonet hdr);
}

packet_in is the only extern object available to the parser. Calling extract reads the next 24 bits from the incoming packet into the provided header. If fewer than 24 bits remain, the call does nothing and the read cursor does not advance.

Built-in action and match kind

action NoAction() {}

match_kind { exact }

NoAction is a no-op action available for use in table action lists. exact is the only supported match kind for table key fields.

Test Suite

The Nano-P4 test suite lives in nano-p4/testdata/ and is split into two directories:

  • positive/: 32 nano-p4 programs that are expected to type-check and produce correct output
  • negative/: 21 nano-p4 programs that are expected to be rejected by the type checker

Positive tests

Each positive test is a pair of files with the same base name:

  • <name>.p4: a valid Nano-P4 program
  • <name>.stf: a packet test that drives the program and checks its output

For example, table-const-entries.p4 installs an ACL table and exercises it with packets of different packet types:

#include <nano_model.p4>

action drop(out bool pass) {
    pass = false;
}

action fwd(out bool pass) {
    pass = true;
}

parser Parser(packet_in pkt, out Header hdr) {
    state start {
        pkt.extract(hdr.nanonet);
        transition accept;
    }
}

control Filter(inout Header hdr, out bool pass) {
    table acl {
        key = { hdr.nanonet.packetType : exact; }
        actions = { drop(pass); fwd(pass); }
        const entries = {
            (7w1) : fwd(pass);
            (7w2) : fwd(pass);
            (7w0) : drop(pass);
        }
    }
    apply {
        pass = true;
        acl.apply();
    }
}

NanoSwitch(Parser(), Filter()) main;

Its companion table-const-entries.stf sends three packets and asserts which ones are forwarded:

packet 0 010000
expect 0 010000

packet 0 000000

packet 0 050000
expect 0 050000

The first packet (packetType = 0x01) matches entry 7w1 and is forwarded, so the .stf file includes an expect directive echoing it back. The second packet (packetType = 0x00) matches entry 7w0 and is dropped, so there is no corresponding expect. The third packet (packetType = 0x05) has no matching entry; pass stays true from the initializer and the packet is forwarded.

Negative tests

Negative tests are .p4 files only. There is no .stf companion because the program should not get past typechecking.

For example, bit-arith-mixed-widths.p4 attempts arithmetic between operands of different widths:

#include <nano_model.p4>

parser Parser(packet_in pkt, out Header hdr) {
    state start {
        transition accept;
    }
}

control Filter(inout Header hdr, out bool pass) {
    apply {
        bit<8> x = 8w10;
        bit<16> y = 16w3;
        bit<8> sum = x + y;
        pass = sum == 8w13;
    }
}

NanoSwitch(Parser(), Filter()) main;

This should be rejected because x + y mixes bit<8> and bit<16>, which the type system does not allow.

Running the suite

Run all positive tests (type-check + simulate):

$ ./nano-p4spectec test-check nano-p4/spec \
    -i nano-p4/include \
    -p4-dir nano-p4/testdata/positive

$ ./nano-p4spectec test-eval nano-p4/spec \
    -i nano-p4/include \
    -p4-dir nano-p4/testdata/positive

Run all negative tests (expect rejection):

$ ./nano-p4spectec test-check nano-p4/spec \
    -i nano-p4/include \
    -neg \
    -p4-dir nano-p4/testdata/negative

As you write new spec rules, re-running these commands is the primary way to verify correctness. Keep them handy throughout the tutorial.

Writing Static Semantics Rules

Static semantics defines what it means for a Nano-P4 program to be well-typed. Before a program runs, the type checker walks through every declaration, statement, and expression to verify that types are used consistently and that names refer to things that actually exist.

In this chapter, we read through the static semantics specification of Nano-P4 piece by piece. Rather than building the spec from scratch, we take a functioning spec apart and understand what it means. There are curated exercises at the end of every section where you debug or extend a faulty version of the spec. Each section explains what a piece of the spec says, why it is written that way, and how it connects to the P4-SpecTec constructs you saw in the previous chapter.

By the end of this chapter, the full type-checking relation for an entire program is:

relation Program_ok:
  |- program -| typingContext
  hint(input %0)

Program_ok takes a program as input and, if the program is well-typed, produces a typingContext as output; otherwise it fails.

In this chapter

  • Typing Context: the data structures that hold type information as the checker walks the program
  • Types: rules for validating type expressions and checking type equality
  • Expressions and L-values: how the type of an expression is derived from its parts, and how assignable locations are checked
  • Statements: how statements are checked and how variable declarations extend the context
  • Parameters and Arguments: how function signatures are elaborated and how call sites are validated against them
  • Declarations: how top-level declarations such as actions, externs, type definitions, parsers, and controls are checked and registered
  • Parser Block: how parser local declarations and parser states are type-checked
  • Control Block: how control local declarations are type-checked
  • Tables: how table keys, action lists, and constant entries are validated

Typing Context

Before writing a single typing rule, we need somewhere to store what the checker knows so far: which type names exist, which actions and parsers have been declared, and which variables are in scope. That storage is the typing context, defined in 5.00-typing-context.watsup.

The Three Layers

The typing context is not a single flat map. It is split into three layers, each serving a different scoping purpose.

syntax typingContext =
  { GLOBAL globalTypingLayer,
    BLOCK  blockTypingLayer,
    LOCAL  localTypingLayer }

The global layer holds information visible everywhere in the program: type definitions (structs, headers, externs, parsers, controls, packages), callable definitions (actions, parsers, controls), and global variables.

syntax globalTypingLayer =
  { TYPE     typeDefEnv,
    CALLABLE callableTypeDefEnv,
    FRAME    typeFrame }

The block layer holds the parameters of the current parser or control block. Parameters are declared once at the top of a block and visible throughout it, but they must be kept separate from local variables so that scoping rules can be enforced correctly. This layer also holds variables declared at block level declarations in parser and control blocks.

syntax blockTypingLayer =
  { FRAME typeFrame }

The local layer holds local variables declared inside a block body. It is a stack of frames rather than a single frame, because P4 allows nested block statements, each of which introduces its own scope.

syntax localTypingLayer =
  { FRAMES typeFrame* }

A typeFrame is a map from variable names to their types and directions:

syntax varTypeIR  = direction typeIR
syntax typeFrame  = map<id, varTypeIR>

The direction (IN, OUT, INOUT, or EMPTY for directionless) is stored alongside the type because the checker needs it to enforce l-value rules, such as only OUT and INOUT variables being allowed on the left side of an assignment.

Callables and Type Definitions

Two environments in the global layer deserve a closer look.

typeDefEnv maps type names to their internal representations (typeDefIR). When the checker sees a named type like Header, it looks it up here to resolve it to its full struct or header definition.

callableTypeDefEnv maps callable names to their callable type definitions:

syntax callableTypeDef =
  | ACTION parameterIR*
  | PARSER parameterIR*
  | CONTROL parameterIR*

This is what the checker looks up when it sees an action call or a constructor invocation such as MyAction() or Filter(). The full P4 spec separates callables and constructors into distinct environments; Nano-P4 merges them into a single callableTypeDefEnv for simplicity.

The scope Tag

Many functions in this file take a scope argument:

syntax scope = GLOBAL | BLOCK | LOCAL

This tag is how the spec selects which layer to read from or write to. Rather than writing three separate functions for each operation, the spec uses one function with three pattern-matched cases dispatching on the scope. You will see this pattern throughout the static semantics.

Frame Entry and Exit

When the checker enters a block statement, it pushes a new empty frame onto the local stack. When it exits, it pops that frame and discards any variables declared inside.

def $enter_t(TC)
  = TC[ .LOCAL.FRAMES = $empty_typeFrame :: TC.LOCAL.FRAMES ]

def $exit_t(TC) = TC[ .LOCAL.FRAMES = typeFrame_t* ]
  -- if typeFrame_h :: typeFrame_t* = TC.LOCAL.FRAMES

$enter_t prepends an empty frame with ::. $exit_t discards the head frame by pattern-matching the stack as typeFrame_h :: typeFrame_t* and reconstructing the context with only the tail.

This pair is used together around a block body, ensuring that variables declared inside a block cannot escape it.

Adders

The adder functions insert a new binding into the appropriate layer, first checking that the name is not already bound, then adding the new entry.

def $add_var_t(LOCAL, TC, id, varTypeIR) = TC'
  -- if typeFrame_h :: typeFrame_t* = TC.LOCAL.FRAMES
  -- if ~$in_set<id>($dom_map<id, varTypeIR>(typeFrame_h), id)
  -- if typeFrame_h' = $add_map<id, varTypeIR>(typeFrame_h, id, varTypeIR)
  -- if TC' = TC[ .LOCAL.FRAMES = typeFrame_h' :: typeFrame_t* ]

The local adder only touches the head frame, pointing to the innermost scope. This is intentional: a variable declared in a nested block should not be visible in the enclosing block.

There are three adder families:

  • $add_var_t(scope, typingContext, id, varTypeIR): adds a variable to a frame
  • $add_callableDef_t(typingContext, callableId, callableTypeDef): adds an action, parser, or control to the callable env
  • $add_typeDef_t(typingContext, typeId, typeDefIR): adds a struct, header, extern, or other type to the type def env

The latter two always write to the global layer, so they take no scope argument.

Finders

The finder functions look up a name and return its associated type. Variable lookup follows a scope chain: local stack first, then the block frame, then the global frame.

def $find_var_t(LOCAL, TC, id) = varTypeIR
  -- if typeFrame* = TC.LOCAL.FRAMES
  -- if varTypeIR = $find_maps<id, varTypeIR>(typeFrame*, id)

def $find_var_t(LOCAL, TC, id) = $find_var_t(BLOCK, TC, id)
  -- if typeFrame* = TC.LOCAL.FRAMES
  -- if eps = $find_maps<id, varTypeIR>(typeFrame*, id)

The two clauses for LOCAL form a conditional: the first succeeds if the variable is found in the local frames; the second fires when the first returns eps (not found) and delegates to BLOCK. The BLOCK finder applies the same pattern to fall through to GLOBAL if the variable is not in the block frame.

This chain means a local variable can shadow a block parameter, and a block parameter can shadow a global variable.

The TC Meta-variable

After the syntax definitions, 5.00 also declares:

var TC : typingContext

TC is shorthand for the typing context threaded through nearly every rule in the static semantics. Rather than writing typingContext in full each time, the spec declares TC once as a typed meta-variable of type typingContext. The elaborator then recognizes TC, TC', TC_1, and any other suffix variant as standing for a value of that type, wherever they appear in rule bodies or function definitions across all spec files.

Exercise

Branch: exercise/3.1

Check out the exercise branch in the spec submodule:

git -C nano-p4/spec checkout exercise/3.1

Run the following test to observe the failure:

./nano-p4spectec check nano-p4/spec -i nano-p4/include -p nano-p4/testdata/exercise/3.1.p4

The test should pass (it is a valid program), but it does not. If you get stuck, Chapter 6 walks through this exercise as a worked example. The Lvalue_ok/referenceExpression rule attempts to find the block variable pass but the call to $find_var_t fails.

;; 5.04-typing-lvalue.watsup

rule Lvalue_ok/referenceExpression:
  scope TC |- referenceExpression : typeIR
  -- if id = $id(referenceExpression)
  -- if direction typeIR = $find_var_t(scope, TC, id) ;; <- fails!
  -- if direction = OUT \/ direction = INOUT

Add the missing clause to $find_var_t in 5.00-typing-context.watsup.

When you are done, restore the original branch:

git -C nano-p4/spec checkout main

Types

This section covers how the type checker reasons about types, focusing on two concerns: well-formedness and equivalence.

The type checker needs to know two things about any type it encounters: whether the type is well-formed, and whether two types are the same. These two jobs are handled by two relations defined in 5.02-typing-type.watsup.

Type Elaboration: Type_ok

The Type_ok relation elaborates a surface-syntax type into its internal representation (typeIR).

relation Type_ok:
  typingContext |- type ~> typeIR
  hint(input %0 %1)

Read typingContext |- type ~> typeIR as: “under typingContext, the type type elaborates to the internal type typeIR.”

For base types, the rule is trivial: the surface type is already its own internal representation.

rule Type_ok/signed:   TC |- INT `< n `>    ~> INT `< n `>
rule Type_ok/unsigned: TC |- BIT `< n `>    ~> BIT `< n `>
rule Type_ok/boolType: TC |- BOOL          ~> BOOL
rule Type_ok/matchKindType: TC |- MATCH_KIND ~> MATCH_KIND

Named types require a lookup.

rule Type_ok/typeName:
  TC |- (_TID typeId) ~> typeIR
  -- if typeDefIR = $find_typeDef_t(TC, typeId)
  -- if typeIR = $typeIR_of_typeDefIR(typeDefIR)

When the checker sees a named type such as Header, it:

  1. Looks up Header in the typing context TC via $find_typeDef_t, retrieving a typeDefIR.
  2. Converts that typeDefIR to a typeIR with $typeIR_of_typeDefIR.

$typeIR_of_typeDefIR is a two-clause function defined in 5.00:

def $typeIR_of_typeDefIR(dataTypeIR)       = dataTypeIR
def $typeIR_of_typeDefIR(objectTypeDefIR)  = objectTypeDefIR

Both clauses are identity-like: a typeDefIR is either a dataTypeIR or an objectTypeDefIR, and both are subtypes of typeIR, so the conversion just changes the tag. The function exists to make the type explicit to the elaboration rule.

When a named type is resolved into its underlying type, not only do we know that it is a valid type, but the resolved information can be used somewhere else along the type checking process.

What typeIR looks like

Understanding elaboration requires knowing what the internal type universe looks like.

Base types need no further structure:

syntax baseTypeIR =
  | INT `< nat `>
  | BIT `< nat `>
  | BOOL
  | MATCH_KIND

Data types are user-defined and carry their full field list:

syntax structTypeIR = STRUCT typeId `{ fieldTypeIR* `}
syntax headerTypeIR = HEADER typeId `{ fieldTypeIR* `}

Structs and headers carry both a typeId (the name the programmer gave them) and the full field list fieldTypeIR*. The name matters for equality, as discussed in Type Equality below.

Object types represent instantiable components (parsers, controls, packages, externs, tables):

syntax parserObjectTypeIR  = PARSER  typeId `( parameterIR* `)
syntax controlObjectTypeIR = CONTROL typeId `( parameterIR* `)
syntax packageObjectTypeIR = PACKAGE typeId `( parameterIR* `)
syntax externObjectTypeIR  = EXTERN  typeId externMethodTypeDefEnv
syntax tableObjectTypeIR   = TABLE   typeId

Each carries its name and its parameter list (or method map, for externs).

Type Equality: Type_eq

Type_eq decides whether two internal types are the same.

relation Type_eq:
  typeIR ~~ typeIR
  hint(input %0 %1)

Read typeIR_a ~~ typeIR_b as: “typeIR_a and typeIR_b are equal types.”

Base types

rule Type_eq/baseTypeIR:
  baseTypeIR ~~ baseTypeIR

Two base types are equal if and only if they are syntactically identical. INT<8> is not equal to INT<16>, and BOOL is not equal to BIT<1>. Pattern matching handles this: the same variable baseTypeIR appears on both sides, so the rule only fires when the two sides are the same term.

Structs and headers

rule Type_eq/structTypeIR:
  (STRUCT typeId `{ _ `}) ~~ (STRUCT typeId `{ _ `})

rule Type_eq/headerTypeIR:
  (HEADER typeId `{ _ `}) ~~ (HEADER typeId `{ _ `})

Two struct/header types are equal when they share the same typeId. The field lists on both sides are wildcarded with _ and ignored entirely.

This is nominal equality, not structural equality. If two independent structs happen to have identical fields but different names, they are not considered equal by this relation. P4 treats struct and header types as distinct by name; the spec reflects this by comparing type names rather than structural content.

Externs

rule Type_eq/externObjectTypeIR:
  (EXTERN typeId _) ~~ (EXTERN typeId _)

Extern types are also compared by name only. As with structs, the global type environment guarantees that the same name always resolves to the same extern declaration.

Parsers, controls, and packages

rule Type_eq/parserObjectTypeIR:
  (PARSER _ `( parameterIR_a* `)) ~~ (PARSER _ `( parameterIR_b* `))
  -- (ParameterType_eq: parameterIR_a ~~ parameterIR_b)*

rule Type_eq/controlObjectTypeIR:
  (CONTROL _ `( parameterIR_a* `)) ~~ (CONTROL _ `( parameterIR_b* `))
  -- (ParameterType_eq: parameterIR_a ~~ parameterIR_b)*

rule Type_eq/packageObjectTypeIR:
  (PACKAGE _ `( parameterIR_a* `)) ~~ (PACKAGE _ `( parameterIR_b* `))
  -- (ParameterType_eq: parameterIR_a ~~ parameterIR_b)*

Parsers, controls, and packages are compared structurally: their names (the _) are ignored, and equality holds when the parameter lists are equal pairwise under ParameterType_eq.

This contrasts with how structs and headers are compared: a type name is a declaration, so two values have the same type only if they were declared under that exact name. Parsers and controls are different: a control type declaration describes an interface, and any control block that satisfies that interface is a valid implementation. The name of the implementing block is irrelevant; what matters is that its parameter list matches.

Consider:

// control type declaration (in nano_model.p4)
control filter(inout Header hdr, out bool accept);

// control block declaration (user-written)
control Filter(inout Header hdr, out bool pass) {
    apply { pass = true; }
}

// package instantiation
NanoSwitch(MyParser(), Filter()) main;

filter and Filter are two different names, yet Filter is a valid implementation of the filter interface because their parameter lists match. Structural comparison is what captures this.

Parameter equality

All three iterated premises above delegate to ParameterType_eq:

rule ParameterType_eq:
  (direction typeIR_a _) ~~ (direction typeIR_b _)
  -- Type_eq: typeIR_a ~~ typeIR_b

Two parameters are equal when they share the same direction and have equal types under Type_eq. The parameter name (the trailing _) is ignored.

Tables

rule Type_eq/tableObjectTypeIR:
  (TABLE typeId) ~~ (TABLE typeId)

Tables are compared by name only, consistent with structs and externs.

Exercise

Branch: exercise/3.2

Check out the exercise branch in the spec submodule:

git -C nano-p4/spec checkout exercise/3.2

Run the following test to observe the failure:

./nano-p4spectec check nano-p4/spec -i nano-p4/include -p nano-p4/testdata/exercise/3.2.p4

The test program declares two variables with the same struct type and tries to assign one to the other. The checker should accept this, but it rejects it.

Find the bug/missing rule in 5.02-typing-type.watsup and fix it accordingly.

When you are done, restore the original branch:

git -C nano-p4/spec checkout main

Expressions and L-values

Two spec files handle the type-checking of expressions and l-values: 5.03-typing-expression.watsup and 5.04-typing-lvalue.watsup.

The relation defined in 5.03 is Expr_ok:

relation Expr_ok:
  scope typingContext |- expression : typeIR
  hint(input %0 %1 %2)

Read scope TC |- e : T as: “under context TC at scope scope, expression e has type T.”

The scope argument threads through many rules so that variable lookup (which delegates to $find_var_t) can consult the right layers of the typing context.

Literal Expressions

The simplest rules are the literal cases. Boolean literals always have type BOOL:

rule Expr_ok/boolean:
  scope TC |- booleanLiteral : BOOL

Integer literals carry their width and signedness, and discard their values:

rule Expr_ok/integer-unsigned:
  scope TC |- nat W int : BIT `< nat `>

rule Expr_ok/integer-signed:
  scope TC |- nat S int : INT `< nat `>

As for what TC means, refer to The TC Meta-variable.

Reference Expressions

A reference expression is just a name used as a value:

rule Expr_ok/referenceExpression:
  scope TC |- name : typeIR
  -- if id = $id(name)
  -- if _ typeIR = $find_var_t(scope, TC, id)

The two premises are:

  1. Convert name to an id using $id. id is an alias for text.
  2. Look it up via $find_var_t, returning a varTypeIR pair of direction and typeIR. The direction is wildcarded with _ because reading a variable has no direction constraint.

The scope argument to $find_var_t is the same one passed into Expr_ok, so lookup follows the correct scope chain automatically.

Unary Expressions

Nano-P4 has two kinds of unary operators: boolean negation and integer operators.

rulegroup Expr_ok/unaryExpression {

  rule Expr_ok/boolean:
    scope TC |- '!' expression : BOOL
    -- Expr_ok: scope TC |- expression : BOOL

  rule Expr_ok/integer:
    scope TC |- unop expression : integerTypeIR
    -- if unop <- [ '~', '-', '+' ]
    -- Expr_ok: scope TC |- expression : integerTypeIR

}

Both rules check that the operand has the expected type and propagate the same type to the result. ! only works on booleans; ~, -, and + work on any integer type (BIT<n> or INT<n>), and the result has the same integer type.

The if unop <- [...] premise is a membership check: it confirms that the operator in question is one of the listed tokens.

Binary Expressions

Five groups of binary operators are defined, each with its own rule:

rule Expr_ok/arithmetic:
  scope TC |- expression_l binop expression_r : integerTypeIR
  -- if binop <- [ '*', '+', '-' ]
  -- Expr_ok: scope TC |- expression_l : integerTypeIR
  -- Expr_ok: scope TC |- expression_r : integerTypeIR

Arithmetic operators (*, +, -) require both operands to have the same integer type and produce that type. The variable integerTypeIR appears in all three positions, so pattern matching enforces that both operands share exactly the same type.

rule Expr_ok/comparison:
  scope TC |- expression_l binop expression_r : BOOL
  -- if binop <- [ '<=', '>=', '<', '>' ]
  -- Expr_ok: scope TC |- expression_l : integerTypeIR
  -- Expr_ok: scope TC |- expression_r : integerTypeIR

Comparison operators consume integers and produce BOOL. The same variable integerTypeIR appears in both premises, so both operands must have exactly the same integer type, just as with arithmetic operators.

rule Expr_ok/equality:
  scope TC |- expression_l binop expression_r : BOOL
  -- if binop <- [ '!=', '==' ]
  -- Expr_ok: scope TC |- expression_l : baseTypeIR
  -- Expr_ok: scope TC |- expression_r : baseTypeIR

Equality operators accept any base type (INT<n>, BIT<n>, BOOL, MATCH_KIND), not just integers, and return BOOL. The same variable baseTypeIR appears in both premises, so both operands must have exactly the same base type.

rule Expr_ok/bitwise:
  scope TC |- expression_l binop expression_r : integerTypeIR
  -- if binop <- [ '&', '^', '|' ]
  -- Expr_ok: scope TC |- expression_l : integerTypeIR
  -- Expr_ok: scope TC |- expression_r : integerTypeIR

Bitwise operators mirror arithmetic: same-type integers in, same type out.

rule Expr_ok/logical:
  scope TC |- expression_l binop expression_r : BOOL
  -- if binop <- [ '&&', '||' ]
  -- Expr_ok: scope TC |- expression_l : BOOL
  -- Expr_ok: scope TC |- expression_r : BOOL

Logical operators require both operands to be BOOL and return BOOL.

Member Access

Member access is written expr.field. The result type is determined by looking up the field name in the struct or header definition:

rule Expr_ok/struct:
  scope TC |- memberAccessBase '.' member : typeIR
  -- Expr_ok: scope TC |- memberAccessBase : typeIR_base
  -- if STRUCT _ `{ (typeIR_field id_field ';')* `} = typeIR_base
  -- if id_member = $id(member)
  -- if typeIR = $assoc_<id, typeIR>(id_member, (id_field, typeIR_field)*)

rule Expr_ok/header:
  scope TC |- memberAccessBase '.' member : typeIR
  -- Expr_ok: scope TC |- memberAccessBase : typeIR_base
  -- if HEADER _ `{ (typeIR_field id_field ';')* `} = typeIR_base
  -- if id_member = $id(member)
  -- if typeIR = $assoc_<id, typeIR>(id_member, (id_field, typeIR_field)*)

Both rules follow the same shape:

  1. Type-check the base expression and get back a struct or header type typeIR_base.
  2. Destructure typeIR_base to extract its field list (typeIR_field id_field)*.
  3. Convert the member token to an id_member.
  4. Look up id_member in the field association list with $assoc_<id, typeIR>.

The struct and header cases are separate rules because the pattern in step 2 matches STRUCT _ { ... } or HEADER _ { ... } but not both simultaneously.

Call Expressions

Nano-P4 only supports two kinds of call expressions: parser and control instantiation via constructor invocation. These appear in the package argument list (e.g., NanoSwitch(Parser(), Filter())).

rule Expr_ok/parser:
  scope TC |- (_TID typeId) `( _EMPTY `) : parserObjectTypeIR
  -- if PARSER parameterIR* = $find_callableTypeDef_t(TC, typeId)
  -- if parserObjectTypeIR = PARSER typeId `( parameterIR* `)

rule Expr_ok/control:
  scope TC |- (_TID typeId) `( _EMPTY `) : controlObjectTypeIR
  -- if CONTROL parameterIR* = $find_callableTypeDef_t(TC, typeId)
  -- if controlObjectTypeIR = CONTROL typeId `( parameterIR* `)

Both rules look up typeId in the callable type definition environment with $find_callableTypeDef_t. By the time these rules fire, the parser/control declaration must have been registered as a callable. If the callable is a parser, the result is a PARSER object type; if it is a control, the result is a CONTROL object type. Both carry the type name and the parameter list, used later when checking the enclosing package instantiation.

The argument list is always EMPTY here because parser/control blocks in Nano-P4 do not have constructor parameters, and therefore do not accept arguments during instantiation.

Parenthesized Expressions

rule Expr_ok/parenthesizedExpression:
  scope TC |- `( expression `) : typeIR
  -- Expr_ok: scope TC |- expression : typeIR

Parentheses are transparent: they add no type information and simply propagate the type of the inner expression.

L-values

An l-value names a storage location and can appear on the left-hand side of an assignment. The Lvalue_ok relation in 5.04-typing-lvalue.watsup determines whether an expression qualifies as an l-value and what type it holds:

relation Lvalue_ok:
  scope typingContext |- lvalue : typeIR
  hint(input %0 %1 %2)

Only three forms of l-values exist in Nano-P4:

syntax lvalue =
  | referenceExpression
  | lvalue '.' member
  | `( lvalue `)

Reference L-values

rule Lvalue_ok/referenceExpression:
  scope TC |- referenceExpression : typeIR
  -- if id = $id(referenceExpression)
  -- if direction typeIR = $find_var_t(scope, TC, id)
  -- if direction = OUT \/ direction = INOUT

This rule adds a constraint that Expr_ok/referenceExpression does not have: the direction must be OUT or INOUT. IN and directionless (EMPTY) variables cannot appear on the left of an assignment because they are read-only.

Member Access L-values

The rules for member access l-values are left as an exercise. Have fun!

Parenthesized L-values

rule Lvalue_ok/parenthesized:
  scope TC |- `( lvalue `) : typeIR
  -- Lvalue_ok: scope TC |- lvalue : typeIR

Like parenthesized expressions, parentheses around an l-value are transparent.

Exercise

Branch: exercise/3.3

Check out the exercise branch in the spec submodule:

git -C nano-p4/spec checkout exercise/3.3

Run the following test to observe the failure:

./nano-p4spectec check nano-p4/spec -i nano-p4/include -p nano-p4/testdata/exercise/3.3.p4

The test program assigns to a member of a struct field that is an INOUT parameter. The checker should accept this, but results in an error instead.

The Lvalue_ok/structTypeIR and Lvalue_ok/headerTypeIR rules are missing from 5.04-typing-lvalue.watsup.

Hint: Write them by analogy with Expr_ok/struct and Expr_ok/header in 5.03-typing-expression.watsup.

When you are done, restore the original branch:

git -C nano-p4/spec checkout main

Statements

5.05-typing-statement.watsup type-checks all statements in Nano-P4.

Statement_ok uses a threading pattern: each rule takes an incoming context and produces an outgoing one, though only variable declarations extend the context; all others pass it through unchanged.

relation Statement_ok:
  scope typingContext |- statement -| typingContext
  hint(input %0 %1 %2)

Read scope TC_0 |- s -| TC_1 as: “under context TC_0 at scope scope, statement s is well-typed and produces context TC_1.”

Empty Statement

rule Statement_ok/emptyStatement:
  scope TC |- emptyStatement -| TC

An empty statement is always well-typed and leaves the context unchanged.

Variable Declaration

Variable declarations are handled by a dedicated relation VarDecl_ok before being wrapped by Statement_ok:

rule VarDecl_ok:
  scope TC_0 |- type name ('=' expression) ';' -| TC_1
  -- Type_ok: TC_0 |- type ~> typeIR
  -- Expr_ok: scope TC_0 |- expression : typeIR'
  -- Type_eq: typeIR ~~ typeIR'
  -- if id = $id(name)
  -- if varTypeIR = INOUT typeIR
  -- if TC_1 = $add_var_t(scope, TC_0, id, varTypeIR)

rule Statement_ok/variableDeclaration:
  scope TC_0 |- variableDeclaration -| TC_1
  -- VarDecl_ok: scope TC_0 |- variableDeclaration -| TC_1

VarDecl_ok performs four checks and one update:

  1. Elaborate the declared type type.
  2. Type-check the initializer expression.
  3. Check that both types are equal via Type_eq.
  4. Compute id from the name token.
  5. Add the new variable to the context with $add_var_t, recording its direction as INOUT.

The direction INOUT is unconditional here: all local variables in Nano-P4 are read-write by default. This is distinct from parameters, which carry an explicit direction from their declaration.

The outgoing context TC_1 is what the rest of the block sees, so any subsequent statement can refer to the newly declared variable.

Assignment Statement

The rule for assignment statements is left as the exercise for this section.

Here is what Statement_ok/assignmentStatement should perform:

  1. Check that the left-hand side is a valid l-value
  2. Check that the right-hand side type-checks.
  3. Check that both types are equal.

Call Statement

There are three kinds of call statements: action call, extern method call, and table apply method call.

Action

rule Statement_ok/callStatement-name:
  scope TC |- referenceExpression `( argumentList `) ';' -| TC
  -- if callableId = $id(referenceExpression)
  -- if ACTION parameterIR* = $find_callableTypeDef_t(TC, callableId)
  -- ArgumentList_ok: scope TC |- argumentList : argumentIR*
  -- Call_convention_ok: parameterIR* '@' argumentIR*

An action call like myAction() looks up the callable in the context, extracts its parameter list, type-checks the arguments, and checks the calling convention.

ArgumentList_ok type-checks each argument expression in argumentList and yields argumentIR*, where argumentIR is a pair of the argument expression and its resolved typeIR. Call_convention_ok verifies that argument directions and types match the parameters. Both of these relations are covered in the next section.

Extern Method

rule Statement_ok/callStatement-member:
  scope TC |- (lvalue_base '.' member) `( argumentList `) ';' -| TC
  -- if expression_base = $expression_of_lvalue(lvalue_base)
  -- Expr_ok: scope TC |- expression_base : typeIR_base
  -- if EXTERN typeId externMethodTypeDefEnv = typeIR_base
  -- if callableId = $id(member)
  -- if VOID callableId `( parameterIR* `)
      = $find_map<callableId, externMethodTypeDefIR>(
          externMethodTypeDefEnv,
          callableId
        )
  -- ArgumentList_ok: scope TC |- argumentList : argumentIR*
  -- Call_convention_ok: parameterIR* '@' argumentIR*

A method call like pkt.extract(hdr) resolves the base expression to an extern type, looks up the method name in the extern’s method environment, and then checks arguments against the method’s parameter list.

$expression_of_lvalue converts the syntactic lvalue_base into an expression before passing it to Expr_ok, since the two are distinct syntactic sorts.

All extern methods in Nano-P4 return VOID, so there is no return type to propagate.

Table Apply Method

rule Statement_ok/callStatement-table-apply:
  scope TC |- (lvalue_base '.' APPLY) `( argumentList `) ';' -| TC
  -- if expression_base = $expression_of_lvalue(lvalue_base)
  -- Expr_ok: scope TC |- expression_base : (TABLE typeId)

A table apply call like tbl.apply() only needs to confirm that the base expression has a table type. No argument or parameter checking is needed: table apply takes no arguments in Nano-P4.

Block Statement

rule Statement_ok/blockStatement:
  scope TC_0 |- blockStatement -| TC_2
  -- if TC_1 = $enter_t(TC_0)
  -- Block_ok: TC_1 |- blockStatement
  -- if TC_2 = $exit_t(TC_1)

A block statement pushes a new scope frame with $enter_t before checking the body, then pops it with $exit_t afterward. The outgoing context TC_2 has the same shape as TC_0: any variables declared inside the block are discarded.

The body is checked by Block_ok, which flattens the statementList and threads the context through each statement in sequence:

rule Block_ok:
  TC_0 |- `{ statementList `}
  -- if statement* = $flatten_statementList(statementList)
  -- Statements_ok: TC_0 |- statement* -| TC_1

rule Statements_ok/nil:
  TC_0 |- eps -| TC_0

rule Statements_ok/cons:
  TC_0 |- statement_h :: statement_t* -| TC_2
  -- Statement_ok: LOCAL TC_0 |- statement_h  -| TC_1
  -- Statements_ok:      TC_1 |- statement_t* -| TC_2

Statements_ok threads the context left to right: each statement receives the context produced by the previous one. Notice that Statement_ok is always called with LOCAL scope inside Statements_ok/cons, because statements inside a block body live in the local scope layer.

Note that Block_ok does not call $enter_t/$exit_t itself: the frame push and pop happen in Statement_ok/blockStatement, one level up. Block_ok receives a context that already has the new frame on the stack.

Conditional Statement

rule Statement_ok/conditionalStatement:
  scope TC |- IF `( expression `) blockStatement_then
              ELSE blockStatement_else -| TC
  -- Expr_ok: scope TC |- expression : BOOL
  -- Block_ok: TC |- blockStatement_then
  -- Block_ok: TC |- blockStatement_else

An if/else statement checks that the condition has type BOOL, then checks both branches independently under the same incoming context TC. The two branches do not see each other’s declarations, and neither introduces names into the enclosing scope: each Block_ok premise calls $enter_t/$exit_t internally via Statement_ok/blockStatement.

The outgoing context is the same TC that came in.

Exercise

Branch: exercise/3.4

Check out the exercise branch in the spec submodule:

git -C nano-p4/spec checkout exercise/3.4

Run the following test to observe the failure:

./nano-p4spectec check nano-p4/spec -i nano-p4/include -p nano-p4/testdata/exercise/3.4.p4

The test program contains a valid assignment statement, but the checker rejects it.

The Statement_ok/assignmentStatement rule in 5.05-typing-statement.watsup has been omitted entirely. Write the rule from scratch.

There are three tests for this exercise: 3.4.p4, 3.4.1.p4, 3.4.2.p4. The latter two tests are negative tests, so they must be rejected by the typechecker.

./nano-p4spectec check nano-p4/spec -i nano-p4/include -p nano-p4/testdata/exercise/3.4.p4
# should pass!

./nano-p4spectec check nano-p4/spec -i nano-p4/include -p nano-p4/testdata/exercise/3.4.1.p4
# should fail!

./nano-p4spectec check nano-p4/spec -i nano-p4/include -p nano-p4/testdata/exercise/3.4.2.p4
# should fail!

When you are done, restore the original branch:

git -C nano-p4/spec checkout main

Parameters and Arguments

Three spec files cover the static semantics of function-like calls in Nano-P4: how parameter lists are elaborated, how argument expressions are typed, and how the two sides are matched at a call site: 5.06-typing-parameter.watsup, 5.07-typing-argument.watsup, and 5.13-typing-call-convention.watsup.

Parameters

Parameter_ok

A single parameter is checked by Parameter_ok:

relation Parameter_ok:
  scope typingContext |- parameter : parameterIR -| typingContext
  hint(input %0 %1 %2)

The relation takes an incoming context and produces an outgoing one: each parameter adds a new variable binding visible to subsequent parameters.

rule Parameter_ok:
  scope TC_0 |- direction type name : parameterIR -| TC_1
  -- Type_ok: TC_0 |- type ~> typeIR
  -- if nameIR = $id(name)
  -- if parameterIR = direction typeIR nameIR
  -- if varTypeIR = direction typeIR
  -- if TC_1 = $add_var_t(scope, TC_0, nameIR, varTypeIR)

The rule elaborates the declared type, packages the result into a parameterIR triple of (direction, typeIR, nameIR), and extends the context with the new variable. Unlike local variables (which are unconditionally INOUT), a parameter’s direction comes directly from its declaration and is stored as-is.

Parameters_ok and ParameterList_ok

Multiple parameters are threaded left to right, exactly like statements in Section 3.4:

rule Parameters_ok/nil:
  scope TC |- eps : eps -| TC

rule Parameters_ok/cons:
  scope TC_0 |- parameter_h :: parameter_t* : parameterIR_h :: parameterIR_t* -| TC_2
  -- Parameter_ok: scope TC_0 |- parameter_h : parameterIR_h -| TC_1
  -- Parameters_ok: scope TC_1 |- parameter_t* : parameterIR_t* -| TC_2

ParameterList_ok is a thin wrapper that flattens the left-recursive syntax before delegating to Parameters_ok, and also enforces that no two parameters share the same name:

rule ParameterList_ok:
  scope TC_0 |- parameterList : parameterIR* -| TC_1
  -- if parameter* = $flatten_parameterList(parameterList)
  -- Parameters_ok: scope TC_0 |- parameter* : parameterIR* -| TC_1
  -- if $distinct_params(parameterIR*)

$distinct_params extracts the name fields from each parameterIR and checks them for uniqueness using the standard-library predicate $distinct_.

Helper predicates

Two helper predicates guard parameter lists in declaration rules (covered in Section 3.6):

dec $is_object_typeIR(typeIR) : bool
dec $no_object_params(parameterIR*) : bool

$is_object_typeIR returns true for parser, control, and package object types. $no_object_params confirms that no parameter in a list has an object type. This enforces the Nano-P4 restriction that actions and controls may not take other programmable-block objects as parameters.

Arguments

Argument_ok

An argument is a call-site expression paired with its elaborated type:

relation Argument_ok:
  scope typingContext |- argument : argumentIR
  hint(input %0 %1 %2)
rule Argument_ok:
  scope TC |- expression : argumentIR
  -- Expr_ok: scope TC |- expression : typeIR
  -- if argumentIR = expression '#' typeIR

The rule type-checks the expression and bundles it with its type into an argumentIR pair expression '#' typeIR. The expression itself is kept because the call-convention check below needs to inspect it structurally.

ArgumentList_ok

rule ArgumentList_ok:
  scope TC |- argumentList : argumentIR*
  -- if argument* = $flatten_argumentList(argumentList)
  -- (Argument_ok: scope TC |- argument : argumentIR)*

The iteration premise (Argument_ok: ...)* checks each argument independently under the same context TC. Unlike parameters, arguments do not bind anything, so there is no threading.

Call Convention

Call_convention_ok pairs up a parameter list parameterIR* with an argument list argumentIR* and performs a pairwise check.

rule Call_convention_ok/nil:
  eps '@' eps

rule Call_convention_ok/cons:
  (parameterIR_h :: parameterIR_t*) '@' (argumentIR_h :: argumentIR_t*)
  -- Call_convention_arg_ok: parameterIR_h '@' argumentIR_h
  -- Call_convention_ok:     parameterIR_t* '@' argumentIR_t*

The structural recursion over the two lists in lockstep requires equal lengths: there is no wildcard rule for mismatched lengths, so an arity mismatch has no applicable rule and the check fails.

Each pair is checked by Call_convention_arg_ok, which has three cases based on the parameter’s direction:

rule Call_convention_arg_ok/empty:
  parameterIR '@' argumentIR
  -- if _EMPTY typeIR_param _ = parameterIR
  -- if expression_arg '#' typeIR_arg = argumentIR
  -- Type_eq: typeIR_param ~~ typeIR_arg

rule Call_convention_arg_ok/in:
  parameterIR '@' argumentIR
  -- if IN typeIR_param _ = parameterIR
  -- if expression_arg '#' typeIR_arg = argumentIR
  -- Type_eq: typeIR_param ~~ typeIR_arg

rule Call_convention_arg_ok/out-inout:
  parameterIR '@' argumentIR
  ---- ;; Left for exercise!

EMPTY and IN parameters only require a type match.

OUT and INOUT parameters additionally require the argument expression to be an l-value, enforced by $expression_is_lvalue. This rule is left for you as exercise for this section.

Exercise

Branch: exercise/3.5

Check out the exercise branch in the spec submodule:

git -C nano-p4/spec checkout exercise/3.5

Run the following test to observe the failure:

./nano-p4spectec check nano-p4/spec -i nano-p4/include -p nano-p4/testdata/exercise/3.5.p4

The test program passes a literal integer to an OUT parameter. The checker should reject this, but it does not.

The Call_convention_arg_ok/out-inout rule in 5.13-typing-call-convention.watsup has been omitted entirely. Write the rule from scratch. There are three tests for this exercise: 3.5.p4, 3.5.1.p4, 3.5.2.p4. The latter two tests are negative tests, so they must be rejected by the typechecker.

./nano-p4spectec check nano-p4/spec -i nano-p4/include -p nano-p4/testdata/exercise/3.5.p4
# should pass!

./nano-p4spectec check nano-p4/spec -i nano-p4/include -p nano-p4/testdata/exercise/3.5.1.p4
# should fail!

./nano-p4spectec check nano-p4/spec -i nano-p4/include -p nano-p4/testdata/exercise/3.5.2.p4
# should fail!

When you are done, restore the original branch:

git -C nano-p4/spec checkout main

Declarations

This section covers two spec files: 5.08-typing-declaration.watsup and 5.12-typing-extern.watsup.

Together they handle the top-level declarations that make up a Nano-P4 program:

  • instantiations
  • action declarations
  • extern declarations
  • type declarations
    • structs
    • headers
    • parser/control/package type signatures
    • match-kind declarations
  • parser declaration
  • control declaration

The section also covers the top-level entry point Program_ok, which sequences all declarations and builds the final typing context.

A recurring theme in this section is the threading pattern seen in earlier sections: most relations take an incoming context TC_0 and produce an outgoing context TC_1 after adding whatever names the declaration introduces.

Action Declaration

ActionDecl_ok

relation ActionDecl_ok:
  typingContext |- actionDeclaration -| typingContext
  hint(input %0 %1)
rule ActionDecl_ok:
  TC_0 |- actionDeclaration -| TC_1
  -- if ACTION name_action `( parameterList `) blockStatement = actionDeclaration
  -- if parameter* = $flatten_parameterList(parameterList)
  -- if (direction _ name = parameter)*
  -- if $directionless_trailing(direction*)
  -- Parameters_ok: LOCAL TC_0 |- parameter* : parameterIR* -| TC_body
  -- Block_ok: TC_body |- blockStatement
  -- if callableId = $id(name_action)
  -- if callableTypeDef = ACTION parameterIR*
  -- if TC_1 = $add_callableDef_t(TC_0, callableId, callableTypeDef)

The rule proceeds in three phases.

Structural extraction and direction check. The action is unpacked to obtain its name, parameter list, and body. The parameter directions are extracted and passed to $directionless_trailing, which enforces a P4 rule: all directionless (EMPTY) parameters must appear at the end of the parameter list.

Type-checking the body. Parameters_ok is invoked at LOCAL scope to elaborate the parameter list and produce TC_body, the context in which the action body is checked. Note that TC_0 is the outer context passed to Parameters_ok, so existing names remain visible inside the action. Block_ok then verifies the body under TC_body.

Registering the action. The action is stored in TC_0’s callable environment under callableId with type ACTION parameterIR*. The action is registered in TC_0, not TC_body: the bindings introduced by the parameters are not visible outside the action.

$directionless_trailing

The helper scans the direction list from the end to enforce the trailing rule:

def $directionless_trailing(direction*)
  = $directionless_trailing'(true, $rev_<direction>(direction*))

def $directionless_trailing'(_, eps) = true
def $directionless_trailing'(true, _EMPTY :: direction_t*)
  = $directionless_trailing'(true, direction_t*)
def $directionless_trailing'(false, _EMPTY :: direction_t*) = false
def $directionless_trailing'(_, direction_h :: direction_t*)
  = $directionless_trailing'(false, direction_t*)
  -- if direction_h =/= _EMPTY

The reversed list is walked left to right carrying a boolean flag. The flag starts as true and flips to false the moment a non-_EMPTY direction is seen. Any _EMPTY direction encountered after the flag flips signals that a directionless parameter follows a directional one, which fails the check. The reversal means “the end” of the original list is seen first.

Extern Declaration

Extern checking is split across the two spec files: ExternMethod_ok lives in 5.12-typing-extern.watsup and ExternDecl_ok lives in 5.08-typing-declaration.watsup.

ExternMethod_ok

rule ExternMethod_ok:
  TC |- functionPrototype ';' : externMethodTypeDefIR
  -- if VOID name `( parameterList `) = functionPrototype
  -- ParameterList_ok: LOCAL TC |- parameterList : parameterIR* -| TC_body
  -- if callableId = $id(name)
  -- if externMethodTypeDefIR = VOID callableId `( parameterIR* `)

Each method prototype in an extern block is individually checked by ExternMethod_ok. Nano-P4 restricts extern methods to return VOID, so the only information extracted is the method name and its elaborated parameter list. The result is an externMethodTypeDefIR value: VOID callableId `( parameterIR* `).

Note that this relation produces externMethodTypeDefIR rather than an updated typing context. The caller, ExternDecl_ok, is responsible for assembling all method types into a method environment.

ExternDecl_ok

rule ExternDecl_ok:
  TC_0 |- EXTERN name `{ externMethodPrototypeList `} -| TC_1
  -- if externMethodPrototype*
      = $flatten_externMethodPrototypeList(externMethodPrototypeList)
  -- (ExternMethod_ok : TC_0 |- externMethodPrototype : externMethodTypeDefIR)*
  -- if (VOID callableId_method `( _ `) = externMethodTypeDefIR)*
  -- if $distinct_<callableId>(callableId_method*)
  -- if externMethodTypeDefEnv = `{ (callableId_method ':' externMethodTypeDefIR)* `}
  -- if typeId = $id(name)
  -- if typeDefIR = EXTERN typeId externMethodTypeDefEnv
  -- if TC_1 = $add_typeDef_t(TC_0, typeId, typeDefIR)

The rule flattens the method prototype list, checks each prototype independently under TC_0 via an iteration premise (ExternMethod_ok: ...)*, and collects the resulting externMethodTypeDefIR*. It then enforces that all method names are distinct with $distinct_<callableId>, assembles them into a method environment externMethodTypeDefEnv, and registers the entire extern type in TC_0’s type definition environment under the extern’s name.

Type Declaration

TypeDecl_ok is a rulegroup with one rule for each kind of type declaration.

Struct and Header

rule TypeDecl_ok/structTypeDeclaration:
  TC_0 |- STRUCT name_struct `{ typeFieldList `} -| TC_1
  -- if typeField* = $flatten_typeFieldList(typeFieldList)
  -- if (type name ';' = typeField)*
  -- (Type_ok: TC_0 |- type ~> typeIR)*
  -- if (id_field = $id(name))*
  -- if $distinct_<id>(id_field*)
  -- if typeId = $id(name_struct)
  -- if fieldTypeIR* = (typeIR id_field ';')*
  -- if typeDefIR = STRUCT typeId `{ fieldTypeIR* `}
  -- if TC_1 = $add_typeDef_t(TC_0, typeId, typeDefIR)

The struct rule extracts each field, elaborates each type, checks that field names are distinct, and registers the resulting struct type definition. The header rule is identical in structure with HEADER substituted for STRUCT.

Parser, Control, and Package Type Declarations

These three rules follow the same pattern; here is the parser case:

rule TypeDecl_ok/parserTypeDeclaration:
  TC_0 |- PARSER name `( parameterList `) ';' -| TC_1
  -- ParameterList_ok: BLOCK TC_0 |- parameterList : parameterIR* -| TC_body
  -- if $no_object_params(parameterIR*)
  -- if typeId = $id(name)
  -- if typeDefIR = PARSER typeId `( parameterIR* `)
  -- if TC_1 = $add_typeDef_t(TC_0, typeId, typeDefIR)

A parser, control, or package type declaration (as opposed to a full parser or control definition) is just a signature: a name and a parameter list with no body. The parameter list is elaborated under BLOCK scope, and $no_object_params (from Section 3.5) ensures that none of the parameters have object types.

The type definition registered in TC_1 is PARSER typeId `( parameterIR* `), CONTROL typeId `( parameterIR* `), or PACKAGE typeId `( parameterIR* `) depending on the variant. These type definitions are what the type checker looks up during package instantiation.

Top-Level Declarations

Decl_ok is the dispatch relation for individual top-level declarations. Most rules simply delegate to one of the specialized relations above. A few are worth examining directly.

Instantiation

rule Decl_ok/instantiation:
  TC_0 |- (_TID typeId_target) `( argumentList `) name ';' -| TC_0
  -- if PACKAGE typeId `( parameterIR* `) = $find_typeDef_t(TC_0, typeId_target)
  -- ArgumentList_ok: GLOBAL TC_0 |- argumentList : argumentIR*
  -- Call_convention_ok: parameterIR* '@' argumentIR*
  -- if typeId_object = $id(name)
  -- if typeId_object = "main"
  -- if packageObjectTypeIR = PACKAGE typeId_object `( parameterIR* `)
  -- if varTypeIR = _EMPTY packageObjectTypeIR
  -- if TC_1 = $add_var_t(GLOBAL, TC_0, typeId_object, varTypeIR)

Instantiation connects parsers, controls, and externs into the main package. In Nano-P4, the only instantiable type at the top level is a PACKAGE type. The rule looks up the target type, checks that the arguments match the package’s parameters via Call_convention_ok, and verifies that the object name is literally "main". The resulting variable is registered in the global frame with direction _EMPTY.

Match-Kind Declaration

rule Decl_ok/matchKindDeclaration:
  TC_0 |- MATCH_KIND `{ nameList `} -| TC_1
  -- if name* = $flatten_nameList(nameList)
  -- if (id = $id(name))*
  -- if $distinct_<id>(id*)
  -- if varTypeIR* = $repeat_<varTypeIR>(_EMPTY MATCH_KIND, |id*|)
  -- if TC_1 = $add_vars_t(GLOBAL, TC_0, id*, varTypeIR*)

A match_kind declaration introduces a set of named constants of type MATCH_KIND. The rule checks for name collisions with $distinct_<id>, then creates one _EMPTY MATCH_KIND variable for each name and adds them all to the global frame using $add_vars_t.

Parser Declaration

rule Decl_ok/parserDeclaration:
  TC_0 |- parserDeclaration -| TC_2
  -- if PARSER name `( parameterList `)
      `{ parserLocalDeclarationList parserStateList `} = parserDeclaration
  -- ParameterList_ok: BLOCK TC_0 |- parameterList : parameterIR* -| TC_body
  -- ParserLocalDeclList_ok: TC_body |- parserLocalDeclarationList -| TC_1
  -- ParserStateList_ok: TC_1 |- parserStateList
  -- if callableId = $id(name)
  -- if callableTypeDef = PARSER parameterIR*
  -- if TC_2 = $add_callableDef_t(TC_0, callableId, callableTypeDef)

A full parser declaration is checked in three stages: the parameter list is elaborated to produce TC_body, the local declarations are threaded through to extend it into TC_1, and the parser states are checked under TC_1. Finally, the callable type PARSER parameterIR* is added to TC_0’s callable environment.

The parser-specific relations ParserLocalDeclList_ok and ParserStateList_ok are covered in Section 3.7.

Control Declaration

rule Decl_ok/controlDeclaration:
  TC_0 |- controlDeclaration -| TC_2
  -- if CONTROL name
      `( parameterList `)
      `{ controlLocalDeclarationList APPLY controlBody `} = controlDeclaration
  -- ParameterList_ok: BLOCK TC_0 |- parameterList : parameterIR* -| TC_body
  -- ControlLocalDeclList_ok: TC_body |- controlLocalDeclarationList -| TC_1
  -- Block_ok: TC_1 |- controlBody
  -- if callableId = $id(name)
  -- if callableTypeDef = CONTROL parameterIR*
  -- if TC_2 = $add_callableDef_t(TC_0, callableId, callableTypeDef)

A full control declaration follows a similar three-stage structure as a parser declaration: the parameter list is elaborated to produce TC_body, the control-local declarations are threaded through to extend TC_body into TC_1, and the apply block is checked under TC_1 via Block_ok. After all that, the control’s callable type CONTROL parameterIR* is added to TC_0’s callable environment.

The control-specific relation ControlLocalDeclList_ok is covered in Section 3.8.

Sequencing Declarations: Decls_ok and Program_ok

All top-level declarations are sequenced by Decls_ok:

rule Decls_ok/nil:
  TC_0 |- eps -| TC_0

rule Decls_ok/cons:
  TC_0 |- declaration_h :: declaration_t* -| TC_2
  -- Decl_ok: TC_0 |- declaration_h -| TC_1
  -- Decls_ok: TC_1 |- declaration_t* -| TC_2

This is the same threading pattern used by Statements_ok in Section 3.4 and Parameters_ok in Section 3.5.

Program_ok is the entry point for the entire program:

rule Program_ok:
  |- program -| TC'
  -- if declaration* = $flatten_program(program)
  -- if TC = $empty_typingContext
  -- Decls_ok: TC |- declaration* -| TC'

It starts from an empty typing context, flattens the top-level declaration list, and threads the declarations through Decls_ok. The resulting context TC' holds all top-level type definitions, callable types, and global variables after the program has been fully checked.

Exercise

Branch: exercise/3.6

Check out the exercise branch in the spec submodule:

git -C nano-p4/spec checkout exercise/3.6

Run the following test to observe the failure:

./nano-p4spectec check nano-p4/spec -i nano-p4/include -p nano-p4/testdata/exercise/3.6.p4

The checker will report that directionless_trailing is undefined. The entire $directionless_trailing definition has been removed from 5.08-typing-declaration.watsup. Write it from scratch. Use $rev_<direction> to reverse the list, and $directionless_trailing' as the recursive worker that carries a boolean flag.

There are four tests for this exercise: 3.6.p4 and 3.6.1.p4 are positive tests (should pass); 3.6.2.p4 and 3.6.3.p4 are negative tests (should fail).

./nano-p4spectec check nano-p4/spec \
    -i nano-p4/include \
    -p nano-p4/testdata/exercise/3.6.p4
# should pass!

./nano-p4spectec check nano-p4/spec \
    -i nano-p4/include \
    -p nano-p4/testdata/exercise/3.6.1.p4
# should pass!

./nano-p4spectec check nano-p4/spec \
    -i nano-p4/include \
    -p nano-p4/testdata/exercise/3.6.2.p4
# should fail!

./nano-p4spectec check nano-p4/spec \
    -i nano-p4/include \
    -p nano-p4/testdata/exercise/3.6.3.p4
# should fail!

When you are done, restore the original branch:

git -C nano-p4/spec checkout main

Parser Block

This section covers 5.09-typing-parser.watsup, which type-checks the body of a parser declaration.

Recall from Section 3.6 that Decl_ok/parserDeclaration decomposes a parser declaration into three parts and delegates each to a separate relation:

rule Decl_ok/parserDeclaration:
  TC_0 |- parserDeclaration -| TC_2
  -- if PARSER name `( parameterList `)
      `{ parserLocalDeclarationList parserStateList `} = parserDeclaration
  -- ParameterList_ok: BLOCK TC_0 |- parameterList : parameterIR* -| TC_body
  -- ParserLocalDeclList_ok: TC_body |- parserLocalDeclarationList -| TC_1
  -- ParserStateList_ok: TC_1 |- parserStateList
  -- ...

The parameter list is handled by ParameterList_ok (covered in Section 3.5). The remaining two relations, ParserLocalDeclList_ok and ParserStateList_ok, are defined in 5.09-typing-parser.watsup and are covered here.

Parser Local Declarations

A parser may declare local variables before its states. These declarations are handled by ParserLocalDecl_ok:

relation ParserLocalDecl_ok:
  typingContext |- parserLocalDeclaration -| typingContext
  hint(input %0 %1)

rule ParserLocalDecl_ok:
  TC_0 |- variableDeclaration -| TC_1
  -- VarDecl_ok: BLOCK TC_0 |- variableDeclaration -| TC_1

In Nano-P4, parser local declarations are restricted to variable declarations. The rule simply delegates to VarDecl_ok at BLOCK scope, which elaborates the type, checks the initializer, and extends the context with the new binding (see Section 3.4 for VarDecl_ok).

Multiple local declarations are threaded left to right by ParserLocalDecls_ok, which follows the same nil/cons pattern used throughout the spec:

rule ParserLocalDecls_ok/nil:
  TC_0 |- eps -| TC_0

rule ParserLocalDecls_ok/cons:
  TC_0 |- parserLocalDeclaration_h :: parserLocalDeclaration_t* -| TC_2
  -- ParserLocalDecl_ok: TC_0 |- parserLocalDeclaration_h -| TC_1
  -- ParserLocalDecls_ok: TC_1 |- parserLocalDeclaration_t* -| TC_2

ParserLocalDeclList_ok is a thin wrapper that flattens the left-recursive syntax before delegating to ParserLocalDecls_ok:

rule ParserLocalDeclList_ok:
  TC_0 |- parserLocalDeclarationList -| TC_1
  -- if parserLocalDeclaration*
    = $flatten_parserLocalDeclarationList(parserLocalDeclarationList)
  -- ParserLocalDecls_ok: TC_0 |- parserLocalDeclaration* -| TC_1

The context TC_1 that ParserLocalDeclList_ok produces is the context passed to ParserStateList_ok, so local variable bindings are visible inside all parser states.

Parser Transitions

Each parser state ends with a transition statement, which names the next state to enter. There are two forms: a direct name transition and a select expression.

ParserTransition_ok/name

rule ParserTransition_ok/name:
  TC nameIR_state* |- TRANSITION (name ';')
  -- if nameIR = $id(name)
  -- if nameIR <- nameIR_state*

A simple transition foo; checks that foo refers to a known parser state. The set of valid target names is passed in as nameIR_state*. The membership check nameIR <- nameIR_state* enforces that the target is a known state, including the built-in accept and reject states, which are prepended to nameIR_state* by ParserStateList_ok before any per-state check is performed (See ParserStateList_ok).

ParserTransition_ok/expression

Here’s an example of a select expression in a parser block in Nano-P4 code.

transition select(hdr.nanonet.packetType) {
    7w1 : parse_data;
    7w0 : reject;
}

The following is the corresponding rule:

rule ParserTransition_ok/expression:
  TC_0 nameIR_state* |- TRANSITION selectExpression
  -- if SELECT `( expression `) `{ selectCaseList `} = selectExpression
  -- Expr_ok: LOCAL TC_0 |- expression : typeIR
  -- if selectCase* = $flatten_selectCaseList(selectCaseList)
  -- if (expression_case ':' name_case ';' = selectCase)*
  ---- ;; expression
  -- (Expr_ok: LOCAL TC_0 |- expression_case : typeIR_case)*
  -- (Type_eq: typeIR ~~ typeIR_case)*
  ---- ;; name
  -- if (nameIR_case = $id(name_case))*
  -- if (b_contains = nameIR_case <- nameIR_state*)*
  -- if $forall_(b_contains*)

A select transition dispatches to different states based on the value of an expression. The rule performs two independent checks over the case list.

Expression check. The selector expression expression is type-checked to produce typeIR. Each case label expression_case is also type-checked, and its type typeIR_case must equal typeIR via Type_eq.

Name check. Each target name in the case list is resolved to a nameIR_case and checked against nameIR_state* via membership. The results are collected into b_contains*, and $forall_ asserts that every element is true. This ensures every case target is a known parser state.

Parser States

ParserState_ok

relation ParserState_ok:
  typingContext nameIR* |- parserState
  hint(input %0 %1 %2)

rule ParserState_ok:
  TC_0 nameIR_state* |- STATE name `{ statementList transitionStatement `}
  -- if TC_1 = $enter_t(TC_0)
  -- if statement* = $flatten_statementList(statementList)
  -- Statements_ok: TC_1 |- statement* -| TC_2
  -- ParserTransition_ok: TC_2 nameIR_state* |- transitionStatement
  -- if TC_3 = $exit_t(TC_2)

A parser state has a name, a sequence of statements, and a transition statement. The rule:

  1. Pushes a new scope frame with $enter_t, producing TC_1.
  2. Flattens and type-checks the statements inside the state under TC_1, threading the context through to produce TC_2.
  3. Type-checks the transition statement under TC_2, passing in the full set of valid state names nameIR_state*.
  4. Pops the scope frame with $exit_t.

The relation takes the set of valid state names nameIR_state* as an extra input parameter alongside the typing context. This is how ParserState_ok knows which target names are legal in transitions. The set is computed once by ParserStateList_ok and threaded down to every state.

ParserStateList_ok

rule ParserStateList_ok:
  TC |- parserStateList
  -- if parserState* = $flatten_parserStateList(parserStateList)
  -- if (STATE name `{ _ _ `} = parserState)*
  -- if (nameIR_state = $id(name))*
  ---- ;; it is illegal to explicitly define states named 'accept' and 'reject'
  -- if ~("accept" <- nameIR_state*) /\ ~("reject" <- nameIR_state*)
  -- if nameIR_state_all* = "accept"::"reject"::nameIR_state*
  -- (ParserState_ok: TC nameIR_state_all* |- parserState)*

ParserStateList_ok validates the collection of parser states as a whole and then checks each one individually. The rule proceeds as follows:

  1. The state list is flattened and each state’s name is extracted, yielding nameIR_state*.
  2. One membership check enforces a P4 structural rule for parsers:
    • ~("accept" <- nameIR_state*) /\ ~("reject" <- nameIR_state*): the names accept and reject are reserved built-in states and may not be defined explicitly.
  3. accept and reject are prepended to nameIR_state* to form nameIR_state_all*, the complete set of valid transition targets (user-defined states plus the two built-in sinks).
  4. Finally, (ParserState_ok: TC nameIR_state_all* |- parserState)* applies ParserState_ok to every state in parallel, passing the full name set so that each state’s transition can reference any other state, including accept and reject.

Exercise

Branch: exercise/3.7

Check out the exercise branch in the spec submodule:

git -C nano-p4/spec checkout exercise/3.7

One of the rules presented in this section is faulty: two premises have been removed from it in 5.09-typing-parser.watsup. The checker now accepts programs it should reject. Find the faulty rule and restore the missing premises.

There are three tests for this exercise: 3.7.p4 is a positive test (should pass); 3.7.1.p4 and 3.7.2.p4 are negative tests (should fail).

./nano-p4spectec check nano-p4/spec \
    -i nano-p4/include \
    -p nano-p4/testdata/exercise/3.7.p4
# should pass!

./nano-p4spectec check nano-p4/spec \
    -i nano-p4/include \
    -p nano-p4/testdata/exercise/3.7.1.p4
# should fail!

./nano-p4spectec check nano-p4/spec \
    -i nano-p4/include \
    -p nano-p4/testdata/exercise/3.7.2.p4
# should fail!

When you are done, restore the original branch:

git -C nano-p4/spec checkout main

Control Block

This section covers 5.10-typing-control.watsup, which type-checks the body of a control declaration.

Recall from Section 3.6 that Decl_ok/controlDeclaration decomposes a control declaration into three parts and delegates each to a separate relation:

rule Decl_ok/controlDeclaration:
  TC_0 |- controlDeclaration -| TC_2
  -- if CONTROL name
      `( parameterList `)
      `{ controlLocalDeclarationList APPLY controlBody `} = controlDeclaration
  -- ParameterList_ok: BLOCK TC_0 |- parameterList : parameterIR* -| TC_body
  -- ControlLocalDeclList_ok: TC_body |- controlLocalDeclarationList -| TC_1
  -- Block_ok: TC_1 |- controlBody
  -- if callableId = $id(name)
  -- if callableTypeDef = CONTROL parameterIR*
  -- if TC_2 = $add_callableDef_t(TC_0, callableId, callableTypeDef)

The parameter list is handled by ParameterList_ok (covered in Section 3.5), and the apply block is handled by Block_ok (covered in Section 3.4). The remaining relation, ControlLocalDeclList_ok, is defined in 5.10-typing-control.watsup and is covered here.

Control Local Declarations

A control may declare local variables and tables before its apply block. These declarations are handled by ControlLocalDecl_ok:

relation ControlLocalDecl_ok:
  typingContext |- controlLocalDeclaration -| typingContext
  hint(input %0 %1)

Unlike the parser case, which only allows variable declarations as local declarations, a control local declaration can be either a variable declaration or a table declaration. The ControlLocalDecl_ok rulegroup has one rule for each form:

rule ControlLocalDecl_ok/variableDeclaration:
  TC_0 |- variableDeclaration -| TC_1
  -- VarDecl_ok: BLOCK TC_0 |- variableDeclaration -| TC_1

rule ControlLocalDecl_ok/tableDeclaration:
  TC_0 |- tableDeclaration -| TC_1
  -- TableDecl_ok: TC_0 |- tableDeclaration -| TC_1

Both rules simply delegate to the appropriate specialized relation:

  • VarDecl_ok handles variable declarations at BLOCK scope (see Section 3.4).
  • TableDecl_ok handles table declarations (covered in Section 3.9).

Both produce an updated context TC_1 with the new binding, making the declared name visible to subsequent local declarations and the apply block.

Sequencing Local Declarations

Multiple local declarations are threaded left to right by ControlLocalDecls_ok, which follows the same nil/cons pattern used throughout the spec. The empty sequence leaves the context unchanged. A non-empty sequence checks the head declaration under the current context, obtains an updated context, and then checks the tail under that updated context, threading bindings left to right.

ControlLocalDeclList_ok is a thin wrapper that flattens the left-recursive syntax before delegating to ControlLocalDecls_ok:

rule ControlLocalDeclList_ok:
  TC_0 |- controlLocalDeclarationList -| TC_1
  -- if controlLocalDeclaration*
      = $flatten_controlLocalDeclarationList(controlLocalDeclarationList)
  -- ControlLocalDecls_ok: TC_0 |- controlLocalDeclaration* -| TC_1

The context TC_1 produced by ControlLocalDeclList_ok is the context passed to Block_ok for the apply block, so all local variable and table bindings are visible inside apply.

Exercise

Branch: exercise/3.8

Check out the exercise branch in the spec submodule:

git -C nano-p4/spec checkout exercise/3.8

Run the following test to observe the failure:

./nano-p4spectec check nano-p4/spec -i nano-p4/include -p nano-p4/testdata/exercise/3.8.p4

The test program defines a control with a table local declaration. The checker should accept it, but it does not.

The two rules for ControlLocalDecls_ok have been omitted from 5.10-typing-control.watsup. Fill them in using the prose description in Sequencing Local Declarations above. The relation signature is already declared:

relation ControlLocalDecls_ok:
  typingContext |- controlLocalDeclaration* -| typingContext
  hint(input %0 %1)

Once the rules are in place:

./nano-p4spectec check nano-p4/spec \
    -i nano-p4/include \
    -p nano-p4/testdata/exercise/3.8.p4
# should pass!

When you are done, restore the original branch:

git -C nano-p4/spec checkout main

Tables

5.11-typing-table.watsup type-checks table declarations. A table bundles a key, an action list, and optional constant entries, each with distinct checking requirements. The spec handles this by building a table context (TBLC) from the key and action list and using it to validate entries.

The overall structure is:

  • TableKey_ok checks the key expression and resolves the match kind.
  • TableAction_ok / TableActionList_ok check each action reference and build the action list.
  • TableEntry_ok / TableEntries_ok validate optional constant entries against the table context.
  • TableProperties_ok orchestrates the above and produces the table context.
  • TableDecl_ok wraps everything and registers the table name in the typing context.

Table Key

A Nano-P4 table has exactly one key field of the form `{ expression ':' name_matchKind ';' `}.

rule TableKey_ok:
  TC |- `{ expression ':' name_matchKind ';' `} : matchKey
  -- Expr_ok: BLOCK TC |- expression : typeIR
  ---- ;; check match kind
  -- if id = $id(name_matchKind)
  -- if _EMPTY MATCH_KIND = $find_var_t(GLOBAL, TC, id)
  ---- ;; create key name
  -- if nameIR = $strip_all_whitespace($print_<expression>(expression))
  -- if matchKey = typeIR ':' nameIR

The rule proceeds in three phases.

Key expression. Expr_ok type-checks the key expression at BLOCK scope, yielding typeIR. This is the type that entry keys must match.

Match kind. The match kind name is converted to an id and looked up in the global frame of the typing context. A valid match kind is declared with match_kind { exact; lpm; ternary; ... }, which registers each name as a variable of type EMPTY MATCH_KIND (see the Decl_ok/matchKindDeclaration rule in Section 3.6). The lookup $find_var_t(GLOBAL, TC, id) must return exactly EMPTY MATCH_KIND; any other result would cause the rule to fail.

Key IR. The key expression is pretty-printed and stripped of whitespace to produce nameIR, a human-readable label. The result matchKey bundles the type and label as typeIR `: nameIR.

Table Actions

$split_dataplane_parameters

Before looking at action checking, it helps to understand the helper that separates an action’s parameters into two groups:

dec $split_dataplane_parameters(parameterIR*)
  : (parameterIR*, parameterIR*)

An action may have both data-plane parameters (those with an explicit direction like in, out, inout) and control-plane parameters (those with direction EMPTY, i.e., directionless).

The function walks the parameter list and partitions them: the first element of the pair is the data-plane parameters, the second is the control-plane parameters. This distinction matters when checking table action references: the actions list in a table supplies control-plane arguments at compile time, while data-plane arguments come from table entries. This distinction is clearer in P4, since NanoSwitch does not support control-plane operations.

TableAction_ok

relation TableAction_ok:
  typingContext |- tableAction : matchAction
  hint(input %0 %1)

A table action reference in the actions property can appear either without arguments or with a parenthesized argument list. There is one rule for each form.

Without arguments: The action name is looked up in the callable environment, which must resolve to an ACTION parameterIR* entry. All parameters must be directionless (_EMPTY), enforced by the $forall_ check. The resulting matchAction records the callable id and parameter list with an empty data-plane argument sequence (eps).

rule TableAction_ok/no-argumentList:
  TC |- tableAction : matchAction
  -- if nonTypeName ';' = tableAction
  -- if callableId = $id(nonTypeName)
  -- if ACTION parameterIR* = $find_callableTypeDef_t(TC, callableId)
  -- if (direction _ _ = parameterIR)*
  -- if $forall_((direction = _EMPTY)*)
  -- if matchAction = callableId `( parameterIR* '@' eps `)

With arguments: When arguments are present, they supply the data-plane parameters. The rule first type-checks the arguments, then splits the action’s parameters into data-plane and control-plane groups. Call_convention_ok verifies the provided arguments against the data-plane parameters only. The control-plane parameters are not passed here but are instead bound by the argument list.

rule TableAction_ok/argumentList:
  TC |- tableAction : matchAction
  -- if (nonTypeName `( argumentList `)) ';' = tableAction
  -- if callableId = $id(nonTypeName)
  -- if argument* = $flatten_argumentList(argumentList)
  -- (Argument_ok: BLOCK TC |- argument : argumentIR)*
  -- if ACTION parameterIR* = $find_callableTypeDef_t(TC, callableId)
  -- if (parameterIR_data*, parameterIR_control*)
      = $split_dataplane_parameters(parameterIR*)
  -- Call_convention_ok: parameterIR_data* '@' argumentIR*
  -- if matchAction = callableId `( parameterIR* '@' argumentIR* `)

The resulting matchAction stores the full parameter list alongside the control-plane arguments. TableEntry_ok will later use this to reconcile per-entry data-plane arguments against what was registered here.

TableActionList_ok

rule TableActionList_ok:
  TC |- tableActionList : matchAction*
  -- if tableAction* = $flatten_tableActionList(tableActionList)
  -- TableActions_ok: TC |- tableAction* : matchAction*
  -- if (callableId `( _ '@' _ `) = matchAction)*
  -- if $distinct_<callableId>(callableId*)

The rule flattens the action list, checks each action via TableActions_ok (which sequences TableAction_ok over the list), extracts the callable ids, and enforces that action names are distinct. The resulting matchAction* is stored in the table context.

Table Entries

Table entries are optional constant rules that match a key value to a specific action invocation.

relation TableEntry_ok:
  typingContext tableContext |- tableEntry
  hint(input %0 %1 %2)

Unlike relations seen so far, TableEntry_ok takes two separate context arguments: the ordinary typing context TC (for expression and argument checking) and the table context TBLC (for key type and action list lookup).

Entry without arguments

rule TableEntry_ok/action-no-argumentList:
  TC TBLC |- `( expression `) ':' name ';'
  ---- ;; check key
  -- Expr_ok: BLOCK TC |- expression : typeIR
  -- if typeIR_key ':' _ = TBLC.KEY
  -- Type_eq: typeIR ~~ typeIR_key
  ---- ;; check action
  -- if callableId = $id(name)
  -- if (eps, eps) = $find_action(TBLC, callableId)

The key expression is type-checked and compared against TBLC.KEY’s type via Type_eq. The action name is resolved through $find_action, which searches the TBLC.ACTIONS list. For an argument-free entry, the action registered in the actions list must itself have no data-plane or control-plane arguments ((eps, eps)).

Entry with arguments

rule TableEntry_ok/action-argumentList:
  TC TBLC |- `( expression `) ':' (name `( argumentList `)) ';'
  ---- ;; check key
  -- Expr_ok: BLOCK TC |- expression : typeIR
  -- if typeIR_key ':' _ = TBLC.KEY
  -- Type_eq: typeIR ~~ typeIR_key
  -- if callableId = $id(name)
  -- if (parameterIR_action*, argumentIR_action*)
      = $find_action(TBLC, callableId)
  -- ArgumentList_ok: BLOCK TC |- argumentList : argumentIR_entry*
  ---- ;; check call convention for entry
  -- Call_convention_ok: parameterIR_action* '@' argumentIR_entry*
  ---- ;; check alignment with action
  -- if (parameterIR_data*, parameterIR_control*)
      = $split_dataplane_parameters(parameterIR_action*)
  -- if argumentIR_action_data* = argumentIR_action*[0 : |parameterIR_data*|]
  -- if argumentIR_entry_data*
      = argumentIR_entry*[0 : |parameterIR_data*|]
  -- if (argumentIR_action_data = argumentIR_entry_data)*

This rule applies when the entry supplies arguments to the action. After checking the key, $find_action retrieves both the registered parameter list and the control-plane arguments previously bound in the actions list. The entry’s argument list is type-checked and verified against the action’s full parameter list via Call_convention_ok.

The trailing block enforces alignment: the data-plane arguments in the entry must be syntactically identical to those already bound in the actions list. Both sides are sliced to the length of parameterIR_data* and compared element-wise.

Table Properties

TableProperties_ok orchestrates the key and action checks and produces the table context that entry checking depends on.

rule TableProperties_ok/no-entries:
  TC |- (KEY '=' tableKey)
        (ACTIONS '=' `{ tableActionList `}) : TBLC
  -- TableKey_ok: TC |- tableKey : matchKey
  -- TableActionList_ok: TC |- tableActionList : matchAction*
  -- if TBLC = { KEY matchKey, ACTIONS matchAction* }

rule TableProperties_ok/entries:
  TC |- (KEY '=' tableKey)
        (ACTIONS '=' `{ tableActionList `})
        (CONST ENTRIES '=' `{ tableEntryList `}) : TBLC
  -- TableKey_ok: TC |- tableKey : matchKey
  -- TableActionList_ok: TC |- tableActionList : matchAction*
  -- if TBLC = { KEY matchKey, ACTIONS matchAction* }
  -- if tableEntry* = $flatten_tableEntryList(tableEntryList)
  -- TableEntries_ok: TC TBLC |- tableEntry*

Both rules check the key and action list first, then assemble TBLC from the results. The entries variant additionally flattens and validates each constant entry via TableEntries_ok, passing TBLC so that entry checking can cross-reference key types and action registrations.

Table Declaration

rule TableDecl_ok:
  TC_0 |- TABLE name `{ tableProperties `} -| TC_1
  -- TableProperties_ok:
      TC_0 |- tableProperties : TBLC
  -- if typeId = $id(name)
  -- if tableObjectTypeIR = TABLE typeId
  -- if varTypeIR = _EMPTY tableObjectTypeIR
  -- if TC_1 = $add_var_t(BLOCK, TC_0, typeId, varTypeIR)

TableDecl_ok delegates all property checking to TableProperties_ok, then registers the table name as a variable of type EMPTY (TABLE typeId) at BLOCK scope. The EMPTY direction means the table is a read-only object, consistent with how externs and other non-directional values are typed. After this, the name is visible in the apply block of the enclosing control as a table object that can be invoked with .apply().

Exercise

Branch: exercise/3.9

Check out the exercise branch in the spec submodule:

git -C nano-p4/spec checkout exercise/3.9

The alignment check has been removed from TableEntry_ok/action-argumentList in 5.11-typing-table.watsup on this branch. Write a Nano-P4 program that exposes the missing check: it should pass on the exercise branch but be rejected by the full spec. You may use 3.9.p4 as your starting point.

Your program must use const entries and have at least one entry that invokes an action with arguments. Think about what the alignment block is comparing, and what a const entries entry could supply that would differ from what the actions list registered.

Check out the exercise branch and verify your program passes on the faulty spec:

git -C nano-p4/spec checkout exercise/3.9
./nano-p4spectec check nano-p4/spec -i nano-p4/include -p <your-program.p4>
# should pass!

Then restore the original branch and verify it is rejected:

git -C nano-p4/spec checkout main
./nano-p4spectec check nano-p4/spec -i nano-p4/include -p <your-program.p4>
# should fail!

Loading Phase

Static semantics verifies that a program is well-typed and produces a typingContext as its result. Dynamic semantics takes that context and evaluates the program. The loading phase sits between them: it takes the typingContext, processes each declaration to build a loadContext, and hands the result to dynamic semantics.

Why is the loading phase necessary?

In full P4, the phase between type checking and execution is called instantiation. Parsers, controls, and externs hold stateful components such as register arrays, counter arrays, and tables. Writing MyParser() or MyControl() does not just name a declaration; it allocates a live object with its own private state. Instantiation is the pass that evaluates constructor arguments, allocates those objects, and wires them together before any packet arrives.

Nano-P4 has no stateful components, so true instantiation is unnecessary. But a lighter version of the same idea is still needed: the evaluator must know which callable bodies to invoke and which specific parser and control serve as the entry point. Type checking is concerned with what something is, not what its body looks like. By the time type checking finishes, the typingContext knows that my_parser is a parser that accepts certain parameter types, but it does not retain the body of my_parser for later execution.

The loading phase fills that gap. It walks the declarations a second time, pairs each callable body with the elaborated parameter types that type checking produced, and records which parser and control were chosen as the entry point.

What does the loading phase produce?

The loadContext holds three things that dynamic semantics needs:

  • CALLABLE: a map from callable identifiers to their elaborated definitions (callableDef). Each definition pairs a callable’s name and elaborated parameter list with its body, ready for the evaluator to invoke.
  • PARSER and CONTROL: the specific parser and control declarations that were instantiated as the entry point of the NanoSwitch. Dynamic semantics starts execution from these two.

When make_evalContext is called at the start of dynamic semantics, it receives both the typingContext (for type information) and the loadContext (for callable bodies and entry points) and combines them into the evalContext used throughout evaluation.

In this chapter

Load Context

The load context is defined in 7.0-load-context.watsup. It is a single flat layer, unlike the three-layered typing context. This reflects its narrower purpose: it only needs to hold information that the evaluator will look up by name at runtime.

Elaborated Callable Definitions

Before defining the context itself, the spec introduces the intermediate representation (IR) for callables. The IR for each callable kind pairs the callable’s elaborated parameter list with its body:

syntax actionDeclarationIR =
  ACTION nameIR
    `( parameterIR* `) blockStatement

syntax parserDeclarationIR =
  PARSER nameIR
    `( parameterIR* `)
    `{ parserLocalDeclarationList parserStateList `}

syntax controlDeclarationIR =
  CONTROL nameIR
    `( parameterIR* `)
    `{ controlLocalDeclarationList APPLY controlBody `}

syntax callableDef =
  | actionDeclarationIR
  | parserDeclarationIR
  | controlDeclarationIR

The surface syntax uses parameterList, containing raw parameter names and type annotations. The IR replaces this with parameterIR*, a list of direction-annotated internal types produced by elaboration. The loading phase is responsible for attaching the correct parameterIR* from the typingContext to the callable body from the source declaration.

The Load Context

syntax globalLoadLayer =
  { CALLABLE_TYPE callableTypeDefEnv,
    CALLABLE callableDefEnv,
    PARSER parserDeclarationIR?,
    CONTROL controlDeclarationIR? }

syntax loadContext = globalLoadLayer

var LC : loadContext

The four fields serve two purposes:

  • CALLABLE_TYPE is copied directly from TC.GLOBAL.CALLABLE at initialization. It gives the loading rules read-only access to the elaborated parameter types from the typing phase, so each callable body can be paired with the correct parameterIR*.
  • CALLABLE starts empty and is populated as declarations are processed. By the end of loading it contains the full callableDef for every action, parser, and control in the program.
  • PARSER and CONTROL start as eps (absent) and are set when the instantiation declaration is encountered. They identify the specific parser and control that serve as the NanoSwitch entry point.

Initialization

dec $make_loadContext(typingContext) : loadContext
def $make_loadContext(TC) = LC
  -- if LC
      = {
          CALLABLE_TYPE TC.GLOBAL.CALLABLE,
          CALLABLE $empty_callableDefEnv,
          PARSER eps,
          CONTROL eps }

$make_loadContext seeds the context from the typingContext that typing produced. The callable type information is carried over; everything else starts empty and is filled in as declarations are loaded.

Helpers

The spec defines three helper functions for working with the load context.

$find_callableDef_l looks up a callable body by its identifier:

dec $find_callableDef_l(loadContext, callableId) : callableDef
def $find_callableDef_l(LC, callableId) = callableDef
  -- if callableDef
      = $find_map<callableId, callableDef>(LC.CALLABLE, callableId)

$add_callableDef_l inserts a new callable body. It first checks that the identifier is not already present, preventing duplicate definitions:

dec $add_callableDef_l(loadContext, callableId, callableDef)
  : loadContext

def $add_callableDef_l(LC, callableId, callableDef)
  = LC'
  -- if callableDefEnv = LC.CALLABLE
  -- if ~$in_set<callableId>(
        $dom_map<callableId, callableDef>(callableDefEnv),
        callableId
      )
  -- if callableDefEnv'
      = $add_map<callableId, callableDef>(
          callableDefEnv,
          callableId,
          callableDef
        )
  -- if LC' = LC[ .CALLABLE = callableDefEnv' ]

$find_callableTypeDef_l looks up a callable’s elaborated type from the CALLABLE_TYPE field. Loading rules call this to retrieve the parameterIR* that was computed during type checking:

dec $find_callableTypeDef_l(loadContext, callableId) : callableTypeDef
def $find_callableTypeDef_l(LC, callableId) = callableTypeDef
  -- if callableTypeDef
      = $find_map<callableId, callableTypeDef>(LC.CALLABLE_TYPE, callableId)

Loading Declarations

The loading rules are defined in 7.1-load-declaration.watsup. The central relation is Decl_load, which processes a single declaration and returns an updated loadContext.

relation Decl_load:
  loadContext |- declaration -| loadContext
  hint(input %0 %1)

Most declaration kinds either pass through unchanged or build a callableDef and register it. The interesting cases also retrieve elaborated parameter types from CALLABLE_TYPE.

Passthrough Rules

Type declarations, extern declarations, and match-kind declarations do not contribute anything to the load context. Each was fully handled during type checking. Their loading rules are one-liners that pass the context through unchanged:

rule Decl_load/typeDeclaration:
  LC |- typeDeclaration -| LC

rule Decl_load/externDeclaration:
  LC |- externDeclaration -| LC

rule Decl_load/matchKindDeclaration:
  LC |- MATCH_KIND `{ nameList `} -| LC

Action, Parser, and Control Declarations

All three rules follow the same pattern: extract the callable’s name from the source declaration, look up the elaborated parameterIR* that type checking stored in CALLABLE_TYPE, pair it with the body to form the IR, and register the result.

Here is the action rule:

rule Decl_load/actionDeclaration:
  LC_0 |- actionDeclaration -| LC_1
  -- if ACTION name_action `( _ `) blockStatement = actionDeclaration
  -- if callableId = $id(name_action)
  -- if ACTION parameterIR* = $find_callableTypeDef_l(LC_0, callableId)
  -- if actionDeclarationIR
      = ACTION callableId `( parameterIR* `) blockStatement
  -- if LC_1 = $add_callableDef_l(LC_0, callableId, actionDeclarationIR)

The parameter list in the source declaration is matched with _, discarding the raw surface parameters. The elaborated parameterIR* is fetched from CALLABLE_TYPE instead. This is why CALLABLE_TYPE exists in the load context: it gives loading rules read access to what type checking already computed, without re-running elaboration.

The parser and control rules are identical in structure, substituting PARSER and CONTROL for ACTION and including the body fields specific to each:

rule Decl_load/parserDeclaration:
  LC_0 |- parserDeclaration -| LC_1
  -- if PARSER name
    `( parameterList `)
    `{ parserLocalDeclarationList parserStateList `} = parserDeclaration
  -- if callableId = $id(name)
  -- if PARSER parameterIR* = $find_callableTypeDef_l(LC_0, callableId)
  -- if parserDeclarationIR
      = PARSER callableId
          `( parameterIR* `)
          `{ parserLocalDeclarationList parserStateList `}
  -- if LC_1 = $add_callableDef_l(LC_0, callableId, parserDeclarationIR)

rule Decl_load/controlDeclaration:
  LC_0 |- controlDeclaration -| LC_1
  -- if CONTROL name
    `( parameterList `)
    `{ controlLocalDeclarationList APPLY controlBody `} = controlDeclaration
  -- if callableId = $id(name)
  -- if CONTROL parameterIR* = $find_callableTypeDef_l(LC_0, callableId)
  -- if controlDeclarationIR
      = CONTROL callableId
          `( parameterIR* `)
          `{ controlLocalDeclarationList APPLY controlBody `}
  -- if LC_1 = $add_callableDef_l(LC_0, callableId, controlDeclarationIR)

Instantiation

The instantiation rule is different from the others. Its job is not to build a callableDef but to record which parser and control serve as the NanoSwitch entry point.

rule Decl_load/instantiation:
  LC_0 |- (_TID typeId_target) `( argumentList `) name ';' -| LC_1
  -- if argument* = $flatten_argumentList(argumentList)
  -- if (_TID callableId_parser) `( _ `) = argument*[0]
  -- if parserDeclarationIR
      = $find_callableDef_l(LC_0, callableId_parser)
  -- if (_TID callableId_control) `( _ `) = argument*[1]
  -- if controlDeclarationIR
      = $find_callableDef_l(LC_0, callableId_control)
  -- if LC_1
      = LC_0[ .PARSER = parserDeclarationIR ][ .CONTROL = controlDeclarationIR ]

The NanoSwitch(Parser(), Filter()) main; instantiation declaration passes the parser and control as its first two arguments. The rule extracts their names from argument*[0] and argument*[1], looks up each callableDef from the CALLABLE map, and writes the results into LC.PARSER and LC.CONTROL. The parser and control declarations must already be in CALLABLE when this rule fires, so declaration order matters.

Sequencing and Entry Point

Decls_load sequences declarations using the same nil/cons threading pattern seen throughout the spec:

rule Decls_load/nil:
  LC_0 |- eps -| LC_0

rule Decls_load/cons:
  LC_0 |- declaration_h :: declaration_t* -| LC_2
  -- Decl_load: LC_0 |- declaration_h -| LC_1
  -- Decls_load: LC_1 |- declaration_t* -| LC_2

Program_load is the top-level entry point. It initializes a fresh loadContext from the typingContext produced by type checking, then threads all declarations through Decls_load:

relation Program_load:
  typingContext |- program -| loadContext
  hint(input %0 %1)

rule Program_load:
  TC |- program -| LC'
  -- if declaration* = $flatten_program(program)
  -- if LC = $make_loadContext(TC)
  -- Decls_load: LC |- declaration* -| LC'

The resulting LC' is passed to $make_evalContext, which combines it with TC to build the evalContext used throughout execution.

Dynamic Semantics

Dynamic semantics defines what it means for a Nano-P4 program to run. Where static semantics asks “is this program well-typed?”, dynamic semantics asks “what does this program compute?” It specifies how values are produced from expressions, how statements update variable bindings, how packets flow through parsers and controls, and how callable bodies are invoked and their results returned.

In this chapter, we read through the dynamic semantics specification of Nano-P4 piece by piece. The spec is already written; we take it apart and understand what it says and why. As with Chapter 3, each section ends with a curated exercise where you debug or extend a faulty version of the spec.

The NanoSwitch Pipeline

Before diving into individual evaluation rules, it helps to see the big picture. 9-nano-switch.watsup defines the top-level driver that ties everything together. For each incoming packet it does three things:

  1. Setup (NanoSwitch_setup): clears the per-packet global frame and initializes packet_in, hdr, and accept to their default values.
  2. Parse (NanoSwitch_parse): invokes the loaded parser with packet_in and hdr as arguments. If the parser transitions to reject, the packet is dropped immediately.
  3. Filter (NanoSwitch_filter): invokes the loaded control with hdr and accept as arguments. After the control returns, the global accept flag is read to decide whether to forward or drop the packet.
rule NanoSwitch_drive/filter:
  EC |- objectState_packet : forwardingDecision -| EC_2
  -- NanoSwitch_setup:  EC   |- objectState_packet -| EC_0
  -- NanoSwitch_parse:  EC_0 |- parserDeclarationIR : ACCEPT -| EC_1
  -- NanoSwitch_filter: EC_1 |- controlDeclarationIR -| EC_2
  -- if forwardingDecision = $nanoswitch_forwarding(EC_2)

9-nano-switch.watsup is not covered in detail here, since it technically covers architecture implementation, but every relation it calls (Parser_apply, Control_apply, $nanoswitch_forwarding) is explained in the sections that follow.

In this chapter

  • Evaluation Context: the data structures that hold runtime state as the interpreter walks the program, mirroring the typing context from Chapter 3
  • Values: the runtime values that expressions reduce to
  • Expressions and L-values: how every expression form reduces to a single value, and how assignable locations are read and written
  • Statements: how statements update the evaluation context, including variable declarations, assignments, calls, nested blocks, and conditionals
  • Call & Convention: the copy-in and copy-out mechanism that binds arguments to parameters at call sites and propagates out results back to the caller
  • Parser Block: how parser states are entered, how packet extraction and transitions work, and how accept or reject is reached
  • Control Block: how control local declarations are evaluated and how the apply block executes
  • Tables: how a table key is evaluated, how an entry is matched, and how the selected action is invoked

Evaluation Context

Before writing a single evaluation rule, we need somewhere to store what the interpreter knows at runtime: which type names exist, which callables have been loaded, and which variables are currently in scope along with their runtime values. That storage is the evaluation context, defined in 8.00-eval-context.watsup.

If you have read Section 3.1, the structure here will look familiar. The evaluation context mirrors the three-layer design of the typing context, but maps names to values rather than types.

The Three Layers

syntax evalContext =
  { GLOBAL globalEvalLayer,
    BLOCK  blockEvalLayer,
    LOCAL  localEvalLayer }

Global layer holds information visible everywhere: the type definition environment, the callable definitions loaded by the loading phase, a global frame, the loaded parser declaration, and the loaded control declaration.

syntax globalEvalLayer =
  { TYPE     typeDefEnv,
    CALLABLE callableDefEnv,
    FRAME    frame,
    PARSER   parserDeclarationIR,
    CONTROL  controlDeclarationIR }

The PARSER and CONTROL fields are new compared to the typing context. At runtime the interpreter needs the full bodies of the top-level parser and control, not merely their type signatures, so the evaluation context carries the complete IR forms.

Block layer holds runtime values for the parameters and block-level declarations of the current parser or control block.

syntax blockEvalLayer =
  { FRAME frame }

Local layer holds variables declared inside a block body, as a stack of frames.

syntax localEvalLayer =
  { FRAMES frame* }

A frame is a map from nameIR to value:

syntax frame = map<nameIR, value>

Unlike the typing context, which stores a (direction, type) pair per name, a frame here stores the actual runtime value. Direction is irrelevant at evaluation time; what matters is the current contents of each variable.

Constructing the Initial Context

$make_evalContext builds the evaluation context from the typing context and load context produced by the loading phase:

def $make_evalContext(TC, LC) = EC
  -- if parserDeclarationIR = LC.PARSER
  -- if controlDeclarationIR = LC.CONTROL
  -- if globalEvalLayer
      = { TYPE TC.GLOBAL.TYPE,
          CALLABLE LC.CALLABLE,
          FRAME $empty_frame,
          PARSER parserDeclarationIR,
          CONTROL controlDeclarationIR }
  -- if blockEvalLayer = { FRAME $empty_frame }
  -- if localEvalLayer = { FRAMES ([ $empty_frame ]) }
  -- if EC
      = { GLOBAL globalEvalLayer,
          BLOCK  blockEvalLayer,
          LOCAL  localEvalLayer }

The type definitions come from the typing context (TC.GLOBAL.TYPE), while the callable definitions and loaded declarations come from the load context (LC). All three frames start empty; the interpreter fills them as it evaluates variable declarations and calls.

Context Inheritance

When the interpreter enters a new callable, it does not start with a blank slate. The global layer should still be accessible, but the block and local layers must be reset so that the new callable’s parameters and locals do not collide with the caller’s. $inherit_e handles this reset:

def $inherit_e(GLOBAL, EC)
  = EC[ .BLOCK = blockEvalLayer ][ .LOCAL = localEvalLayer ]
  -- if blockEvalLayer  = { FRAME $empty_frame }
  -- if localEvalLayer  = { FRAMES ([ $empty_frame ]) }

def $inherit_e(BLOCK, EC)
  = EC[ .LOCAL = localEvalLayer ]
  -- if localEvalLayer  = { FRAMES ([ $empty_frame ]) }

def $inherit_e(LOCAL, EC) = EC

The three clauses form a staircase: inheriting at GLOBAL scope resets both the block frame and the local stack; inheriting at BLOCK scope resets only the local stack; inheriting at LOCAL scope leaves everything untouched.

There is no counterpart to $inherit_e in the static semantics. Type checking walks each top-level declaration under the outer context and never invokes a callable body from an arbitrary caller, so it never needs to reset the block and local layers the way the interpreter does at a call site.

Helper Functions

The rest of 8.00-eval-context.watsup defines helper functions that closely mirror those in 5.00-typing-context.watsup from Section 3.1, so they are not covered in full here. Briefly:

  • Finders ($find_var_e, $find_callableDef_e, $find_typeDef_e): look up a name by walking the same local-then-block-then-global scope chain as the typing context finders.
  • Adders ($add_var_e): insert a new binding into the appropriate layer, refusing to overwrite an existing name.
  • Frame entry and exit ($enter_e, $exit_e): push and pop a fresh frame on the local stack when entering and leaving a block statement, the runtime counterpart to $enter_t / $exit_t.

One helper with no direct analog in the typing context is the updater ($update_var_e). The type checker never changes a variable’s type after declaration, but the interpreter must overwrite values on every assignment. The local updater walks the frame stack recursively until it finds the frame that owns the name:

def $update_var_e(LOCAL, EC, nameIR, value)
  = EC_pop'[ .LOCAL.FRAMES = frame_h :: EC_pop'.LOCAL.FRAMES ]
  -- if frame_h :: frame_t* = EC.LOCAL.FRAMES
  -- if ~$in_set<nameIR>($dom_map<nameIR, value>(frame_h), nameIR)
  -- if EC_pop  = EC[ .LOCAL.FRAMES = frame_t* ]
  -- if EC_pop' = $update_var_e(LOCAL, EC_pop, nameIR, value)

This clause fires when the name is not in the head frame: it pops the head, recurses on the tail, then pushes the head back on top of the updated context. Without it, assigning to a variable declared in an outer scope from inside a nested block would fail.

The EC Meta-variable

var EC : evalContext

EC is the typed meta-variable for the evaluation context, analogous to TC in the static semantics. Any identifier beginning with EC (EC', EC_0, EC_pop, etc.) is recognized by the elaborator as a value of type evalContext. You will see it threaded through almost every evaluation relation in the chapters that follow.

Exercise

Branch: exercise/5.1

Check out the exercise branch in the spec submodule:

git -C nano-p4/spec checkout exercise/5.1

Run the following test to observe the failure:

./nano-p4spectec eval nano-p4/spec -i nano-p4/include -p nano-p4/testdata/exercise/5.1.p4 -stf nano-p4/testdata/exercise/5.1.stf

The test should succeed, but the interpreter gets stuck. If you get stuck, Chapter 6 covers the debugging techniques that apply here. In 8.00-eval-context.watsup, the LOCAL updater clause that handles the case where a name is not in the head frame has been removed. Without it, assigning to a variable declared in an outer scope from inside a nested block fails silently.

Add the missing clause back to $update_var_e(LOCAL, ...) in 8.00-eval-context.watsup.

When you are done, restore the original branch:

git -C nano-p4/spec checkout main

Values

The value domain, defined in 3.0-value.watsup, is used throughout dynamic semantics rules. The overall structure mirrors typeIR from Section 3.2: base values correspond to base types, data values to struct and header types, and object values to object types. Each category is described below.

Base Values

syntax integerValue = integerLiteral
syntax boolValue = _B bool
syntax matchKindValue = MATCH_KIND '.' nameIR

syntax baseValue =
  | integerValue
  | boolValue
  | matchKindValue

Integer values reuse the surface-syntax integerLiteral, which carries both a bit-width and a raw integer payload: 8 W 42 is the unsigned 8-bit value 42, and 8 S 255 is the signed 8-bit value -1. For signed integers, the payload is always a non-negative bit pattern in the range [0, 2^w); the two’s-complement interpretation is applied by the arithmetic operations when needed.

Bool values wrap a boolean with the _B tag.

Match-kind values are written MATCH_KIND . nameIR, where nameIR is the member name, such as exact or lpm.

Data Values

syntax fieldValue = value nameIR ';'

syntax structValue = STRUCT typeId `{ fieldValue* `}
syntax headerValue = HEADER typeId `{ fieldValue* `}

syntax dataValue =
  | structValue
  | headerValue

Data values are the runtime counterparts of structTypeIR and headerTypeIR. Each carries its type name (typeId) and a list of field values (fieldValue*), where each field value pairs a runtime value with the field’s name.

The structure is symmetric with the type representation. A structTypeIR holds fieldTypeIR* (type-name pairs), and a structValue holds fieldValue* (value-name pairs). This parallel makes field-level operations straightforward: to read a field, scan the fieldValue* list for the matching nameIR; to write a field, reconstruct the list with the updated entry.

Nano-P4 treats structs and headers identically at the value level. The distinction matters only for extern operations (such as packet extraction) and validity tracking, which are not part of the core evaluation rules.

Object Values

syntax packetValue = PACKET typeId objectState

syntax tableValue = TABLE nameIR tableProperties

syntax objectValue =
  | packetValue
  | tableValue

Object values represent runtime state for the two object kinds that appear inside a Nano-P4 program’s evaluation.

Packet values carry an objectState, whose type is declared extern syntax. This means the spec gives the type a name but leaves its concrete representation to the toolchain implementation. The spec only ever passes objectState values through extern operations (See Section 5.5: Call & Convention - Extern Method Call); it never inspects or constructs one directly. The typeId identifies the packet type name.

Table values carry the table’s nameIR and its tableProperties (the key list and action list as loaded from the program). A tableValue is created when a control’s local table declaration is evaluated and stored in the block frame. When tbl.apply() is called, this value is retrieved and used to drive the lookup, as we will see in Section 5.8.

Default Values

When a variable is declared without an initializer, the interpreter must produce a well-typed initial value. The $default function maps a typeIR to the canonical zero value for that type:

dec $default(typeIR) : value

def $default(BIT `< w `>) = w W 0
def $default(INT `< w `>) = w S 0
def $default(BOOL) = _B false
def $default(STRUCT typeId `{ fieldTypeIR* `})
  = STRUCT typeId `{ fieldValue* `}
  -- if (typeIR id ';' = fieldTypeIR)*
  -- if (value = $default(typeIR))*
  -- if (fieldValue = value id ';')*
def $default(HEADER typeId `{ fieldTypeIR* `})
  = HEADER typeId `{ fieldValue* `}
  -- if (typeIR id ';' = fieldTypeIR)*
  -- if (value = $default(typeIR))*
  -- if (fieldValue = value id ';')*

Unsigned integers default to w W 0, signed integers to w S 0, and booleans to _B false. For structs and headers, $default recurses field by field: it unpacks each fieldTypeIR into a (typeIR, id) pair, computes the default value for that type, and reassembles the result as a fieldValue. The list comprehension syntax (... = ...)* zips the three lists in lockstep.

Note that $default has no clause for object types. Tables and packets are never declared with uninitialized values; they are always constructed explicitly during loading or local declaration evaluation.

Expressions and L-values

8.03-eval-expression.watsup and 8.04-eval-lvalue.watsup define the Expr_eval, Lvalue_eval, and Lvalue_write relations.

relation Expr_eval:
  scope evalContext |- expression : value
  hint(input %0 %1 %2)

Read scope EC |- e : v as: “under context EC at scope scope, expression e evaluates to value v.”

The structure mirrors Expr_ok from Section 3.3 almost rule-for-rule with one key difference: where Expr_ok produces a typeIR, Expr_eval produces a value.

Literals

Boolean and integer literals evaluate to themselves:

rule Expr_eval/true:
  scope EC |- TRUE : _B true

rule Expr_eval/false:
  scope EC |- FALSE : _B false

rule Expr_eval/integerLiteral:
  scope EC |- integerLiteral : integerLiteral

Integer literals are already values in the syntax (w W i or w S i), so the rule is a no-op: the literal on the right of |- is identical to the one on the left.

Reference Expressions

rule Expr_eval/referenceExpression:
  scope EC |- name : value
  -- if nameIR = $id(name)
  -- if value = $find_var_e(scope, EC, nameIR)

The rule converts name to nameIR and looks up the current value in the evaluation context. Compare with Expr_ok/referenceExpression, which calls $find_var_t to get a type instead.

Unary and Binary Expressions

rule Expr_eval/unaryExpression:
  scope EC |- unop expression : value'
  -- Expr_eval: scope EC |- expression : value
  -- if value' = $un_op(unop, value)

rule Expr_eval/binaryExpression:
  scope EC |- expression_l binop expression_r : value
  -- Expr_eval: scope EC |- expression_l : value_l
  -- Expr_eval: scope EC |- expression_r : value_r
  -- if value = $bin_op(binop, value_l, value_r)

Both rules delegate the actual computation to helpers defined in 3.1-operations.watsup: $un_op for unary operators and $bin_op for binary operators. These helpers are defined exhaustively for every operator and value type combination. For example, unsigned addition converts both operands to raw integers, adds them, and wraps the result back into a fixed-width bitstring:

def $bin_op('+', w W i_l, w W i_r) = w W i'
  -- if i_l' = $bitstr_to_int(w, i_l)
  -- if i_r' = $bitstr_to_int(w, i_r)
  -- if i'   = $int_to_bitstr(w, $(i_l' + i_r'))

The static semantics already ensures that both operands have the same type, so $bin_op never has to handle mismatched widths at runtime.

Member Access

rule Expr_eval/struct:
  scope EC |- memberAccessBase '.' member : value_member
  -- Expr_eval: scope EC |- memberAccessBase : value_base
  -- if STRUCT typeId `{ fieldValue* `} = value_base
  -- if (value_field nameIR_field ';' = fieldValue)*
  -- if nameIR = $id(member)
  -- if value_member
      = $assoc_<nameIR, value>(nameIR, (nameIR_field, value_field)*)

rule Expr_eval/header:
  scope EC |- memberAccessBase '.' member : value_member
  -- Expr_eval: scope EC |- memberAccessBase : value_base
  -- if HEADER typeId `{ fieldValue* `} = value_base
  -- if (value_field nameIR_field ';' = fieldValue)*
  -- if nameIR = $id(member)
  -- if value_member
      = $assoc_<nameIR, value>(nameIR, (nameIR_field, value_field)*)

Where Expr_ok/struct destructures the type STRUCT _ `{ (typeIR id ';')* `} to find the field’s type, Expr_eval/struct destructures the value STRUCT typeId `{ fieldValue* `} to find the field’s current value. The $assoc_ call performs a linear scan of the (nameIR_field, value_field)* pairs to return the value associated with nameIR.

Parenthesized Expressions

rule Expr_eval/parenthesizedExpression:
  scope EC |- `( expression `) : value
  -- Expr_eval: scope EC |- expression : value

Parentheses are transparent at runtime, just as they are during type checking.

Reading L-values: Lvalue_eval

relation Lvalue_eval:
  scope evalContext |- lvalue : value
  hint(input %0 %1 %2)

An l-value is a location that can appear on the left of an assignment. Reading one is straightforward: convert it to the equivalent expression and delegate to Expr_eval:

rule Lvalue_eval:
  scope EC |- lvalue : value
  -- if expression = $expression_of_lvalue(lvalue)
  -- Expr_eval:
      scope EC |- expression : value

$expression_of_lvalue is a syntactic helper that turns a lvalue into an expression of the same shape. Every l-value form has a corresponding expression form, so the conversion is always total.

Writing L-values: Lvalue_write

Writing is more involved: the interpreter finds the variable that owns the location, updates its stored value, and threads the updated context forward.

relation Lvalue_write:
  scope evalContext |- lvalue := value -| evalContext
  hint(input %0 %1 %2 %3)

Simple Variable

rule Lvalue_write/referenceExpression:
  scope EC_0 |- referenceExpression := value -| EC_1
  -- if nameIR = $id(referenceExpression)
  -- if EC_1 = $update_var_e(scope, EC_0, nameIR, value)

A bare name resolves to a nameIR and calls $update_var_e, which walks the frame stack to find the frame that owns nameIR and overwrites the binding there. The frame walk was covered in Section 5.1.

Member Access

Both rules for struct and header member access follow the same read-modify-write pattern: read the current value of the base l-value, update the relevant field inside it, reconstruct the whole-aggregate value, and write it back recursively. The recursion handles nested member access naturally. These rules are left as the exercise for this section.

$update_fieldValue is a small helper that scans the field list and replaces the value associated with the given nameIR:

dec $update_fieldValue(fieldValue*, nameIR, value) : fieldValue*

def $update_fieldValue(eps, nameIR, value) = eps
def $update_fieldValue(
    (value_field_h nameIR_field_h ';') :: fieldValue_t*,
    nameIR, value)
  = (value nameIR ';') :: fieldValue_t*
  -- if nameIR_field_h = nameIR
def $update_fieldValue(
    (value_field_h nameIR_field_h ';') :: fieldValue_t*,
    nameIR, value)
  = (value_field_h nameIR_field_h ';') ::
      $update_fieldValue(fieldValue_t*, nameIR, value)
  -- if nameIR_field_h =/= nameIR

The first clause handles an empty list. The second replaces the head when its name matches. The third skips the head and recurses when names differ.

Parenthesized L-value

rule Lvalue_write/parenthesized:
  scope EC_0 |- (`( lvalue `)) := value -| EC_1
  -- Lvalue_write:
      scope EC_0 |- lvalue := value -| EC_1

Parentheses are transparent for writing, just as they are for reading.

Exercise

Branch: exercise/5.3

Check out the exercise branch in the spec submodule:

git -C nano-p4/spec checkout exercise/5.3

Run the following test to observe the failure:

./nano-p4spectec eval nano-p4/spec -i nano-p4/include -p nano-p4/testdata/exercise/5.3.p4 -stf nano-p4/testdata/exercise/5.3.stf

The test program writes to a nested member l-value and reads the result back. It should pass, but the interpreter gets stuck. Two rules have been omitted from 8.04-eval-lvalue.watsup. Write them by analogy with each other and with Lvalue_write/referenceExpression.

When you are done, restore the original branch:

git -C nano-p4/spec checkout main

Statements

8.05-eval-statement.watsup defines the Statement_eval relation:

relation Statement_eval:
  scope evalContext |- statement -| evalContext
  hint(input %0 %1 %2)

Read scope EC_0 |- s -| EC_1 as: “under context EC_0 at scope scope, statement s executes and produces context EC_1.”

This threading pattern mirrors Statement_ok from Section 3.4, but the context now carries values instead of types.

Empty Statement

rule Statement_eval/emptyStatement:
  scope EC |- emptyStatement -| EC

An empty statement is a no-op: it returns the incoming context unchanged.

Variable Declaration

rule VarDecl_eval:
  scope EC_0 |- type name ('=' expression) ';' -| EC_1
  -- Expr_eval:
      scope EC_0 |- expression : value
  -- if nameIR = $id(name)
  -- if EC_1 = $add_var_e(scope, EC_0, nameIR, value)

rule Statement_eval/variableDeclaration:
  scope EC_0 |- variableDeclaration -| EC_1
  -- VarDecl_eval: scope EC_0 |- variableDeclaration -| EC_1

VarDecl_eval evaluates the initializer expression to a value, converts the declared name to nameIR, and inserts the binding into the context with $add_var_e. The declared type annotation is not used at evaluation time; the static semantics already guarantees that the value has the right type.

Assignment Statement

rule Statement_eval/assignmentStatement:
  scope EC_0 |- lvalue '=' expression ';' -| EC_1
  -- Expr_eval:
      scope EC_0 |- expression : value
  -- Lvalue_write:
      scope EC_0 |- lvalue := value -| EC_1

Assignment evaluates the right-hand side to a value, then delegates the write to Lvalue_write. The updated context EC_1 carries the new binding. Note that both Expr_eval and Lvalue_write receive EC_0: the right-hand side is evaluated in the context before the write, which is the usual sequential evaluation order.

Call Statement

rule Statement_eval/callStatement:
  scope EC_0 |- lvalue `( argumentList `) ';' -| EC_1
  -- Callee_eval:
      scope EC_0 |- lvalue : callee
  -- if argument* = $flatten_argumentList(argumentList)
  -- Call_eval:
      scope EC_0 |- callee `( argument* `) -| EC_1

A call statement first resolves the callee name to a callee value via Callee_eval, flattens the argument list, then hands off to Call_eval. Call_eval handles binding arguments to parameters, executing the body, and propagating any out or inout writes back to the caller’s context. Both relations are covered in detail in Section 5.5: Call Convention.

Block Statement

rule Statement_eval/blockStatement:
  scope EC_0 |- blockStatement -| EC_3
  -- if EC_1 = $enter_e(EC_0)
  -- Block_eval:
      EC_1 |- blockStatement -| EC_2
  -- if EC_3 = $exit_e(EC_2)

A block pushes a fresh frame onto the local stack with $enter_e, evaluates the body under the extended context, then pops the frame with $exit_e. Any variables declared inside the block are discarded when the block exits. This is the runtime counterpart of Statement_ok/blockStatement from Section 3.4.

The helper relations that thread statements through a block are:

rule Block_eval:
  EC_0 |- `{ statementList `} -| EC_1
  -- if statement* = $flatten_statementList(statementList)
  -- Statements_eval:
      LOCAL EC_0 |- statement* -| EC_1

rule Statements_eval/nil:
  scope EC |- eps -| EC

rule Statements_eval/cons:
  scope EC_0 |- statement_h :: statement_t* -| EC_2
  -- Statement_eval:
      scope EC_0 |- statement_h -| EC_1
  -- Statements_eval:
      scope EC_1 |- statement_t* -| EC_2

Block_eval flattens the statement list and passes it to Statements_eval at LOCAL scope. Statements_eval threads the context left to right through each statement in sequence, just like Statements_ok in the type checker.

Conditional Statement

The two rules for conditionalStatement form a rulegroup. One rule fires when the condition evaluates to _B true and runs the then-branch block; the other fires when the condition evaluates to _B false and runs the else-branch block. Neither branch receives the other’s local declarations: each Block_eval call manages its own frame push and pop.

Compare with Statement_ok/conditionalStatement from Section 3.4, where both branches are checked under the same incoming context. At evaluation time, exactly one branch executes depending on the runtime value of the condition.

Exercise

Branch: exercise/5.4

Check out the exercise branch in the spec submodule:

git -C nano-p4/spec checkout exercise/5.4

Run the following test to observe the failure:

./nano-p4spectec eval nano-p4/spec -i nano-p4/include -p nano-p4/testdata/exercise/5.4.p4 -stf nano-p4/testdata/exercise/5.4.stf

The test should pass, but it fails. The rules for Statement_eval/conditionalStatement are missing. Write them yourself, and verify that 5.4.p4 passes.

When you are done, restore the original branch:

git -C nano-p4/spec checkout main

Call & Convention

8.13-eval-call.watsup and 8.14-eval-convention.watsup together define how call sites are resolved and how arguments are passed to and from callees.

Recall from Section 5.4 that a call statement resolves to:

rule Statement_eval/callStatement:
  scope EC_0 |- lvalue `( argumentList `) ';' -| EC_1
  -- Callee_eval:
      scope EC_0 |- lvalue : callee
  -- if argument* = $flatten_argumentList(argumentList)
  -- Call_eval:
      scope EC_0 |- callee `( argument* `) -| EC_1

Callee_eval resolves the call target to a typed callee value; Call_eval then takes that value and executes the call. This section explains both.

Callee Resolution

Before a call can execute, the spec needs to know what kind of thing is being called. A call target is either an actionCallee, an externMethodCallee, or a tableApplyMethodCallee. The callee type captures that information:

syntax actionCallee =
  ACTION callableId `( parameterIR* `) blockStatement

syntax externMethodCallee =
  EXTERN_METHOD lvalue '.' callableId `( parameterIR* `)

syntax tableApplyMethodCallee =
  TABLE nameIR '.' APPLY `{ tableProperties `}

syntax callee =
  | actionCallee
  | externMethodCallee
  | tableApplyMethodCallee

Each variant bundles everything Call_eval will need to execute the call.

Callee_eval inspects the call target and produces the appropriate variant:

relation Callee_eval:
  scope evalContext |- lvalue : callee
  hint(input %0 %1 %2)

Action callee

rule Callee_eval/action:
  scope EC |- referenceExpression : actionCallee
  -- if callableId = $id(referenceExpression)
  -- if actionDeclarationIR = $find_callableDef_e(EC, callableId)
  -- if ACTION _ `( parameterIR* `) blockStatement = actionDeclarationIR
  -- if actionCallee = ACTION callableId `( parameterIR* `) blockStatement

A bare name is resolved to a callableId and looked up in the callable environment with $find_callableDef_e. The result must be an ACTION declaration, from which the parameter list and body block are extracted and bundled into an actionCallee.

Extern method callee

rule Callee_eval/extern:
  scope EC |- lvalue_base '.' member : externMethodCallee
  -- Lvalue_eval:
      scope EC |- lvalue_base : packetValue
  -- if PACKET typeId objectState = packetValue
  -- if callableId = $id(member)
  -- if EXTERN _ externMethodTypeDefEnv = $find_typeDef_e(EC, typeId)
  -- if VOID _ `( parameterIR* `)
      = $find_map<callableId, externMethodTypeDefIR>(
          externMethodTypeDefEnv,
          callableId
        )
  -- if externMethodCallee
      = EXTERN_METHOD lvalue_base '.' callableId `( parameterIR* `)

In Nano-P4, the only extern type is packet_in and the only object of that type is pkt, so in practice base is always pkt and method is always extract. Therefore, Lvalue_eval evaluates base to a packetValue of the form PACKET typeId objectState, and typeId is used to look up the extern type definition (containing the method signatures) in the context.

The method is then found in the extern type’s method environment by callableId. The result is an externMethodCallee carrying the base l-value (needed to write the updated packet state back after the call) and the method’s parameter list.

Table apply callee

rule Callee_eval/table:
  scope EC |- lvalue_base '.' member : tableApplyMethodCallee
  -- Lvalue_eval:
      scope EC |- lvalue_base : tableValue
  -- if TABLE nameIR tableProperties = tableValue
  -- if tableApplyMethodCallee = TABLE nameIR '.' APPLY `{ tableProperties `}

A member access tbl.apply resolves tbl to a tableValue (stored in the block frame by ControlLocalDecl_eval/tableDeclaration, see Section 5.7) and packages its properties into a tableApplyMethodCallee. The member name itself is not checked here: the static type checker already verified that the only method on a table value is apply.

Call Execution

Call_eval dispatches on the callee variant produced by Callee_eval.

relation Call_eval:
  scope evalContext |- callee `( argument* `) -| evalContext
  hint(input %0 %1 %2 %3)

Action call

rule Call_eval/actionCallee:
  scope EC_0 |- actionCallee `( argument* `) -| EC_1
  -- if ACTION callableId `( parameterIR* `) blockStatement = actionCallee
  -- if EC_callee_0 = $inherit_e(GLOBAL, EC_0)
  -- Copy_in:
      scope EC_0 parameterIR*
        '@' LOCAL EC_callee_0 argument*
      ~> EC_callee_1 lvalue?*
  -- Block_eval:
      EC_callee_1 |- blockStatement -| EC_callee_2
  -- Copy_out:
      scope EC_0 parameterIR*
        '@' LOCAL EC_callee_2 lvalue?*
      ~> EC_1

The caller’s scope and context EC_0 are used for both argument evaluation and the eventual write-back. $inherit_e(GLOBAL, EC_0) produces a fresh callee context that shares the global frame of the caller but starts with empty block and local frames. Copy_in binds each argument to the matching parameter in the callee context and records which caller l-values need to be updated after the call. Block_eval runs the action body. Copy_out writes the final values of out and inout parameters back to the caller.

Extern method call

rule Call_eval/externMethodCallee:
  scope EC_0 |- externMethodCallee `( argument* `) -| EC_2
    -- if EXTERN_METHOD lvalue_extern '.' callableId `( parameterIR* `)
        = externMethodCallee
    -- Lvalue_eval:
        scope EC_0 |- lvalue_extern : value_extern
    -- if EC_callee_0 = $inherit_e(GLOBAL, EC_0)
    -- Copy_in:
        scope EC_0 parameterIR*
          '@' LOCAL EC_callee_0 argument*
        ~> EC_callee_1 lvalue?*
    -- if (_ _ nameIR = parameterIR)*
    -- ExternMethodCall_eval:
        EC_callee_1 |- value_extern '.' callableId `( nameIR* `)
                    : value_extern' -| EC_callee_2
    -- Copy_out:
        scope EC_0 parameterIR*
          '@' LOCAL EC_callee_2 lvalue?*
        ~> EC_1
    -- Lvalue_write:
        scope EC_1 |- lvalue_extern := value_extern' -| EC_2

Extern method calls follow the same Copy_in / body / Copy_out shape as action calls. The key differences are:

  • The extern object’s current value is read with Lvalue_eval before the call.
  • The body is dispatched through ExternMethodCall_eval, an extern relation whose implementation is outside the spec (it handles built-in operations like pkt.extract). It receives the current extern value and the bound parameter names, and returns an updated extern value value_extern'.
  • After Copy_out writes back any directional parameters, Lvalue_write stores the updated extern value back to lvalue_extern, so the caller’s view of the extern object reflects whatever the method did to it.

Table apply call

rule Call_eval/tableApplyMethodCallee:
  scope EC_0 |- tableApplyMethodCallee `( argument* `) -| EC_1
    -- if TABLE typeId '.' APPLY `{ tableProperties `} = tableApplyMethodCallee
    -- if EC_callee_0 = $inherit_e(BLOCK, EC_0)
    -- Table_eval:
        EC_callee_0 |- tableProperties -| EC_callee_1
    -- if EC_1 = EC_0[ .GLOBAL = EC_callee_1.GLOBAL ]
                     [ .BLOCK = EC_callee_1.BLOCK ]

Table apply does not use Copy_in / Copy_out because a table invocation has no declared parameter list. Instead, $inherit_e(BLOCK, EC_0) creates a callee context that shares both the global and block frames of the caller (so the table can read hdr and write to pass), with only the local frame reset. After Table_eval runs, the caller’s global and block layers are updated from the callee context so that any writes made during table execution are visible.

Copy-in

P4’s parameter passing is not simple value-passing. Each parameter has a direction that controls the behavior of both what the callee receives at entry and what the caller gets back at exit. Copy-in and copy-out implement this two-phase handoff.

At the call site, copy-in initializes each callee parameter from the corresponding argument. For in and directionless parameters, this is a straightforward value copy. For out parameters the argument is not read. Instead, the callee starts with a default value regardless of what the caller had. For inout the current value is copied in just like in, but the argument location is also recorded so that copy-out can write back to it.

Copy_in processes the parameter and argument lists in lock-step, delegating each pair to Copy_in_arg. It follows the standard nil/cons pattern.

The per-argument rule Copy_in_arg has three cases depending on the parameter direction:

rulegroup Copy_in_arg {

  rule Copy_in_arg/directionless-in:
      scope_caller EC_caller (direction _ nameIR)
      '@' scope_callee EC_callee argument
    ~> EC_callee' eps
    -- if direction = _EMPTY \/ direction = IN
    -- Expr_eval:
        scope_caller EC_caller |- argument : value
    -- if EC_callee' = $add_var_e(scope_callee, EC_callee, nameIR, value)

  rule Copy_in_arg/out:
      scope_caller EC_caller (OUT typeIR nameIR)
      '@' scope_callee EC_callee argument
    ~> EC_callee' lvalue
    -- if value = $default(typeIR)
    -- if EC_callee' = $add_var_e(scope_callee, EC_callee, nameIR, value)
    -- if lvalue = $lvalue_of_expression(argument)

  rule Copy_in_arg/inout:
    scope_caller EC_caller (INOUT typeIR nameIR)
      '@' scope_callee EC_callee argument
    ~> EC_callee' lvalue
    -- Expr_eval:
        scope_caller EC_caller |- argument : value
    -- if EC_callee' = $add_var_e(scope_callee, EC_callee, nameIR, value)
    -- if lvalue = $lvalue_of_expression(argument)

}

_EMPTY / IN: The argument is evaluated in the caller’s context and copied into the callee. No write-back is needed, so eps is returned as the l-value placeholder.

OUT: The callee parameter is initialized to the default value for its type ($default(typeIR)), which is zero for bit-vectors and integers, false for booleans, and zeroed structs and headers. The argument expression is not evaluated, since its value is irrelevant. Instead, $lvalue_of_expression converts the argument expression into an l-value that Copy_out will later write the final value to.

INOUT: Combines both: the current caller value is copied in, and the argument expression is also remembered as an l-value for write-back.

Copy-out

Once the callee body has finished executing, copy-out propagates results back to the caller. For in and directionless parameters, nothing is written back and the copy is discarded. For out and inout parameters the final value of the callee’s local variable is read and written to the l-value that copy-in recorded from the argument expression.

Copy_out mirrors Copy_in, processing pairs with Copy_out_arg:

rulegroup Copy_out_arg {

  rule Copy_out_arg/directionless-in:
    scope_caller EC_caller (direction _ _)
      '@' scope_callee EC_callee eps
    ~> EC_caller
    -- if direction = IN \/ direction = _EMPTY

  ;; Copy_out_arg/out-inout is left as the exercise for this section.

}

_EMPTY / IN: The l-value placeholder is eps, so nothing is written back. The caller’s context is returned unchanged.

OUT / INOUT: The final value of the parameter is read from the callee context with $find_var_e, then written to the caller l-value that was recorded during Copy_in. This is how an action’s writes to an out or inout parameter propagate back to the variable the caller passed in. You will write this rule in the exercise below.

Exercise

Branch: exercise/5.5

Check out the exercise branch in the spec submodule:

git -C nano-p4/spec checkout exercise/5.5

Run the following test to observe the failure:

./nano-p4spectec eval nano-p4/spec -i nano-p4/include -p nano-p4/testdata/exercise/5.5.p4 -stf nano-p4/testdata/exercise/5.5.stf

The test should pass, but it fails. The Copy_out_arg/out-inout rule is missing from 8.14-eval-convention.watsup. It handles the write-back of out and inout parameters to the caller. Write it using the prose description in Copy-out above, and verify that the test passes.

When you are done, restore the original branch:

git -C nano-p4/spec checkout main

Parser Block

8.09-eval-parser.watsup defines the evaluation semantics for parser blocks.

The entrypoint for parser execution is Parser_apply, which is called by NanoSwitch_parse, a relation for architecture simulation:

rule NanoSwitch_parse:
  EC_0 |- parserDeclarationIR : transitionResult -| EC_1
  -- if argument* = [ _ID "packet_in", _ID "hdr" ]
  -- Parser_apply:
      EC_0 argument* |- parserDeclarationIR : transitionResult -| EC_1

NanoSwitch_parse constructs the argument list (packet_in and hdr) and hands off to Parser_apply, which runs the full parser state machine. The transitionResult it returns determines whether the packet proceeds to the control block or is dropped. Parser_apply is defined at the bottom of this section; the pieces it relies on are covered first.

Parser Local Declarations

Before the parser state machine runs, a parser may declare local variables. These are handled by ParserLocalDecl_eval:

rule ParserLocalDecl_eval:
  EC_0 |- variableDeclaration -| EC_1
  -- VarDecl_eval:
      BLOCK EC_0 |- variableDeclaration -| EC_1

In Nano-P4, the only form a parser local declaration can take is a variable declaration. The rule delegates to VarDecl_eval at BLOCK scope, mirroring ParserLocalDecl_ok from Section 3.7. The resulting binding is visible inside all parser states.

Multiple local declarations are sequenced by ParserLocalDecls_eval using the same nil/cons pattern seen throughout the spec, and ParserLocalDeclList_eval wraps the flattening step before delegating to it.

Parser Transitions

Each parser state ends with a transition statement naming the next state to enter.

parser Parser(packet_in pkt, out Header hdr) {
    state start {
        pkt.extract(hdr.nanonet);
        transition accept;
    }
}

The ParserTransition_eval relation reduces a transition statement to a transitionResult, which is one of ACCEPT, REJECT, or STATE nameIR.

relation ParserTransition_eval:
  evalContext |- transitionStatement : transitionResult
  hint(input %0 %1)

There are four rules:

rulegroup ParserTransition_eval {

  rule ParserTransition_eval/accept:
    EC |- TRANSITION (name ';') : ACCEPT
    -- if $id(name) = "accept"

  rule ParserTransition_eval/reject:
    EC |- TRANSITION (name ';') : REJECT
    -- if $id(name) = "reject"

  rule ParserTransition_eval/state:
    EC |- TRANSITION (name ';') : (STATE nameIR)
    -- if nameIR = $id(name)
    -- if nameIR =/= "accept" /\ nameIR =/= "reject"

  rule ParserTransition_eval/selectExpression:
    EC |- TRANSITION selectExpression : transitionResult
    -- ParserSelect_eval:
        EC |- selectExpression : transitionResult

}

The first three rules handle a plain transition name;. The name is resolved to a nameIR and compared against the reserved strings "accept" and "reject". If it matches either built-in sink, the result is ACCEPT or REJECT respectively. Otherwise it becomes STATE nameIR, signaling that execution should continue in the named user-defined state.

The fourth rule delegates a select expression to ParserSelect_eval, covered next.

Select Expressions

A select transition dispatches to different states based on the runtime value of an expression:

transition select(hdr.nanonet.packetType) {
    7w1 : parse_data;
    7w2 : reject;
}

There are two rules:

rulegroup ParserSelect_eval {

  rule ParserSelect_eval/match:
    EC |- selectExpression : transitionResult
    -- if SELECT `( expression `) `{ selectCaseList `} = selectExpression
    -- Expr_eval: LOCAL EC |- expression : value
    -- if selectCase* = $flatten_selectCaseList(selectCaseList)
    -- if (expression_case ':' name_case ';' = selectCase)*
    -- (Expr_eval: LOCAL EC |- expression_case : value_case)*
    -- if name_match = $match_case_value(value, (name_case, value_case)*)
    -- ParserTransition_eval:
        EC |- TRANSITION (name_match ';') : transitionResult

  rule ParserSelect_eval/no-match:
    EC |- selectExpression : REJECT
    -- if SELECT `( expression `) `{ selectCaseList `} = selectExpression
    -- Expr_eval: LOCAL EC |- expression : value
    -- if selectCase* = $flatten_selectCaseList(selectCaseList)
    -- if (expression_case ':' name_case ';' = selectCase)*
    -- (Expr_eval: LOCAL EC |- expression_case : value_case)*
    -- if eps = $match_case_value(value, (name_case, value_case)*)

}

Both rules evaluate the selector expression to value and evaluate all case label expressions to value_case*. The helper $match_case_value scans (name_case, value_case)* left to right and returns the name of the first case whose value matches value, or eps if no case matches.

ParserSelect_eval/match fires when a match is found: it delegates the matched name back to ParserTransition_eval, which then produces the final transitionResult (either ACCEPT, REJECT, or STATE nameIR).

ParserSelect_eval/no-match fires when $match_case_value returns eps. The result is unconditionally REJECT, reflecting the P4 specification rule that an unmatched select drops the packet.

Compare with ParserTransition_ok/expression from Section 3.7: where the static rule type-checks each case label and validates each target name, the dynamic rule evaluates case labels to values and uses $match_case_value to pick a branch at runtime.

Parser State Evaluation

ParserState_eval executes a single named state:

relation ParserState_eval:
  evalContext |- parserState : transitionResult -| evalContext
  hint(input %0 %1)

rule ParserState_eval:
  EC_0 |- parserState : transitionResult -| EC_1
  -- if STATE name `{ statementList transitionStatement `} = parserState
  -- if statement* = $flatten_statementList(statementList)
  -- Statements_eval:
      LOCAL EC_0 |- statement* -| EC_1
  -- ParserTransition_eval:
      EC_1 |- transitionStatement : transitionResult

The rule runs the statements inside the state at LOCAL scope, threading the context from EC_0 to EC_1, then evaluates the trailing transition statement under EC_1 to produce a transitionResult. The relation carries EC_1 out because statements inside the state may write to out or inout parameters visible in the caller’s context (the packet extraction call pkt.extract modifies hdr, for example).

Parser State Transitions

ParserState_eval produces a single-step result. To follow a chain of state transitions until the parser reaches ACCEPT or REJECT, the spec uses ParserState_trans:

rulegroup ParserState_trans {

  rule ParserState_trans/accept:
    EC_0 parserState* |- nameIR : ACCEPT -| EC_1
    -- if parserState_found = $find_parserState(parserState*, nameIR)
    -- ParserState_eval:
        EC_0 |- parserState_found : ACCEPT -| EC_1

  rule ParserState_trans/reject:
    EC_0 parserState* |- nameIR : REJECT -| EC_1
    -- if parserState_found = $find_parserState(parserState*, nameIR)
    -- ParserState_eval:
        EC_0 |- parserState_found : REJECT -| EC_1

  ;; ParserState_trans/state is left as the exercise for this section.

}

$find_parserState locates the state with the given name in the list. The two rules shown are base cases: if ParserState_eval immediately returns ACCEPT or REJECT, the recursion stops. There is a third, recursive rule that handles the case where a state transitions to another named state, which you will write in the exercise below. The full state list parserState* is passed through unchanged so that any state can transition to any other.

Parser Apply

Parser_apply ties everything together:

rule Parser_apply:
  EC_0 argument* |- parserDeclarationIR : transitionResult -| EC_1
  -- if PARSER nameIR
      `( parameterIR* `)
      `{ parserLocalDeclarationList parserStateList `} = parserDeclarationIR
  -- if EC_callee_0 = $inherit_e(GLOBAL, EC_0)
  -- Copy_in:
      GLOBAL EC_0 parameterIR*
        '@' BLOCK EC_callee_0 argument*
      ~> EC_callee_1 lvalue?*
  -- ParserLocalDeclList_eval:
      EC_callee_1 |- parserLocalDeclarationList -| EC_callee_2
  -- if parserState* = $flatten_parserStateList(parserStateList)
  -- ParserState_trans:
      EC_callee_2 parserState* |- "start" : transitionResult -| EC_callee_3
  -- Copy_out:
      GLOBAL EC_0 parameterIR*
        '@' BLOCK EC_callee_3 lvalue?*
      ~> EC_1

This follows the standard call-convention shape covered in detail in Section 5.5:

  1. $inherit_e(GLOBAL, EC_0) creates a fresh callee context that shares the global layer of the caller but starts with an empty block frame.
  2. Copy_in binds the call-site arguments to the parser’s parameters and records which caller l-values correspond to out and inout parameters (covered in Section 5.5).
  3. The parser’s local declarations are evaluated in sequence, extending EC_callee_1 to EC_callee_2.
  4. ParserState_trans runs the state machine starting from "start", threading the context through each state until ACCEPT or REJECT is reached.
  5. Copy_out propagates any out and inout results back to the caller’s context EC_0, producing the final EC_1 (also covered in Section 5.5).

The transitionResult (ACCEPT or REJECT) is returned to the caller (NanoSwitch_parse) to decide whether to continue to the control block or drop the packet.

Exercise

Branch: exercise/5.6

Check out the exercise branch in the spec submodule:

git -C nano-p4/spec checkout exercise/5.6

Run the following test to observe the failure:

./nano-p4spectec eval nano-p4/spec -i nano-p4/include -p nano-p4/testdata/exercise/5.6.p4 -stf nano-p4/testdata/exercise/5.6.stf

The test should pass, but it fails. The third rule of ParserState_trans is missing from 8.09-eval-parser.watsup. Write it and add it back so the test passes.

When you are done, restore the original branch:

git -C nano-p4/spec checkout main

Control Block

8.10-eval-control.watsup defines the evaluation semantics for control blocks.

The entrypoint for control execution is Control_apply, which is called by NanoSwitch_filter, a relation for architecture simulation:

rule NanoSwitch_filter:
  EC_0 |- controlDeclarationIR -| EC_1
  -- if argument* = [ _ID "hdr", _ID "accept" ]
  -- Control_apply:
      EC_0 argument* |- controlDeclarationIR -| EC_1

NanoSwitch_filter constructs the argument list (hdr and accept) and hands off to Control_apply, which runs the control’s local declarations and apply block. Control_apply is defined at the bottom of this section; the pieces it relies on are covered first.

Control Local Declarations

A control block may declare local variables and tables before its apply block. These are handled by ControlLocalDecl_eval:

relation ControlLocalDecl_eval:
  evalContext |- controlLocalDeclaration -| evalContext
  hint(input %0 %1)

Unlike the parser case from Section 5.6, which only allows variable declarations as local declarations, a control local declaration can be either a variable declaration or a table declaration. There is one rule for each form.

ControlLocalDecl_eval/variableDeclaration delegates to VarDecl_eval at BLOCK scope, just as the parser does for its local variables:

rule ControlLocalDecl_eval/variableDeclaration:
  EC_0 |- variableDeclaration -| EC_1
  -- VarDecl_eval:
      BLOCK EC_0 |- variableDeclaration -| EC_1

ControlLocalDecl_eval/tableDeclaration does not evaluate any expressions. Instead, it converts the declared name to a nameIR and packages the table declaration into a tableValue of the form TABLE nameIR tableProperties. That value is inserted into the context with $add_var_e at BLOCK scope, making the table accessible by name within the apply block. You will write this rule in the exercise below.

Sequencing Local Declarations

Multiple local declarations are threaded left to right by ControlLocalDecls_eval, following the same nil/cons pattern used throughout the spec:

rulegroup ControlLocalDecls_eval {

  rule ControlLocalDecls_eval/nil:
    EC |- eps -| EC

  rule ControlLocalDecls_eval/cons:
    EC_0 |- controlLocalDeclaration_h :: controlLocalDeclaration_t* -| EC_2
    -- ControlLocalDecl_eval:
        EC_0 |- controlLocalDeclaration_h -| EC_1
    -- ControlLocalDecls_eval:
        EC_1 |- controlLocalDeclaration_t* -| EC_2

}

ControlLocalDeclList_eval is a thin wrapper that flattens the left-recursive syntax before delegating to ControlLocalDecls_eval:

rule ControlLocalDeclList_eval:
  EC_0 |- controlLocalDeclarationList -| EC_1
  -- if controlLocalDeclaration*
      = $flatten_controlLocalDeclarationList(controlLocalDeclarationList)
  -- ControlLocalDecls_eval:
      EC_0 |- controlLocalDeclaration* -| EC_1

The context produced by ControlLocalDeclList_eval is passed directly to Block_eval for the apply block, so all local variable and table bindings are visible inside apply.

Control Apply

Control_apply ties everything together:

rule Control_apply:
  EC_0 argument* |- controlDeclarationIR -| EC_1
  -- if CONTROL nameIR
      `( parameterIR* `)
      `{ controlLocalDeclarationList APPLY controlBody `} = controlDeclarationIR
  -- if EC_callee_0 = $inherit_e(GLOBAL, EC_0)
  -- Copy_in:
      GLOBAL EC_0 parameterIR*
        '@' BLOCK EC_callee_0 argument*
      ~> EC_callee_1 lvalue?*
  -- ControlLocalDeclList_eval:
      EC_callee_1 |- controlLocalDeclarationList -| EC_callee_2
  -- Block_eval:
      EC_callee_2 |- controlBody -| EC_callee_3
  -- Copy_out:
      GLOBAL EC_0 parameterIR*
        '@' BLOCK EC_callee_3 lvalue?*
      ~> EC_1

The structure mirrors Parser_apply from Section 5.6 and follows the standard call-convention shape (covered in detail in Section 5.5):

  1. $inherit_e(GLOBAL, EC_0) creates a fresh callee context that shares the global layer of the caller but starts with an empty block frame.
  2. Copy_in binds the call-site arguments (hdr and accept) to the control’s parameters and records which caller l-values correspond to out and inout parameters.
  3. The control’s local declarations are evaluated in sequence, extending EC_callee_1 to EC_callee_2. Variable bindings and table values become accessible inside apply.
  4. Block_eval executes the apply block under EC_callee_2, threading the context through each statement in turn and producing EC_callee_3.
  5. Copy_out propagates any out and inout results back to the caller’s context EC_0, producing the final EC_1.

Unlike Parser_apply, Control_apply produces no explicit result value. The control communicates its outcome entirely through the out parameter accept: Filter sets accept = true to pass the packet or accept = false to drop it.

Exercise

Branch: exercise/5.7

Check out the exercise branch in the spec submodule:

git -C nano-p4/spec checkout exercise/5.7

Run the following test to observe the failure:

./nano-p4spectec eval nano-p4/spec -i nano-p4/include -p nano-p4/testdata/exercise/5.7.p4 -stf nano-p4/testdata/exercise/5.7.stf

The test should pass, but it fails. The ControlLocalDecl_eval/tableDeclaration rule is missing from 8.10-eval-control.watsup. Write it using the prose description in Control Local Declarations above, and verify that the test passes.

When you are done, restore the original branch:

git -C nano-p4/spec checkout main

Tables

8.11-eval-table.watsup defines the evaluation semantics for table lookups.

Recall from Section 5.7 that when a control’s local declaration list is evaluated, each table declaration is packaged into a tableValue of the form TABLE nameIR tableProperties and stored in the block frame. When the apply block later calls tbl.apply(), Callee_eval/table retrieves that value and constructs a tableApplyMethodCallee:

rule Callee_eval/table:
  scope EC |- lvalue_base '.' member : tableApplyMethodCallee
  -- Lvalue_eval:
      scope EC |- lvalue_base : tableValue
  -- if TABLE nameIR tableProperties = tableValue
  -- if tableApplyMethodCallee = TABLE nameIR '.' APPLY `{ tableProperties `}

Call_eval/tableApplyMethodCallee then drives Table_eval with those properties:

rule Call_eval/tableApplyMethodCallee:
  scope EC_0 |- tableApplyMethodCallee `( argument* `) -| EC_1
    -- if TABLE typeId '.' APPLY `{ tableProperties `} = tableApplyMethodCallee
    -- if EC_callee_0 = $inherit_e(BLOCK, EC_0)
    -- Table_eval:
        EC_callee_0 |- tableProperties -| EC_callee_1
    -- if EC_1 = EC_0[ .GLOBAL = EC_callee_1.GLOBAL ]
                     [ .BLOCK = EC_callee_1.BLOCK ]

Table_eval runs in a callee context inherited from the BLOCK frame of the caller (unlike parser and control invocations, which inherit from GLOBAL), and its effects are merged back into both the global and block layers of the caller’s context.

The relations that make up Table_eval are covered below.

Table Key

TableKey_eval evaluates the table’s key expression to a concrete value:

relation TableKey_eval:
  evalContext |- tableKey : value
  hint(input %0 %1)

rule TableKey_eval:
  EC |- `{ expression ':' name ';' `} : value
  -- Expr_eval:
      LOCAL EC |- expression : value

The match kind name (name) is ignored at evaluation time; it was only needed during type checking to validate that the match kind is declared (the only key match policy in Nano-P4 is exact). Only the expression matters here, and it is evaluated at LOCAL scope to produce the runtime key value.

Compare with TableKey_ok from Section 3.9: the static rule type-checks the expression and validates the match kind name; the dynamic rule simply evaluates the expression.

Table Entry Matching

TableMatch_eval takes the key value and the list of constant entries, finds the first matching entry, and executes its action:

relation TableMatch_eval:
  evalContext value |- tableEntry* -| evalContext
  hint(input %0 %1 %2)

Matching relies on the helper $match_entry_value, which walks the list of (tableActionReference, value) pairs and returns the tableActionReference of the first pair whose value equals the key, or eps if no entry matches. Its declaration is:

dec $match_entry_value(value, (tableActionReference, value)*)
  : tableActionReference?

The three def clauses that implement it are left as the exercise for this section.

There are three rules in the TableMatch_eval rulegroup. The first handles the case where $match_entry_value returns eps (no entry matched the key) and leaves the context unchanged. The other two handle a successful match and differ only in whether the matched action reference carries an argument list:

rulegroup TableMatch_eval {

  rule TableMatch_eval/no-match:
    EC value_tableKey |- tableEntry* -| EC
    -- if (`( expression_entry `) ':' tableActionReference_entry ';' = tableEntry)*
    -- (Expr_eval: BLOCK EC |- expression_entry : value_entry)*
    -- if eps
        = $match_entry_value(
            value_tableKey,
            (tableActionReference_entry, value_entry)*
          )

  rule TableMatch_eval/match-no-argumentList:
    EC_0 value_tableKey |- tableEntry* -| EC_1
    -- if (`( expression_entry `) ':' tableActionReference_entry ';' = tableEntry)*
    -- (Expr_eval: BLOCK EC_0 |- expression_entry : value_entry)*
    -- if name
        = $match_entry_value(
            value_tableKey,
            (tableActionReference_entry, value_entry)*
          )
    -- Statement_eval:
        BLOCK EC_0 |- name `( _EMPTY `) ';' -| EC_1

  rule TableMatch_eval/match-argumentList:
    EC_0 value_tableKey |- tableEntry* -| EC_1
    -- if (`( expression_entry `) ':' tableActionReference_entry ';' = tableEntry)*
    -- (Expr_eval: BLOCK EC_0 |- expression_entry : value_entry)*
    -- if name `( argumentList `)
        = $match_entry_value(
            value_tableKey,
            (tableActionReference_entry, value_entry)*
          )
    -- Statement_eval:
        BLOCK EC_0 |- name `( argumentList `) ';' -| EC_1

}

Both rules evaluate all entry key expressions to value_entry*, then call $match_entry_value to find the matching action reference, and pattern-match on its shape:

  • match-no-argumentList fires when the reference is a bare name. It synthesizes a call statement name ( EMPTY ) ; and delegates to Statement_eval.
  • match-argumentList fires when the reference carries an argument list. It synthesizes name ( argumentList ) ; and delegates to Statement_eval.

In both cases the action is dispatched as an ordinary call statement, so Statement_eval routes it through Callee_eval and Call_eval exactly as any other action call would be.

Table Evaluation

Table_eval orchestrates the key lookup and entry matching:

rule Table_eval/no-entries:
  EC |- tableKeyProperty tableActionsProperty -| EC

rule Table_eval/no-match:
  EC_0 |- (KEY '=' tableKey)
        (ACTIONS '=' `{ tableActionList `})
        (CONST ENTRIES '=' `{ tableEntryList `}) -| EC_1
  -- TableKey_eval: EC_0 |- tableKey : value_tableKey
  -- if tableEntry* = $flatten_tableEntryList(tableEntryList)
  -- TableMatch_eval: EC_0 value_tableKey |- tableEntry* -| EC_1

Table_eval/no-entries handles a table declared without const entries. No key is evaluated and no action is dispatched; the context passes through unchanged.

Table_eval/entries handles a table with const entries: it evaluates the key, flattens the entry list, and delegates to TableMatch_eval.

Note that the tableActionsProperty is not used at runtime. The actions list was needed at type-checking time to validate which actions are reachable and to register their parameter signatures; at evaluation time the action is dispatched directly by name from within TableMatch_eval.

Exercise

Branch: exercise/5.8

Check out the exercise branch in the spec submodule:

git -C nano-p4/spec checkout exercise/5.8

Run the following test to observe the failure:

./nano-p4spectec eval nano-p4/spec -i nano-p4/include -p nano-p4/testdata/exercise/5.8.p4 -stf nano-p4/testdata/exercise/5.8.stf

The test should pass, but it fails. The three def clauses of $match_entry_value are missing from 8.11-eval-table.watsup. Write them using the prose description in Table Entry Matching above, and verify that the test passes.

When you are done, restore the original branch:

git -C nano-p4/spec checkout main

Tips for Debugging

While writing or updating a spec, you will run into errors. This chapter collects the techniques that help narrow the gap between a failing run and a root cause.

The two worked examples below use the exercises from Section 3.1 and Section 3.2. You may complete those exercises yourself or walk through them here.

How P4-SpecTec Executes Rules

Before diving into the techniques, it helps to understand how the interpreter runs. This is covered in How Rules are Executed in Chapter 1.

Tip 1: Read the Error Message Carefully

The default error output looks like this:

runtime error: relation Program_ok failed
│ ··· omitting 1 traces ···
nano-p4/spec/5.08-typing-declaration.watsup:191.8-191.16
relation Decls_ok failed
└── nano-p4/spec/5.08-typing-declaration.watsup:190.8-190.15
    relation Decl_ok failed

The tree is printed bottom-up: Program_ok called Decls_ok, which called Decl_ok, which failed. The file and line number on each line identifies the premise that invoked the child relation. Navigate there to see what the parent was trying to prove and under what conditions.

Tip 2: Use -trace-full to See Values at Each Step

Pass -trace-full to print the actual values being matched at each step:

./nano-p4spectec check nano-p4/spec -i nano-p4/include -p program.p4 -trace-full

Each [in: ...] line shows the concrete values a relation or function received. This is most useful when a rule silently fails due to a pattern mismatch: you can see exactly what shape the value has and compare it against the patterns written in the spec.

Tip 3: Insert debug Premises

The debug <expr> premise prints the value of <expr> and always succeeds. It is the spec equivalent of console.log. Place it inside a function or rule definition to inspect intermediate values:

def $find_var_t(BLOCK, TC, id) = varTypeIR
  -- debug id            ;; prints the id being looked up
  -- if typeFrame = TC.BLOCK.FRAME
  -- if varTypeIR = $find_map<id, varTypeIR>(typeFrame, id)

The output line includes the source location so you know which premise fired:

nano-p4/spec/5.00-typing-context.watsup:196.12-196.14: id
pass

You can debug any expression, not just variables. debug TC.BLOCK.FRAME prints the entire block frame; debug (TC.LOCAL.FRAMES) prints the local stack. Remove all debug premises before committing: they do not affect correctness but add noise to every run.

Tip 4: Navigate to the Syntax Definition to Check Pattern Matching

When a rule silently fails without calling any sub-relation, the usual cause is an if premise whose pattern does not match the actual value: the value exists and is well-formed, but the pattern is wrong.

-trace-full shows the actual value going into each -- if. Compare it against the pattern written in the spec. Common mismatches:

  • Using STRUCT where the value is HEADER (or vice versa).
  • Forgetting a constructor variant in a rulegroup.
  • Matching on a tag that only covers one branch of a union type.

When you spot a suspicious pattern, look up its type in the spec to see all variants. For example, typeIR is defined across several constructors in 2.1-ir.watsup. If a rulegroup handles STRUCT but not HEADER, types that are headers will fall through every branch and the relation will fail.


Worked Example: Exercise 3.1

This example uses the exercise program from Section 3.1:

control Filter(inout Header hdr, out bool pass) {
    apply {
        pass = true;
    }
}

Step 1: Run and narrow down the declaration

$ ./nano-p4spectec check nano-p4/spec \
    -i nano-p4/include \
    -p nano-p4/testdata/exercise/3.1.p4
runtime error: relation Program_ok failed
...
relation Decl_ok failed

Decl_ok failed somewhere inside one of the declarations, but the message does not say which one. Between nano_core.p4, nano_model.p4, and the program itself, there are many declarations in scope: Nanonet, Header, packet_in, NoAction, parse, filter, NanoSwitch, Parser, Filter, and main. This tells you almost nothing about where to look.

Add a debug before the Decl_ok premise in Decls_ok/cons in 5.08-typing-declaration.watsup:

rule Decls_ok/cons:
  TC_0 |- declaration_h :: declaration_t* -| TC_2
  -- debug declaration_h
  -- Decl_ok: TC_0 |- declaration_h -| TC_1
  -- Decls_ok: TC_1 |- declaration_t* -| TC_2

Because debug always succeeds and prints before Decl_ok runs, the last declaration printed before the error is the one that failed:

...
nano-p4/spec/5.08-typing-declaration.watsup:190.14-190.27: declaration_h
nano-p4/testdata/exercise/3.1.p4:9.1-13.2: CONTROL _ID Filter `( ... `) `{ ... `}
runtime error: relation Program_ok failed

The Filter control declaration is the culprit. Remove the debug line and move on.

Step 2: Narrow down with debug

The error is somewhere inside Filter. Your job now is to find which rule in the call stack actually fails. The call stack for type-checking pass = true looks like this:

Decl_ok/controlDeclaration
  Block_ok
    Statement_ok/assignmentStatement
      Lvalue_ok/referenceExpression
        $find_var_t

You need to find where execution halts. The technique is to bracket a suspicious premise with two debug lines: one before and one after. If the second never prints, that premise is the culprit.

pass = true is an assignment. The right-hand side true is a boolean literal and is unlikely to be the source of error. The suspicious part is the left-hand side: pass is a control parameter, and variable lookup is what could plausibly fail. Open Statement_ok/assignmentStatement in 5.05-typing-statement.watsup and wrap the Lvalue_ok premise:

rule Statement_ok/assignmentStatement:
  scope TC |- lvalue '=' expression ';' -| TC
  -- debug lvalue                          ;; add before
  -- Lvalue_ok: scope TC |- lvalue : typeIR_lvalue
  -- debug lvalue                          ;; add after
  -- Expr_ok: scope TC |- expression : typeIR_expression
  -- Type_eq: typeIR_lvalue ~~ typeIR_expression

If the second debug lvalue never prints, Lvalue_ok is where the failure is. Once confirmed, remove the debug lines and move into Lvalue_ok/referenceExpression in 5.04-typing-lvalue.watsup. Its only meaningful premise is the call to $find_var_t, so that is where to look next.

Open 5.00-typing-context.watsup and look at the clauses for $find_var_t with the BLOCK tag. There are two: a hit clause (returns the found type) and a fallthrough clause (delegates to GLOBAL when the block frame has no match). Add a debug premise to the fallthrough clause to check whether pass is reaching it:

def $find_var_t(BLOCK, TC, id) = $find_var_t(GLOBAL, TC, id)
  -- debug id             ;; add this
  -- if typeFrame = TC.BLOCK.FRAME
  -- if eps = $find_map<id, varTypeIR>(typeFrame, id)

Run again. The output shows:

nano-p4/spec/5.00-typing-context.watsup:...: id
pass

pass does reach the fallthrough. That means pass was looked up in the block frame and treated as a miss. But pass is a control parameter, which lives in the block frame. Something about the hit clause must be preventing it from matching. Compare the hit clause against the fallthrough clause and the analogous GLOBAL hit clause above it.

Step 3: Fix

Remove the debug line and add the missing clause, by analogy with the GLOBAL hit clause directly above it:

def $find_var_t(BLOCK, TC, id) = varTypeIR
  -- if typeFrame = TC.BLOCK.FRAME
  -- if varTypeIR = $find_map<id, varTypeIR>(typeFrame, id)

Run the test again, and it will pass.


Worked Example: Exercise 3.2

This example uses the exercise program from Section 3.2, which assigns a Nanonet header variable to another variable of the same type.

Step 1: Run and narrow down the declaration

The error message is the same as before: Decl_ok failed, with no indication of which declaration. Apply the same debug declaration_h technique from Exercise 3.1. The last declaration printed before the error will be:

nano-p4/testdata/exercise/3.2.p4:10.1-16.2: CONTROL _ID Filter `( ... `) `{ ... `}

The Filter control declaration is the culprit.

Step 2: Narrow down with debug

The call stack for type-checking the body of Filter looks like this:

Decl_ok/controlDeclaration
  Block_ok
    Statement_ok/variableDeclaration   (for: Nanonet b = a)
      VarDecl_ok
        Type_ok
        Expr_ok
        Type_eq

Nanonet b = a declares a variable and initializes it. In VarDecl_ok, the suspicious premise is Type_eq: Type_ok resolves the declared type and Expr_ok infers the type of a, but Type_eq compares them and could silently fail if it has no rule for the types involved.

Wrap Type_eq in VarDecl_ok with two debug lines:

rule VarDecl_ok:
  scope TC_0 |- type name ('=' expression) ';' -| TC_1
  -- Type_ok: TC_0 |- type ~> typeIR
  -- Expr_ok: scope TC_0 |- expression : typeIR'
  -- debug typeIR                           ;; add before
  -- Type_eq: typeIR ~~ typeIR'
  -- debug typeIR                           ;; add after
  -- if id = $id(name)
  -- if varTypeIR = INOUT typeIR
  -- if TC_1 = $add_var_t(scope, TC_0, id, varTypeIR)

If the second debug typeIR never prints, Type_eq is the culprit. Remove the debug lines and move into Type_eq.

Step 3: Use -trace-full to see what Type_eq receives

Once you have confirmed Type_eq is failing, use -trace-full to see the actual values it receives:

./nano-p4spectec check nano-p4/spec -i nano-p4/include \
  -p nano-p4/testdata/exercise/3.2.p4 -trace-full 2>&1 | grep -A3 "Type_eq"

The relevant lines:

[ 8]                  [in: HEADER Nanonet `{ [...] `}, HEADER Nanonet `{ [...] `}]
[ 9]                     | Case analysis on typeIR

Type_eq receives two identical HEADER Nanonet values and immediately does a case analysis on typeIR, but no branch matches. The relation is receiving valid inputs; the problem is not the values but the rules. Open 5.02-typing-type.watsup and check which constructors Type_eq covers. Cross-reference against the dataTypeIR variants in 2.1-ir.watsup. One variant is missing, and it is exactly the constructor that appeared above.

Step 4: Fix

Add the missing rule by analogy with the STRUCT rule directly above it:

rule Type_eq/headerTypeIR:
  (HEADER typeId `{ _ `})
  ~~ (HEADER typeId `{ _ `})

Run the test, and it will pass.

Generating Prose Specification

The chapters up to this point have walked through every part of the Nano-P4 specification as it lives in .watsup files. P4-SpecTec can do more than run programs against a spec: it can also render the spec into human-readable English prose, formatted as AsciiDoc, ready to be compiled into HTML or PDF. As a concrete example of what the output looks like, the P4-SpecTec team publishes a spliced HTML document generated from the mechanized P4 spec.

This chapter introduces the splice workflow and shows it in action on the Nano-P4 conditional statement.

What Splicing Does

Splicing is a two-step process:

  1. You write a skeleton document: a normal AsciiDoc file with special directive placeholders such as ${syntax: conditionalStatement} or ${rulegroup-prose: Statement_eval/conditionalStatement}. These are not AsciiDoc constructs; they are recognized only by P4-SpecTec.
  2. You run nano-p4spectec splice, which reads your .watsup spec files, extracts the relevant definitions, and replaces every placeholder in the skeleton with the rendered content.

The result is a complete AsciiDoc document. Compile it with Asciidoctor to produce HTML or PDF.

Directive Reference

Each directive has the form ${<type>: <key>}.

Directive typeKey formatWhat it renders
syntaxsyntax nameBNF grammar block for that production
relation-title-sourcerelation nameRelation signature in SpecTec source syntax
relation-title-proserelation nameRelation signature rendered as prose
rulegroup-sourceRelName/rulegroupSpecTec source for the rulegroup
rulegroup-proseRelName/rulegroupAuto-generated English prose for the rulegroup
func-sourcefunction nameSpecTec source for auxiliary function definitions
func-prosefunction nameAuto-generated English prose for auxiliary functions

A ${syntax: ...} directive accepts multiple space-separated names and emits them all in a single grammar block.

Running the Splice Command

nano-p4spectec exposes the splice subcommand:

nano-p4spectec splice <spec-files...> -splice <skeleton.adoc> -out <output.adoc>

To splice multiple skeleton files in one pass, repeat -splice/-out pairs as needed:

nano-p4spectec splice nano-p4/spec \
  -splice nano-p4/docs/sections-skeleton/conditional.adoc -out conditional.adoc \
  -splice nano-p4/docs/sections-skeleton/parser.adoc      -out parser.adoc

For Nano-P4, pass the spec files from nano-p4/spec/:

nano-p4spectec splice nano-p4/spec \
  -splice nano-p4/docs/sections-skeleton/conditional-statement.adoc \
  -out conditional-statement-spliced.adoc

A Worked Example: Conditional Statement

Below is a minimal skeleton for the conditional statement section. It uses five directives: one syntax directive for the grammar, and one source/prose pair each for the type-checking and runtime-evaluation rules.

conditional-statement.adoc (skeleton):

[#sec-conditional-statement]
== Conditional Statement

The conditional statement is Nano-P4's sole branching construct. Unlike C or
Java, where any non-zero integer is truthy, P4 (and Nano-P4) require the
condition to be a Boolean expression. Both branches are always required; there
is no optional `else`.

${syntax: conditionalStatement}

=== Type Checking

${rulegroup-source: Statement_ok/conditionalStatement}
${rulegroup-prose: Statement_ok/conditionalStatement}

=== Runtime Evaluation

${rulegroup-source: Statement_eval/conditionalStatement}
${rulegroup-prose: Statement_eval/conditionalStatement}

After running nano-p4spectec splice,

nano-p4spectec splice nano-p4/spec \
  -splice nano-p4/docs/sections-skeleton/conditional-statement.adoc \
  -out conditional-statement-spliced.adoc

each placeholder is replaced. The syntax directive expands to a BNF grammar block:

conditionalStatement
   : IF `( expression ) blockStatement ELSE blockStatement
   ;

The rulegroup-source directive produces a collapsible block (visible only in the HTML backend) showing the raw SpecTec source:

Click to view the specification source
rulegroup Statement_ok/conditionalStatement:
  rule Statement_ok/conditionalStatement:
  scope TC |- IF `( expression `) blockStatement_then ELSE blockStatement_else -| TC
 -- Expr_ok: scope TC |- expression : BOOL
 -- Block_ok: TC |- blockStatement_then
 -- Block_ok: TC |- blockStatement_else

The rulegroup-prose directive produces auto-generated English steps. Without any prose hints on the relations, the output uses raw SpecTec notation in the cross-references:

  1. Let scope TC |- expression : typeIR.

  2. Let!type baseTypeIR be typeIR.

  3. Check that baseTypeIR is BOOL.

  4. If TC |- blockStatementthen holds:

    1. If TC |- blockStatementelse holds:

      1. Result in TC.

The cross-references are legible, but the link text is just a rendering of the relation’s notation rather than natural English. The prose_in and prose_out hints on the relation definition control this. For example, adding:

relation Expr_ok:
  scope typingContext |- expression : typeIR
  hint(input %0 %1 %2)
  hint(prose_in "typing" %2#", under context" %1 "at" %0)
  hint(prose_out %3)

tells the renderer how to describe what Expr_ok computes:

  • prose_in controls the link text in Let X be the result of <link>[...] steps, replacing the raw SpecTec notation with natural English. The %N placeholders refer to the relation’s arguments by index. The # operator fuses two adjacent pieces without inserting a space between them, so %2#", under context" produces expression, under context rather than expression , under context.
  • prose_out names the variable on the left-hand side of Let X be.... With hint(prose_out %3), the renderer picks up typeIR (the fourth argument) and produces Let typeIR be the result of .... For relations whose output is a context, writing hint(prose_out "context" %3) prepends the label, giving Let context EC_1 be... instead of a bare Let EC_1 be.... Without prose_out, the step collapses to a bare Let xref:Expr_ok[...] with no named output variable.

With prose_in/prose_out hints added to Expr_ok, Statement_ok, Block_ok, Expr_eval, Statement_eval, and Block_eval, the prose becomes:

For Block_ok, which is a hold relation (no output), prose_true and prose_false are used instead of prose_in/prose_out:

relation Block_ok:
  typingContext |- blockStatement
  hint(input %0 %1)
  hint(prose_true %1 "is well-typed under context" %0)
  hint(prose_false %1 "is not well-typed under context" %0)

This controls the phrasing of If ... holds branches: prose_true gives the text when the relation holds, prose_false when it does not.

For the runtime evaluation, the output with hints is:

  1. Let value be the result of evaluating expression, under context EC at scope.

  2. If value is equal to true:

    1. Let context EC1 be the result of runtime evaluation of blockStatementthen, under context EC.

    2. The result is context EC1.

  3. Else if value is equal to false:

    1. Let context EC1 be the result of runtime evaluation of blockStatementelse, under context EC.

    2. The result is context EC1.

The hints are defined in 5.01-typing-relation.watsup and 8.01-eval-relation.watsup.

This matches the two-rule structure you saw in Section 5.4: evaluate the condition, then dispatch to the appropriate branch.

Generating an HTML Spec File

Once spliced, compile the AsciiDoc output with Asciidoctor:

asciidoctor -o conditional-statement.html conditional-statement-spliced.adoc

For an HTML document that shows the collapsible source blocks, you need the backend-html5 attribute (which Asciidoctor sets by default when targeting HTML5). The ifdef::backend-html5[] guards in the spliced output are resolved at compile time, so they only appear in the HTML output, not in PDF.

The skeleton file lives in the nano-spec repository at nano-p4/docs/sections-skeleton/conditional-statement.adoc. You can reproduce the full HTML output by running:

./nano-p4spectec splice nano-p4/spec \
  -splice nano-p4/docs/sections-skeleton/conditional-statement.adoc \
  -out out/conditional-statement.adoc
asciidoctor -o out/conditional-statement.html out/conditional-statement.adoc

The prose hints used throughout this chapter live on the prose branch of the nano-spec repository. Check out that branch to find the sample spec with the prose annotations already applied to 5.01-typing-relation.watsup and 8.01-eval-relation.watsup.

Closing

You have now walked through every layer of a mechanized P4 specification: the standard library, syntax definitions, static semantics, loading phase, dynamic semantics, debugging, and prose generation. Along the way, you built a reference type checker and interpreter for Nano-P4 and tested them against concrete programs.

The skills transfer directly. The full mechanized P4 specification lives in the p4-spectec repository and is structured the same way: .watsup files organized by phase, relations defined by rules, auxiliary functions threading context through the spec. The constructs you used here (syntax, relation, rule, def, hint) are exactly what the full spec uses, at larger scale.

If you want to go further, the full spec is the natural next step. The exercises in this tutorial showed how a single missing rule or wrong pattern produces a failure. The same discipline applies: read the error trace, insert debug premises, cross-reference the IR definitions. The toolchain is the same, only the surface area is larger.

Nano-P4 Grammar Reference

The complete Nano-P4 grammar, as defined in 1-syntax.watsup.

program
    : /* empty */
    | program declaration
    ;

declaration
    : instantiation
    | actionDeclaration
    | matchKindDeclaration
    | externDeclaration
    | parserDeclaration
    | controlDeclaration
    | typeDeclaration
    ;

typeDeclaration
    : derivedTypeDeclaration
    | parserTypeDeclaration
    | controlTypeDeclaration
    | packageTypeDeclaration
    ;

derivedTypeDeclaration
    : structTypeDeclaration
    | headerTypeDeclaration
    ;

structTypeDeclaration
    : STRUCT name "{" typeFieldList "}"
    ;

headerTypeDeclaration
    : HEADER name "{" typeFieldList "}"
    ;

typeFieldList
    : /* empty */
    | typeFieldList typeField
    ;

typeField
    : type name ";"
    ;

parserTypeDeclaration
    : PARSER name "(" parameterList ")" ";"
    ;

controlTypeDeclaration
    : CONTROL name "(" parameterList ")" ";"
    ;

packageTypeDeclaration
    : PACKAGE name "(" parameterList ")" ";"
    ;

instantiation
    : type "(" argumentList ")" name ";"
    ;

actionDeclaration
    : ACTION name "(" parameterList ")" blockStatement
    ;

matchKindDeclaration
    : MATCH_KIND "{" nameList "}"
    ;

externDeclaration
    : externObjectDeclaration
    ;

externObjectDeclaration
    : EXTERN name "{" externMethodPrototypeList "}"
    ;

externMethodPrototypeList
    : /* empty */
    | externMethodPrototypeList externMethodPrototype
    ;

externMethodPrototype
    : functionPrototype ";"
    ;

functionPrototype
    : VOID name "(" parameterList ")"
    ;

parserDeclaration
    : PARSER name "(" parameterList ")"
      "{" parserLocalDeclarationList parserStateList "}"
    ;

parserLocalDeclarationList
    : /* empty */
    | parserLocalDeclarationList parserLocalDeclaration
    ;

parserLocalDeclaration
    : variableDeclaration
    ;

parserStateList
    : parserState
    | parserStateList parserState
    ;

parserState
    : STATE name "{" statementList transitionStatement "}"
    ;

transitionStatement
    : TRANSITION stateExpression
    ;

stateExpression
    : name ";"
    | selectExpression
    ;

selectExpression
    : SELECT "(" expression ")" "{" selectCaseList "}"
    ;

selectCaseList
    : /* empty */
    | selectCaseList selectCase
    ;

selectCase
    : expression ":" name ";"
    ;

controlDeclaration
    : CONTROL name "(" parameterList ")"
      "{" controlLocalDeclarationList APPLY controlBody "}"
    ;

controlLocalDeclarationList
    : /* empty */
    | controlLocalDeclarationList controlLocalDeclaration
    ;

controlLocalDeclaration
    : variableDeclaration
    | tableDeclaration
    ;

controlBody
    : blockStatement
    ;

tableDeclaration
    : TABLE name "{" tableProperties "}"
    ;

tableProperties
    : tableKeyProperty tableActionsProperty
    | tableKeyProperty tableActionsProperty tableEntriesProperty
    ;

tableKeyProperty
    : KEY "=" tableKey
    ;

tableKey
    : "{" expression ":" name ";" "}"
    ;

tableActionsProperty
    : ACTIONS "=" "{" tableActionList "}"
    ;

tableActionList
    : tableAction
    | tableActionList tableAction
    ;

tableAction
    : tableActionReference ";"
    ;

tableActionReference
    : name
    | name "(" argumentList ")"
    ;

tableEntriesProperty
    : CONST ENTRIES "=" "{" tableEntryList "}"
    ;

tableEntryList
    : /* empty */
    | tableEntryList tableEntry
    ;

tableEntry
    : "(" expression ")" ":" tableActionReference ";"
    ;

statementList
    : /* empty */
    | statementList statement
    ;

statement
    : emptyStatement
    | variableDeclaration
    | assignmentStatement
    | callStatement
    | blockStatement
    | conditionalStatement
    ;

emptyStatement
    : ";"
    ;

variableDeclaration
    : type name "=" expression ";"
    ;

assignmentStatement
    : lvalue "=" expression ";"
    ;

callStatement
    : lvalue "(" argumentList ")" ";"
    ;

blockStatement
    : "{" statementList "}"
    ;

conditionalStatement
    : IF "(" expression ")" blockStatement ELSE blockStatement
    ;

lvalue
    : name
    | lvalue "." member
    | "(" lvalue ")"
    ;

expression
    : booleanLiteral
    | integerLiteral
    | name
    | unop expression
    | expression binop expression
    | expression "." member
    | name "(" argumentList ")"
    | "(" expression ")"
    ;

booleanLiteral
    : TRUE
    | FALSE
    ;

integerLiteral
    : INTEGER
    ;

name
    : IDENTIFIER
    | APPLY
    | KEY
    | ACTIONS
    | STATE
    ;

unop
    : "!"
    | "~"
    | "-"
    | "+"
    ;

binop
    : "*" | "+" | "-" | "/" | "%"
    | "<<" | ">>"
    | "<=" | ">=" | "<" | ">" | "!=" | "=="
    | "&" | "^" | "|"
    | "&&" | "||"
    ;

argumentList
    : /* empty */
    | argumentListNonEmpty
    ;

argumentListNonEmpty
    : expression
    | argumentListNonEmpty "," expression
    ;

parameterList
    : /* empty */
    | nonEmptyParameterList
    ;

nonEmptyParameterList
    : parameter
    | nonEmptyParameterList "," parameter
    ;

parameter
    : direction type name
    ;

direction
    : /* empty */
    | IN
    | OUT
    | INOUT
    ;

type
    : baseType
    | typeName
    ;

baseType
    : BOOL
    | MATCH_KIND
    | BIT "<" INTEGER ">"
    | INT "<" INTEGER ">"
    ;

typeName
    : TYPE_IDENTIFIER
    ;

member
    : name
    ;

nameList
    : name
    | nameList "," name
    ;