Design intent
Declare what a board is supposed to contain, and have every revision checked against the architecture you agreed rather than against itself.
A netlist says what is wired. It cannot say what was meant. “The board has two regulators” is a fact you can read off the file; “the board should have two regulators” is a decision somebody made in a meeting, and nothing in the export records it.
A design-intent declaration writes that decision down as YAML beside the design, and check compares
the board against it. This tier catches a rail quietly moved to the wrong domain, a module
dropped during a cost reduction, or a power-up order that got reversed when the power tree
was redrawn. None of those look wrong in the schematic. They look wrong against what you said you
were building.
Write a declaration ¶
# designs/gateway/design.yaml
name: gateway
extends: ../../project.yaml
entry: gateway.edn
intent:
modules:
- {name: regulators, class: regulator, count: 2}
- {name: connectors, class: connector, count: 1}
- {name: power tree, nets: [PMIC_MAIN_12V0, PMIC_CORE_3V3, PMIC_IO_1V8]}
- {name: can, nets: [CAN1_CANH, CAN1_CANL, CAN1_TXD, CAN1_RXD]}
nets:
PMIC_MAIN_12V0: {nominal: 12.0, domain: main}
PMIC_CORE_3V3: {nominal: 3.3, domain: io}
PMIC_IO_1V8: {nominal: 3.3, domain: core}
That last rail is wrong on purpose, so the run below has something to catch. PMIC_IO_1V8 is
declared at 3.3 V in the core domain, and it is an actual 1.8 V rail. Nothing about the schematic is
malformed. The board simply stopped matching the architecture, and only a declaration can notice.
Where it goes ¶
In the design’s own design.yaml, under intent:. A design that belongs to a project needs no flag,
because the project reads its descriptor and finds the section:
$ agni check designs/gateway --rule intent/voltage-domain-mismatch
findings by rule:
intent/voltage-domain-mismatch 1
first 1:
[warning] intent/voltage-domain-mismatch: PMIC_IO_1V8 (rail "PMIC_IO_1V8" is declared in voltage domain "core" (3.3V) but its name declares 1.8V)
1 finding(s) total
3 subject(s) considered by 1 rule(s) (--verdicts for the detail)
Intent is usually per-design, where naming conventions, interface profiles and seeded parameters
are usually per-project. Each board has its own intended architecture, so the declaration lives with
the board rather than with the team, in the one file that already says which files the board is. A
project shared by a family of boards can declare it once in its project.yaml, and a design’s own
declaration replaces the project’s.
A design that belongs to no project still runs intent, and its own design.yaml declares it.
--intent-path on check and review names a file in the same shape, a name and an intent:
section, for a design with no design.yaml, or to try a declaration before committing it. The file
travels with the request as the declaration’s value. On a design that already declares intent, it
REPLACES that declaration for the run and is not added to it. A request’s naming convention replaces
the project’s in the same way. A server takes no intent flag, since intent is per design and a flag
would apply one board’s declaration to every design it serves (agni issue 831).
The section’s schema is the DesignIntent message in protos/agni/v1/config/intent.proto, so a
declaration has the same shape in YAML, in JSON and on the wire, and a key the message does not have
fails the load with the line it sits on.
Intent lives only in a descriptor’s intent: section. A key the section does not have, including the
forms of the earlier nine-form vocabulary, fails the load at its line.
The seven forms ¶
Each form answers a question the netlist cannot. Three are keyed by what they describe (a block, a net, a component), and the other four describe relationships between several nets.
| Form | Declares | Fails when |
|---|---|---|
modules |
a functional block the board must contain, by class or MPN with an optional count, or by the nets it must instantiate | a declared module is absent, the count is short, or a declared net is missing |
nets |
what each named net is, keyed by name (its voltage and domain, its peak draw, the protection it carries, its reset polarity, strap level, AC coupling or pull-up reach) | the design contradicts any fact declared for it |
sequences |
the power-up order of groups of rails | the gating chain is absent, or runs the other way round |
strap_groups |
several strap nets read together as one binary number, and the value it encodes | the group does not encode the declared value, or two devices collide |
io_map |
which net is on which pin of which device, and optionally the function selected there and what sits at the far end | the net is on a different pin, the declared net is absent, the far end is wrong, or the datasheet lists no such function on that pin |
margin_factor |
the headroom every supply must have over its rails’ declared peaks | a supply is rated below peak times the factor |
components |
what each named component is, keyed by ref-des (a connector’s exposure) | a declared component is absent, or is not a connector |
A nets entry carries any of these facts, and each compiles to the same rule it always did.
intent:
nets:
VDD_3V3: {nominal: 3.3, domain: io, peak: 0.8, protect: [ovp, discharge]}
MCU_NRST: {reset: low}
BOOT0: {strap: low, min_ohms: 4700, max_ohms: 47000}
PCIE_TX0_P: {ac_coupled: true}
margin_factor: 1.2
A rail with a nominal and no domain is named by its voltage (3.3V), and rails sharing a domain
must share a voltage. protect takes ovp (a TVS or zener clamping the rail) and discharge (a
bleeder to ground). A resistance band belongs to a strap, so min_ohms without strap is a load
error. A band whose resistor has no readable resistance, or a divider’s two resistors, reports
inconclusive with the band unchecked.
pull_up_reach_hops changes how far the pull-up walk searches from that net (agni issue 820).
i2c-pull-up and the rules beside it look for a rail within three series resistors of a bus,
because past three the series resistance is usually comparable to the pull-up and the bus no longer
returns high in time. Some buses reach their pull-up through a deeper network on purpose, such as a
series isolation resistor followed by a level-shifting stage, and the designer has sized it so the
bus still works. Declaring the reach on that net says so.
intent:
nets:
SDA_MAIN: {pull_up_reach_hops: 4}
$ agni check --verdicts --rule i2c-pull-up examples/common/designs/pullup-reach.edn
i2c-pull-up 1 fail, 1 pass
fail SDA_MAIN no rail is reachable from SDA_MAIN through a resistor within 3 hops
pass SCL_MAIN SCL_MAIN reaches rail VCC_3V3 through R5, and rail VCC_3V3 along SCL_MAIN ->
[R6] -> ISO_B -> [R7] -> VCC_3V3
2 verdicts across 1 rule(s), 1 pass, 1 fail
$ agni check --verdicts --rule i2c-pull-up examples/common/designs/pullup-reach.edn --intent-path examples/common/designs/pullup-reach.intent.yaml
i2c-pull-up 2 pass
pass SCL_MAIN SCL_MAIN reaches rail VCC_3V3 through R5, and rail VCC_3V3 along SCL_MAIN ->
[R6] -> ISO_B -> [R7] -> VCC_3V3
pass SDA_MAIN SDA_MAIN reaches rail VCC_3V3 along SDA_MAIN -> [R1] -> SER_A -> [R2] ->
SER_B -> [R3] -> SER_C -> [R4] -> VCC_3V3
2 verdicts across 1 rule(s), 2 pass
The first run is the default, so SDA_MAIN fails within 3 hops. The second declares a reach of 4
for SDA_MAIN alone and it passes through all four resistors, while SCL_MAIN keeps the default. A
failing verdict always states the limit it searched to, so a declared net that still fails says
“within 4 hops”. The reach is per net because the default is an electrical judgement, and moving it
for the whole design would pass buses nobody has sized. Zero or an absent value keeps the default,
and a negative one fails the load. The reach is looked up by the net’s exact name. So
intent/pull-up-reach-declared fails a declaration that names a net missing from the design, and a
misspelled name is reported rather than ignored (agni issue 1165). SCL_MAIN also has two pull-ups,
one direct and one behind a series resistor, and its passing verdict names both paths.
A components entry says whether a connector faces the outside of the product. The exposure rules
(esd-protection, esd-clamp-not-tvs, input-protection and reverse-blocking-absent) treat
every connector as a way into the board, which is right for a cable entry and wrong for a module
socket or a mezzanine that joins two boards inside one enclosure. The product decides which one a
connector is, and the part does not. The same board-to-board connector is the module socket on one
carrier and the cable entry on another, and its part number and footprint rarely say either way, so
the engine does not guess from them.
intent:
components:
J1: {exposure: internal} # the module connector
J18: {exposure: internal} # the expansion mezzanine
A connector declared internal leaves the exposure rules, so a net reaching only it is not checked
for ESD or input protection. A net that also reaches an undeclared connector still is. An undeclared
connector is external, and external can be written to say so. The declarations themselves compile
to intent/exposure-declared. It fails a ref-des missing from the design, and it fails a part that
is not a connector. Either declaration would change nothing while reading as one that took effect.
io_map is the largest form in practice, and most boards already have one, usually as a
spreadsheet. On any board carrying a big MCU or SoC, someone decides which peripheral goes on which
pin long before the schematic exists, firmware is written against that decision, and the schematic
is drawn from it. Usually an assignment moves late, the map is updated, and one net does not get
redrawn. Nothing about the resulting board is electrically wrong, so every other rule passes, and it
surfaces at bring-up as a peripheral that does not respond.
io_map:
- net: I2C_SDA
device: U3
pin: '9'
- net: MCU_NRST
device: U3
pin: PTC11 # the datasheet's name works as well as the designator
function: GPIO # optional, checked against the part's datasheet
to: {device: U1, pin: '5'} # optional far end
Write the pin either way. A map is written in the names a datasheet and a firmware header use, and
a netlist answers in package designators, so both are resolved. PTE7, PTE07, pte7 and a name
carrying a zero-width space pasted out of a spreadsheet all reach the same pin. When a match needed
any of that, the verdict says so, so a note always means something was inferred. agni trace
resolves a pin through the same lookup, so a trace can start from any pin named in a map.
It compiles to as many as five rules rather than one. Three of them ask whether the design kept the
promises the map made, one inverts the question to ask which nets the map never mentioned, and the
fifth compiles when a row declares a function and asks whether the part can be set to it. The
fourth rule’s count of unmentioned nets is usually the important one. A design with sixteen hundred
nets and a map declaring two hundred has two hundred checked and fourteen hundred unexamined, which
is not the same as clean, and nothing else in a run tells those apart. Nets it does not name report
as not-considered rather than as failures, because an undeclared net is a question nobody asked
and not a fault in the board.
The three that check the promises are separated because a reviewer acts on them differently, and
because two of them catch opposite defects (a net the map declares and the netlist does not have is
usually a real disconnection, where a net the netlist has and the map does not declare is an
incomplete map). The far-end columns are the sparse ones. In the one real map we measured, roughly a
third of rows declared a far end, so io-map-far-end gives EVERY row a verdict and a row with no far
end reads not-considered. A rule that reported only the rows carrying one would show a clean result
over a third of the map and say nothing about the rest.
io-map-function checks a row’s function against the functions listed for that pin in the part’s
datasheet. Those functions are read from the vendor’s pin-mux table into the corpus (see
the datasheet layer). Mapping SDA onto the pin
that offers SCL fails, and the failure lists what the pin can be. When a part has no datasheet
entry, or its entry has no pin-mux table, the row reads not-considered with that reason. A
filled-in function column therefore never reads as checked when it was not. A run with no datasheet
corpus at all skips the rule, as it skips every rule that needs one.
The map and the mux table are different records. The map is this board’s allocation and lives here.
The mux table lists what the part can do and lives in its datasheet spec. A mux table transcribed
from a reference manual is usually a file of its own. That file goes into the corpus through
agnids merge-mux rather than into the map.
Reading the map one pin at a time ¶
The four rules each list their own verdicts. That suits a reviewer working through one kind of
defect. It does not suit a reviewer signing off the map itself, who reads it a pin at a time. So a
run that checks a map also lays it out that way. agni check --format html opens with a section per
device (“Pins on U1”), and each declared pin is one row. A row shows the declared function, the
declared net, the net the netlist puts on that pin, the far end and the route to it, and what each
of the four per-row rules concluded.
| outcome | pin | function | declared net | netlist net | far end | route |
|---|---|---|---|---|---|---|
| pass | MDIO | MDIO | MDIO_NET | MDIO_NET | U3.MDIO | MDIO_NET -> [R1] -> MDIO_OUT |
| fail | RST | RESET | RST_N | RST_N | U3.RST |
A row’s outcome is the worst any rule reached on it, so the reset row fails on its far end alone
while its pin and its net both pass. A row no rule could judge reads not considered. The route
column holds the route agni trace prints, net to net through each series part.
The same rows make up the pin_map table. The Tabulate rpc returns it for a check run, a workbook
of the run carries it, and --table writes it as csv.
agni check --verdicts --format csv --table pin_map design/
Suppose a team already keeps a spreadsheet of ball, function and expected net. Each of its rows
maps straight onto a map row. The ball is pin, the function is function, the expected net is
net, and the MCU is each row’s device. A partner pin, when the sheet names one, is to.
Drafting a map from the netlist ¶
A board with a module connector has hundreds of pins to declare, and a map goes wrong first when
someone writes each row by hand. agni pinmap drafts one for a device from the netlist:
agni pinmap designs/jetson --device J1 -o j1-pins.yaml
Each pin on a net becomes a row. The far end is filled in when a walk from the pin, crossing series
parts as agni trace does, reaches exactly one pin on another IC or connector. On the Jetson AGX Thor
baseboard, 170 of the module connector’s pins reach exactly one, seven reach several and say which
in a comment, and the rest sit on ground or a supply and are not walked. The CAN line is one of the
170:
- {device: J1, pin: G38, net: /Expansion connector/CAN0.DOUT, to: {device: J18, pin: H08}} # FSI_GPIO07
agni trace designs/jetson --from J1.G38 --to J18.H08 prints the same route, and the row passes
when the draft is checked:
agni check designs/jetson --intent-path j1-pins.yaml --verdicts --format csv --table pin_map
The draft was read off the same netlist, so every row passes by construction, and that run proves
nothing yet about the board. The draft is useful as an inventory. Review each row against the
module’s pinout and the interface it implements, correct or delete what is wrong, and then the map
checks this board and every later revision against something a person signed off. --intent-path
replaces the design’s own intent for the run, so a design that declares other intent carries the
reviewed rows into its design.yaml instead.
A peak joins two tiers. The declaration supplies the demand, which no design
artifact carries, and a seeded datasheet parameter
supplies the regulator’s capacity. Both halves have to be present, or the rule reports nothing
rather than guessing.
One rule per declared thing ¶
Two review items bound to one rule name share its verdict. So anything a reviewer signs off separately gets its own rule (WS3-058), and the naming follows what the sign-off is about.
| Form | Rules it compiles to | Why that grain |
|---|---|---|
modules with nets, sequences, strap_groups |
one per declared entry, named from a slug of it (subsystem-<name>, sequence-<name>, strap-group-<name>) |
each entry is its own checklist item |
modules without nets |
fixed names (module-missing, module-count) |
the review item is the mechanism rather than any one entry |
nets |
one per FACT (voltage-domain-mismatch, protection-<kind>, property-<kind>, rail-current-capacity, rail-current-margin, load-switch-trip-below-budget) |
a reviewer signs off “every rail has its OVP clamp”, not each rail |
io_map |
five fixed names, whatever the map’s length | the exception, since nobody signs off “net 137 is on the right pin” as its own item |
strap_groups also compiles one strap-address-collision rule across all groups, because a
collision is between two groups and belongs to neither. It compiles only when two groups share a bus,
since over fewer it could only pass. rail-current-margin compiles only when the
declaration states a margin_factor, so an item bound to it reads needs-design-intent rather than
passing against a number nobody declared.
With no declaration, nothing passes ¶
There is no built-in intent, and that absence is deliberate. A generic statement of what a board should contain says nothing, and a rule that enumerated its expectations from the design would always agree with the design. So every intent rule iterates the declaration and probes the netlist, never the reverse.
A design run with no declaration leaves its intent-bound review items reading needs-design-intent, never pass. The mechanism exists and is blocked on an input you have not supplied yet. A board that was checked and found clean is a different thing. Checks and reports covers the full outcome vocabulary.
What intent cannot do ¶
It checks presence, property and order, over the netlist. It does not simulate. A declared sequence is verified as a gating chain in the connectivity, not as timing on a scope, so a board that wires the enables correctly and still browns out at power-up is outside what a declaration can see.
It is also only as good as the names. If the declaration calls a rail PMIC_CORE_3V3, the board has
to call it that too. Naming conventions exist to make that same dependency
explicit rather than accidental.
Where to go next ¶
- Checks and reports explains what
needs-design-intentmeans beside the other outcomes, and how--fail-ontreats them. - Interface profiles is the other declarative tier, per-project rather than per-design, for how a bus is wired rather than what a board contains.
- How a rule gets written shows where an intent declaration sits among the four ways to author a rule, and why the engine ships none of its own.