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-p4spectecsubcommands 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-p4spectecbinary used throughout this tutorial are on thegsoc-nano-specbranch. 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:
| Type | Description |
|---|---|
bool | Boolean (true or false) |
int | Arbitrary-precision integer |
nat | Non-negative integer |
text | String |
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
INTandBOOL. They are written without any marker. - Silent tags are prefixed with an underscore, like
_NUMand_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:
| Operator | Meaning |
|---|---|
~b | logical not |
b1 /\ b2 | logical and |
b1 \/ b2 | logical or |
b1 = b2 | equality |
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 (epsrepresents the absent case). - Each
defcase can have side conditions introduced withif, or a catch-allotherwise. - When a function is called, clauses are tried from top to bottom. If pattern
match fails or
ifpremises 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
ifcondition 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
.p4file 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
evalmeans 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
elabin 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
| Flag | Description |
|---|---|
-p <file> | Path to the Nano-P4 program |
-i <dir> | Include path for Nano-P4 headers (can be repeated) |
-t | Print 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
| Flag | Description |
|---|---|
-p <file> | Path to the Nano-P4 program |
-i <dir> | Include path for Nano-P4 headers (can be repeated) |
-trace-full | Emit 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
| Flag | Description |
|---|---|
-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
| Flag | Description |
|---|---|
-p4-dir <dir> | Directory of .p4 files to test (can be repeated) |
-i <dir> | Include path for Nano-P4 headers (can be repeated) |
-neg | Negative 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
| Flag | Description |
|---|---|
-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
| Goal | Command |
|---|---|
| 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: which P4 features Nano-P4 includes and which it leaves out
- Syntax Definition in P4-SpecTec: how the Nano-P4 grammar is expressed as P4-SpecTec syntax rules
- NanoSwitch Architecture: the target architecture that Nano-P4 programs run on
- Test Suite: Nano-P4 programs used to verify spec correctness
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>andint<N>whereNis 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_kindentry) - 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]) enumtypesheader_unionlistandtupletypes- Generic types
- Arbitrary-width integer literals
Statements and control flow
forloopsswitchstatementifwithout anelse
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 (
.fieldwithout a receiver)
Table features
- The return value of
table.apply()(.hit,.action_run) default_actionsizeand 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:
- Parser: Reads the packet and extracts the
Header. - Filter: Receives the parsed header and an
acceptflag (initialized tofalse). The control block setsacceptbased on header fields and table lookups. After the filter runs, the pipeline readsacceptand 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 outputnegative/: 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:
- Looks up
Headerin the typing contextTCvia$find_typeDef_t, retrieving atypeDefIR. - Converts that
typeDefIRto atypeIRwith$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:
- Convert
nameto anidusing$id.idis an alias fortext. - Look it up via
$find_var_t, returning avarTypeIRpair ofdirectionandtypeIR. 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:
- Type-check the base expression and get back a struct or header type
typeIR_base. - Destructure
typeIR_baseto extract its field list(typeIR_field id_field)*. - Convert the member token to an
id_member. - Look up
id_memberin 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/structandExpr_ok/headerin5.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:
- Elaborate the declared type
type. - Type-check the initializer expression.
- Check that both types are equal via
Type_eq. - Compute
idfrom the name token. - Add the new variable to the context with
$add_var_t, recording its direction asINOUT.
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:
- Check that the left-hand side is a valid l-value
- Check that the right-hand side type-checks.
- 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:
- Pushes a new scope frame with
$enter_t, producingTC_1. - Flattens and type-checks the statements inside the state under
TC_1, threading the context through to produceTC_2. - Type-checks the transition statement under
TC_2, passing in the full set of valid state namesnameIR_state*. - 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:
- The state list is flattened and each state’s name is extracted, yielding
nameIR_state*. - One membership check enforces a P4 structural rule for parsers:
~("accept" <- nameIR_state*) /\ ~("reject" <- nameIR_state*): the namesacceptandrejectare reserved built-in states and may not be defined explicitly.
acceptandrejectare prepended tonameIR_state*to formnameIR_state_all*, the complete set of valid transition targets (user-defined states plus the two built-in sinks).- Finally,
(ParserState_ok: TC nameIR_state_all* |- parserState)*appliesParserState_okto every state in parallel, passing the full name set so that each state’s transition can reference any other state, includingacceptandreject.
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_okhandles variable declarations atBLOCKscope (see Section 3.4).TableDecl_okhandles 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_okchecks the key expression and resolves the match kind.TableAction_ok/TableActionList_okcheck each action reference and build the action list.TableEntry_ok/TableEntries_okvalidate optional constant entries against the table context.TableProperties_okorchestrates the above and produces the table context.TableDecl_okwraps 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.PARSERandCONTROL: 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
loadContexttype and its initialization - Loading Declarations: the rules that walk each declaration and populate the context
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_TYPEis copied directly fromTC.GLOBAL.CALLABLEat 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 correctparameterIR*.CALLABLEstarts empty and is populated as declarations are processed. By the end of loading it contains the fullcallableDeffor every action, parser, and control in the program.PARSERandCONTROLstart aseps(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:
- Setup (
NanoSwitch_setup): clears the per-packet global frame and initializespacket_in,hdr, andacceptto their default values. - Parse (
NanoSwitch_parse): invokes the loaded parser withpacket_inandhdras arguments. If the parser transitions toreject, the packet is dropped immediately. - Filter (
NanoSwitch_filter): invokes the loaded control withhdrandacceptas arguments. After the control returns, the globalacceptflag 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
outresults back to the caller - Parser Block: how parser states are entered, how packet
extraction and transitions work, and how
acceptorrejectis reached - Control Block: how control local declarations are
evaluated and how the
applyblock 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_evalbefore the call. - The body is dispatched through
ExternMethodCall_eval, an extern relation whose implementation is outside the spec (it handles built-in operations likepkt.extract). It receives the current extern value and the bound parameter names, and returns an updated extern valuevalue_extern'. - After
Copy_outwrites back any directional parameters,Lvalue_writestores the updated extern value back tolvalue_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:
$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.Copy_inbinds the call-site arguments to the parser’s parameters and records which caller l-values correspond tooutandinoutparameters (covered in Section 5.5).- The parser’s local declarations are evaluated in sequence, extending
EC_callee_1toEC_callee_2. ParserState_transruns the state machine starting from"start", threading the context through each state untilACCEPTorREJECTis reached.Copy_outpropagates anyoutandinoutresults back to the caller’s contextEC_0, producing the finalEC_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):
$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.Copy_inbinds the call-site arguments (hdrandaccept) to the control’s parameters and records which caller l-values correspond tooutandinoutparameters.- The control’s local declarations are evaluated in sequence, extending
EC_callee_1toEC_callee_2. Variable bindings and table values become accessible insideapply. Block_evalexecutes theapplyblock underEC_callee_2, threading the context through each statement in turn and producingEC_callee_3.Copy_outpropagates anyoutandinoutresults back to the caller’s contextEC_0, producing the finalEC_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-argumentListfires when the reference is a bare name. It synthesizes a call statementname ( EMPTY ) ;and delegates toStatement_eval.match-argumentListfires when the reference carries an argument list. It synthesizesname ( argumentList ) ;and delegates toStatement_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
STRUCTwhere the value isHEADER(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:
- 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. - You run
nano-p4spectec splice, which reads your.watsupspec 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 type | Key format | What it renders |
|---|---|---|
syntax | syntax name | BNF grammar block for that production |
relation-title-source | relation name | Relation signature in SpecTec source syntax |
relation-title-prose | relation name | Relation signature rendered as prose |
rulegroup-source | RelName/rulegroup | SpecTec source for the rulegroup |
rulegroup-prose | RelName/rulegroup | Auto-generated English prose for the rulegroup |
func-source | function name | SpecTec source for auxiliary function definitions |
func-prose | function name | Auto-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:
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_incontrols the link text inLet X be the result of <link>[...]steps, replacing the raw SpecTec notation with natural English. The%Nplaceholders refer to the relation’s arguments by index. The#operator fuses two adjacent pieces without inserting a space between them, so%2#", under context"producesexpression, under contextrather thanexpression , under context.prose_outnames the variable on the left-hand side ofLet X be.... Withhint(prose_out %3), the renderer picks uptypeIR(the fourth argument) and producesLet typeIR be the result of .... For relations whose output is a context, writinghint(prose_out "context" %3)prepends the label, givingLet context EC_1 be...instead of a bareLet EC_1 be.... Withoutprose_out, the step collapses to a bareLet 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:
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
;