Interface profiles

Declare an interface's signals and what must be true of them, and check enforces that reading wherever the interface appears.

A bus has a fixed structure. CAN is a CANH/CANL pair with a termination resistor across it, driven by a transceiver with TXD and RXD. I2C is SCL and SDA, both pulled up.

An interface profile writes that structure down as YAML, and the engine compiles it into check rules. Agni ships profiles for SPI-NOR, eMMC, SD, CAN, LIN, A2B, PCIe, SGMII and MDIO. Your own interfaces, and your own reading of a standard one, go in a directory you hand to --profile-path. No Go.

Write a profile

I2C is not one of the built-ins, and it makes a short first example. Two signals, both needing a pull-up:

# profiles/i2c.yaml
name: I2C
signals:
  - {name: SCL, suffix: SCL, pullup: true, anchor: true}
  - {name: SDA, suffix: SDA, pullup: true}
requirements:
  - {type: signal-missing}
  - {type: missing-pullup}
  - {type: signal-dangling}

Point check at the directory holding it. --tag profile=I2C narrows the run to this profile’s rules:

$ agni check cmd/agni/testdata/conformance/showcase.fires.kicad_pro --profile-path ./profiles/ --tag profile=I2C
findings by rule:
  profile-overlay/i2c-missing-pullup 1

first 1:
  [warning] profile-overlay/i2c-missing-pullup: /SCL (I2C signal net /SCL needs a pull-up but reaches no rail)

1 finding(s) total
5 subject(s) considered by 3 rule(s) (--verdicts for the detail)

Three rules came out of that declaration, one per requirement, and the last line shows that all three ran rather than one. On the variant of the same board with the pull-ups fitted, they run and report nothing:

$ agni check cmd/agni/testdata/conformance/showcase.passes.kicad_pro --profile-path ./profiles/ --tag profile=I2C
no findings (3 rule(s) run)
5 subject(s) considered by 3 rule(s) (--verdicts for the detail)

Rules from --profile-path are namespaced profile-overlay/, so an overlay rule is never mistaken for a built-in one. A request can send a profile in AnalysisConfig.profile_uris. That profile is namespaced request-profiles/, so a client choosing rules by name asks for request-profiles/<name>-missing-pullup. Asking for profile/<name>-missing-pullup instead runs nothing and reports no error today (#905).

flowchart LR
    subgraph d["i2c.yaml"]
      S["signals<br/>SCL: anchor, pullup<br/>SDA: pullup"]
      R["requirements<br/>signal-missing<br/>missing-pullup<br/>signal-dangling"]
    end
    R --> C["compile,<br/>once at load"]
    C --> R1["profile-overlay/<br/>i2c-signal-missing"]
    C --> R2["profile-overlay/<br/>i2c-missing-pullup"]
    C --> R3["profile-overlay/<br/>i2c-signal-dangling"]
    R1 --> D["each rule binds<br/>its signals to nets<br/>by net name"]
    R2 --> D
    R3 --> D
    S -.->|"the matchers"| D

The requirements decide how many rules there are. The signals decide what each one looks at.

The shape of the file

key what it is
name the profile’s name. It appears in rule names, in every finding’s message, and as the profile tag.
signals the interface’s roles, each with a name (the role, e.g. SCL) and one matcher.
requirements the profile’s checks, each naming a registered type.
host optional. Binds the interface to the component that declares it.
instance optional. anchor_stem judges each bus of the interface on its own, as described in One bus at a time.
excludes optional, and only with instance. Signals whose net on a bus says that bus is some other interface.

host takes {attr: <key>, value: <val>} to match a component attribute in the design (interface=CAN), or {class: <device-class>} to match what a part’s datasheet says it is. Either or both, and a component matching either one is a host. The class form needs a seeded parameter set, so without --params it finds nothing rather than guessing.

The four matcher forms

A signal is found by net name, and declares exactly one of these:

form example when to use it
suffix suffix: _CANH the readable default. The role is the tail of the net name.
prefix + suffix prefix: PCIE_, suffix: _TXP the tail is shared with a foreign bus, and a bus prefix tells them apart.
glob glob: ETH_SW*_A_H the identity is in the head of the name and the tail is not distinctive.
regex regex: ^ETH_SW\d+_P\d+_.*_H$ multi-instance naming a glob cannot express. RE2, not auto-anchored, so anchor it yourself.

A glob follows the same SQLite GLOB rules as str.glob in a query: * and ? as usual, and [...] a character class, so a literal [ in a net name is written [[] (see Querying).

Prefix and suffix are conjunctive and count as one form together. Declaring two forms on one signal is an error, because a signal that can be found two ways has no single convention to enforce.

Requirements

type what it checks params
signal-missing every declared signal appears on a bus that is in use
host-incomplete a declared host is wired to all of the interface’s signals
missing-pullup a signal marked pullup: true reaches a rail
signal-dangling a signal net has at least two connections
termination a terminating device bridges the bus pair high, low
esd a signal leaving through a connector has an ESD clamp

Only termination takes params. They name the two bridged net-name suffixes. The shipped CAN profile is the worked example:

# stdlib/profiles/builtins/can.yaml
name: CAN
host: {attr: interface, value: CAN}
signals:
  - {name: CANH, suffix: _CANH, anchor: true}
  - {name: CANL, suffix: _CANL}
  - {name: TXD,  suffix: _TXD}
  - {name: RXD,  suffix: _RXD}
requirements:
  - {type: signal-missing}
  - {type: host-incomplete}
  - {type: termination, params: {high: _CANH, low: _CANL}}
  - {type: signal-dangling}
  - {type: esd}

Those files are the built-ins themselves, not copies of them, so they are safe to read as examples.

A profile with no host, and signals that differ from each other

Suppose you check exported boards and nobody is going to annotate them for you. Then copy the shipped MDIO profile. It declares no host: at all.

name: MDIO
signals:
  - {name: MDIO, regex: '(?i)(^|[^A-Z])MDIO([^A-Z]|$)', pullup: true, anchor: true}
  - {name: MDC,  regex: '(?i)(^|[^A-Z])MDC([^A-Z]|$)'}
requirements:
  - {type: signal-missing}
  - {type: missing-pullup}
  - {type: signal-dangling}

missing-pullup and signal-dangling gate on the in-use test below, not on a host part. That test asks for two distinct signals matching by name. So this profile evaluates on any board whose nets are named conventionally, with nothing asked of the design.

MDIO carries pullup and MDC does not, because they are not the same kind of line. MDIO is bidirectional and open-drain, undriven between frames and through every turnaround cycle, so a resistor holds it high and IEEE 802.3 clause 22 calls for one. MDC is a clock sourced by the station side, push-pull, driven whenever it matters. Requiring a pull-up on it would report a failure on every correctly-built board that omits one.

The same holds beyond Ethernet. pullup belongs on the signals that float when nobody is driving, not on every signal of a bus that has a pull-up somewhere. If you mark a whole bus, the profile starts producing failures, and a reviewer has to learn to ignore them. A checklist whose failures are routinely ignored is worse than no checklist.

The pattern also shows why a signal’s match is bounded. MDCLK opens with the letters MDC and is an unrelated clock, so both patterns require a non-letter on each side of the match. RE2 has no lookaround, so the boundary is written as explicit character classes.

Why MDIO is a profile and not a wider built-in

The engine had one pull-up rule, and it answered one bus family by name. Its pattern matched SDA and SCL at a token boundary, and nothing else looked at a management-interface name. Take a board with no pull-up on either line of its PHY bus. That rule reported nothing for it, though that failure is the rule’s entire subject (agni issue 516). MDIO asks the same electrical question as I2C. Both lines idle high only because a resistor returns them there, and both sit undriven between transactions.

If the built-in rule’s pattern were widened as well, two rules would report the same net, so the coverage is in this profile as data instead. That leaves one gap, the unresolved half of issue 516. The built-in I2C rule proves a pass with the resistor and rail it walked through, while a profile requirement’s finding names only the net. A reviewer asking “show me the pull-up” gets a path on I2C and a bare net name on MDIO.

The anchor, and when a profile decides it is looking at your board

A profile that finds none of its signals has to report nothing. A board with no CAN on it is not a board with a broken CAN bus. Two gates decide this, and both have to clear before the completeness check runs.

The in-use gate wants at least two distinct signals of the profile to appear as nets. One match is not evidence, because a real design has many _CS nets belonging to many things.

You mark one signal anchor: true, and that signal is the anchor. It is always present when the interface is. Completeness is reported against it, so a finding reads CAN interface (anchored at net CAN_CANH) is missing required signal RXD rather than naming a net that is not there. At most one signal may be the anchor, and a profile that declares none generates no completeness rule at all.

flowchart TB
    A["every signal matched<br/>against every net name"] --> B{"two or more distinct<br/>signals matched?"}
    B -->|no| S["silent: no interface<br/>on this board"]
    B -->|yes| C{"an anchor declared,<br/>and its net present?"}
    C -->|no| E["pull-up and dangling<br/>rules evaluate, but no<br/>completeness finding"]
    C -->|yes| F["those, plus missing<br/>signals reported<br/>against the anchor net"]

One bus at a time

Without an instance key a profile treats every matching net on the design as one interface. That is fine when a board carries a bus once. It goes wrong when a board carries two buses that share suffixes, because each lends the other its signals. An SD card slot next to an eMMC is the usual case on a SoC board, and an unused M.2 SMBus clock ending _CLK used to read as the eMMC’s clock.

Under instance: anchor_stem, the anchor’s matcher finds nets, and each of those nets founds its own bus. The bus is named by the anchor’s net name less the anchor’s suffix, so SD2_CMD founds bus SD2. A signal belongs to the bus when its net is named from that stem. For a suffix signal that means exactly the stem and the suffix (SD2_CLK). For a glob or regex signal it means the net matches and starts with the stem and the anchor suffix’s first character, so bus SD never takes SD2_DATA0. Then each of these runs per bus: the in-use gate, completeness, the pull-up and dangling checks, and the coverage panel. A missing signal is reported against that bus’s anchor.

excludes lists signals whose presence on a bus says it is a different interface. The shipped eMMC profile excludes a card detect line, because a removable SD card shares eMMC’s CMD, CLK and DAT names but has four data lines and no reset. Judging it as an eMMC would report a list of missing signals that an SD card never has:

# stdlib/profiles/builtins/emmc.yaml
instance: anchor_stem
signals:
  - {name: CMD,  suffix: _CMD, pullup: true, anchor: true}
  - {name: DAT0, regex: '_DATA?0$'}
  # ...
excludes:
  - {name: CD, regex: '_(CD|CD_B|CD_N|CDn|nCD|DET)$'}

A bus carrying an excluded signal is not in use, so nothing on it is required or judged, and a review item bound to the profile reads it as absent. The shipped SD profile is the mirror image. It excludes an eighth data line and a reset, since those say a bus is an eMMC. So the two profiles split one naming convention between them, and each judges its own bus. An instance key needs an anchor declared by suffix, since the stem comes from taking that suffix off.

Every requirement runs per bus under an instance key. termination asks each bus’s own high net to reach that bus’s low net, so one bus’s terminator no longer clears another, and esd judges an exposed line only when it is on a bus in use. A review item bound to the profile still reads every bus in use together, as DECISIONS.md records.

Re-bind a built-in instead of re-writing it

When the engine already reads your interface and only the net naming differs, a naming map re-binds the roles and leaves the structure alone:

# profiles/can.yaml
override: CAN
suffixes:
  CANH: _CAN_P
  CANL: _CAN_N

Unmapped roles keep the built-in’s suffix. Requirements, host binding and anchor come across unchanged. A profile carrying a built-in’s name replaces that built-in’s rules rather than running beside it, and the CLI says so on stderr:

note: profile-overlay supersedes 5 rule(s): profile/can-signal-missing, profile/can-host-incomplete, profile/can-termination-missing, profile/can-signal-dangling, profile/can-esd-missing

If two readings of one interface ran together, they would invent failures rather than merely duplicate them, since the built-in still anchors on its own naming and reports each re-bound role as missing. Extending and embedding the engine covers the mechanism.

Where the flag reaches

--profile-path takes a directory of these YAML files, and three surfaces accept it: agni check, agni review, and agni serve, where the composed profiles reach the web check panel too. The interface-coverage matrix in the viewer is the exception. It projects the built-in profiles only, so an overlay profile’s findings appear while its coverage row does not.

What the tooling tells you when the declaration is wrong

A profile that cannot do what it says is refused at load, with the error naming what was available.

The four load-time refusals, verbatim

A key missing from the file’s schema. The schema is the ProfileDef message in protos/agni/v1/checks/ruledef.proto, so a misspelled key fails with its line rather than leaving the signal without the requirement it was meant to carry:

error: profiles: i2c.yaml: profile: line 3: unknown key "pull_up" in "signals[0]" (known: anchor, glob, name, prefix, pullup, regex, suffix)

An unregistered requirement type:

error: profiles: i2c.yaml: profile "I2C": unknown requirement type "pullup" (known: esd, host-incomplete, missing-pullup, signal-dangling, signal-missing, termination)

A requirement missing a param it cannot work without:

error: profiles: mybus.yaml: profile "MYBUS": requirement "termination": needs the low param, naming the two bridged net-name suffixes (e.g. high: _CANH, low: _CANL); got map[high:_H]

A signal declaring more than one matcher form:

error: profiles: mybus.yaml: profile "MYBUS": signal "H" declares 2 matcher forms: set exactly one of suffix/prefix, glob, or regex

Load cannot catch a matcher that is merely too loose, because whether _H names one role or half the board depends on the board. check judges that against the design in front of it. A profile whose signals claim an implausible share of the nets:

warning: profile "ETH100": signal "AP" (regex "_H") matches 8 of 32 nets (25%) — the matcher looks too broad to name one role; narrow it with a prefix, a glob, or an anchored regex

And a profile that cannot tell two of its own roles apart:

warning: profile "MYBUS": signals "DATA" and "CLK" both match net "CAN_CANH" — the profile cannot tell these two roles apart, so whichever rule runs first claims the net

Both describe the profile you wrote, not the board, so neither is a finding. They go to stderr, they never change the exit code, and they are left out of --format json.

What a profile cannot do

A suffix-named profile only fires on designs that follow that naming. Rename the nets and the interface goes invisible, silently. host binding exists to stop that, because a component that declares interface=CAN anchors the check no matter what the nets are called. When the naming is merely different rather than absent, a naming map is the cheaper fix.

Where to go next

  • Checks and reports shows that profile findings read like any other, and --fail-on can gate on them.
  • Extending and embedding the engine covers shipping profiles alongside private readers and rules in your own module.
  • CLI reference documents the --profile-path flag.
  • How a rule gets written explains where a profile sits among the four ways to author a rule, and why the shipped profiles use this same YAML format rather than a layer over something else.