Querying your design

Search your whole design as data with ad-hoc datalog queries, each answer traceable to its source.

A check answers “does my design break this rule?” A query answers a question you make up on the spot: “which nets carry more than 12V?”, “which parts have no datasheet?”, “how many parts sit on each net?”. agni query lets you search your whole design as data, and every answer comes back with its source so you can go check it.

The tool turns your design into a set of simple facts. A net has a voltage, a part sits on a net, a datasheet lists a limit, a copper track has a width. A query is a question over those facts.

The facts you can ask about

Each fact is a named relation with a few fields. You query them by name:

Relation Reads From
net.max_voltage(net, volts) a net’s rail voltage the schematic
component.mpn(ref, mpn) a part’s manufacturer part number the schematic
component.class(ref, class) a device class the part is in (a family tag too: a TVS is both tvs and diode) the schematic
component-on-net(ref, net) a part sits on a net the schematic
param(mpn, symbol, value) a datasheet limit --params (see Datasheets)
reaches(from, net) nets reachable through passives the connectivity
route(from, net, path) the same walk, plus what it crossed to get there the connectivity
board.track_width(net, mm) a net’s thinnest copper track the PCB
board.via_drill(net, mm) a net’s smallest via drill the PCB
board.layer(net, layer) a layer the net is routed on the PCB

The datasheet facts need a parameter set (--params), the board facts need a .kicad_pcb or an IPC-2581 board. Ask for a fact your design doesn’t carry and you simply get no rows. The tool never makes one up.

That table is the short form. Every relation also has a full card covering what it means for hardware, how the projection is built, and the cases where an empty result is not a clean answer. The ones this page leans on are inlined at the bottom of this page; the complete set is the relation catalog.

Writing a query

A query is a list of facts separated by commas, an optional filter, and an optional => that picks which columns to show:

component.mpn(?ref, ?mpn), net.max_voltage(?net, ?v), ?v < 30  =>  ?ref, ?net
  • ?ref, ?mpn, ?net, ?v are variables, blanks the tool fills in.
  • Reusing a variable joins: ?net in two facts means “the same net in both”.
  • ?v < 30 filters. Operators: < <= = != > >=. Text in double quotes: "VIN". Numbers plain.
  • => ?ref, ?net picks the answer columns. Omit it to show every variable.

That is the whole language. The examples below build on it.

Start here: the five-rung ladder

These are the starter queries the panel offers as click-to-run chips (and agni query --examples prints the same set). Each adds exactly one idea over the one before. Run one, read the result, then edit it. Every rung names its concept, with a one-line analogy for the SQL-literate.

1. Every part on every net (projection)

component-on-net(?ref, ?net) => ?ref, ?net

A single fact is already a question. This lists each part and the net it sits on. => picks the answer columns, SQL’s SELECT list, with the relations as the tables.

2. Rails above 3V (filter)

net.max_voltage(?net, ?v), ?v > 3 => ?net, ?v

A bare comparison prunes rows, a WHERE clause. Operators: < <= = != > >=. Numbers plain, text in "quotes".

3. Parts sitting on a rail above 3V (join)

component-on-net(?ref, ?net), net.max_voltage(?net, ?v), ?v > 3 => ?ref, ?net, ?v

Reusing ?net in two facts means “the same net in both”, that is a JOIN ... ON. Joins are how you connect what a part is, where it sits, and what its rail carries.

A query over two relations. The fact base holds component-on-net rows and net.max_voltage rows. Naming the same variable in both facts joins them on the net column, the comparison drops the rows that do not satisfy it, and the arrow picks which columns the answer keeps. THE QUERY THE FACT BASE THE ANSWER component-on-net(?ref, ?net), net.max_voltage(?net, ?v), ?v > 3 => ?ref, ?net, ?v component-on-net net.max_voltage REF NET NET VOLTS REF NET V U1 +24V U1 GND J1 +24V +24V 24 GND 0 U1 +24V 24 J1 +24V 24 the same variable in both facts is a join The comparison drops the GND rows, which joined at 0 V. the file each fact was read from. Every answer row also carries

The two relations are separate tables of facts until a variable appears in both. That shared ?net is what pairs a row on the left with a row on the right, and ?v > 3 then decides which of the paired rows survive.

4. Parts on USB nets (predicate)

component-on-net(?ref, ?net), contains(?net, "USB") => ?ref, ?net

contains is a test over an already-bound value, SQL’s LIKE '%USB%'. prefix/suffix are the anchored variants.

5. Reachable through series pass elements (recursion)

reaches(?from, ?net) => ?from, ?net

reaches walks connectivity transitively through series passives (a resistor, an inductor, a ferrite bead, a fuse), a recursive CTE / transitive closure over the connectivity graph. It answers “what does this rail actually feed after the filter”, which no per-net question can see across a series element.

reach-walk diagram

Where these commands run

Every transcript below is generated by running the command shown, so each one works as written, from the working directory its sample lives in. Three samples appear, all checked into a clone of the engine repo:

Sample Run from What it is
designs/gateway/gateway.edn examples/tutorial-project the tutorial’s synthetic gateway board, with a team’s naming and parameters wrapped around it
regulator.fires.kicad_sch, fires.edn cmd/agni/testdata/conformance small single-purpose boards, each built so one thing has something true to report
board.kicad_pcb readers/kicad/testdata a board export, for the copper facts a netlist cannot answer

Point any of them at your own design instead and the same queries run. Nothing here depends on the sample beyond the names it happens to carry.

Going further

Find something by name

entity(name, kind) is the relation that names what exists. Every other relation ranges over a relationship, so a search built on one inherits its blind spots: looking through component-on-net cannot find a part that sits on no net, because such a part has no row there.

$ agni query designs/gateway/gateway.edn 'entity(?name, ?kind), contains(?name, "CAN") => ?name, ?kind'
name       kind  provenance
CAN1_CANH  net   designs/gateway/gateway.edn:CAN1_CANH
CAN1_CANL  net   designs/gateway/gateway.edn:CAN1_CANL
CAN1_RXD   net   designs/gateway/gateway.edn:CAN1_RXD
CAN1_TXD   net   designs/gateway/gateway.edn:CAN1_TXD

4 result(s)

kind is component, net or bus, so you can scope the search:

$ agni query designs/gateway/gateway.edn 'entity(?name, "component"), prefix(?name, "U") => ?name'
name  provenance
U1    designs/gateway/gateway.edn
U2    designs/gateway/gateway.edn
U3    designs/gateway/gateway.edn
U4    designs/gateway/gateway.edn
U5    designs/gateway/gateway.edn

5 result(s)

The string predicates (contains, prefix, suffix, glob, match) decide how the name is matched. A leading wildcard is the case prefix cannot express, which is how you find every net named for the rail it carries whichever block named it:

$ agni query designs/gateway/gateway.edn 'entity(?name, "net"), glob(?name, "*_1V8") => ?name'
name         provenance
PMIC_IO_1V8  designs/gateway/gateway.edn:PMIC_IO_1V8

1 result(s)

The cases only this relation reaches are the ones worth finding during a review. A part connected to nothing:

$ agni query fires.edn 'entity(?ref, "component"), not component-on-net(?ref, ?any) => ?ref'
ref  provenance
X1   fires.edn

1 result(s)

A net with nothing on it, here against a board that has none:

$ agni query fires.edn 'entity(?net, "net"), not component-on-net(?any, ?net) => ?net'
no results

That empty answer is the answer. Asking for something a design does not carry returns no rows rather than an invention, which is the same property the relation cards spend their space on.

Pins are not in entity, because a pin is named by two things rather than one. Enumerate those with pin(?ref, ?pin), which is also what to reach for when you want to find a pin: the viewer’s find-by- name box searches entity, so it will not turn one up.

In the web viewer this has a front door. The query panel has a Find by name mode: type part of a name, and it writes the same query into the box, runs it, and hands you back the query. Each hit is clickable, whatever sort of thing it turns out to be, so a search lands you on the drawing and leaves a sentence you can edit into a better question.

Reading a result cell in the viewer

A cell that names a component, a net or a bus is a link: clicking it highlights that thing on the drawing. The small chips beside it are the sheets it appears on, one per sheet, and clicking one opens that sheet. They are not a grouping of related names, and they have nothing to do with the cell being a net in particular. Any locatable cell gets them.

AVDD_3V3   [ 04_POWER ] [ 07_SENSOR ] [ 11_MCU ] [ +2 ]

That row says the net AVDD_3V3 is drawn on at least five sheets, three of them named. A single-sheet design shows no chips at all, because there is nowhere to navigate. The strip caps at three and counts the rest, since past a handful the interesting fact is how many sheets a thing spans rather than which; clicking the +N expands it.

Whichever entity is on the drawing right now is marked in the table, and so is the chip for the sheet you are looking at. That is what tells you where you are after a click sends the canvas somewhere, and it follows the viewer rather than your last click here: pick something on the drawing instead and the mark moves to that.

Find parts stressed above their datasheet rating

This joins what the part is (component.mpn), what its datasheet says (param), where it sits (component-on-net), and the rail’s voltage (net.max_voltage), then keeps only the ones where the rated maximum is below the rail.

$ agni query regulator.fires.kicad_sch --params ./params/ 'component.mpn(?r,?m), param(?m,"VIN",?vmax), component-on-net(?r,?n), net.max_voltage(?n,?rail), ?vmax < ?rail => ?r, ?m, ?vmax, ?n, ?rail'
r   m       vmax  n     rail  provenance
U1  LM1117  20    +24V  24    datasheet "SNOS412Q - FEBRUARY 2000 - REVISED JANUARY 2023" page 4, "7.1 Absolute Maximum Ratings" (hand, confidence 1) ; regulator.fires.kicad_sch

1 result(s)

U1 (an LM1117) sits on a +24V rail, but its datasheet caps VIN at 20V. The provenance column cites both sides, the schematic and the datasheet page, so you can open each and confirm.

Find undatasheeted parts (negation)

not keeps the rows where a fact is absent. “Parts on a net that have no MPN” (the component.mpn relation is populated from the datasheet join, so pass --params):

$ agni query regulator.fires.kicad_sch --params ./params/ 'component-on-net(?r,?n), not component.mpn(?r,?m) => ?r'
r   provenance
J1  regulator.fires.kicad_sch

1 result(s)

J1 is placed but carries no part number, so a datasheet can never be matched to it.

A not has to be anchored. At least one variable inside it must also appear in a positive relation, so the negation is about the row rather than about the design. Above, ?r is the anchor: not component.mpn(?r,?m) asks whether THIS part has a part number. The ?m is free on purpose and means “for any value”, which is what you want.

Drop the anchor and the question changes without looking like it has:

entity(?n,"net"), not component.class(?tp,"test_point") => ?n

?tp appears nowhere else, so that asks whether the design contains no test point AT ALL, and on a board that has even one it filters away every row. It used to answer “no results”, which reads as a fact about the board. It is now an error naming the unanchored variable.

To negate a pair of relations, name them first. not takes one relation, so the question that spelling was reaching for, nets with no test point ON THEM, needs both component-on-net and component.class to be true of the same part. Define a relation for that and negate it by name:

has_test_point(?n) :- component-on-net(?tp,?n), component.class(?tp,"test_point");
entity(?n,"net"), not has_test_point(?n) => ?n

Clauses are separated by ;, a clause containing :- defines a relation, and the one clause without one is the question. This is the general shape for any “X with no related Y”, which is most of what a coverage question asks: rails with no decoupling, buses with no pull-up, parts with no protection.

A defined relation is fully derived before anything negates it, so the answer does not depend on the order you wrote the clauses in.

Count parts per net (aggregation)

count, min, max, sum and list summarize. Group by the plain columns. The aggregate reduces the rest:

$ agni query regulator.fires.kicad_sch 'component-on-net(?r,?n) => ?n, count(?r)'
n     count(r)  provenance
+24V  2         regulator.fires.kicad_sch
GND   1         regulator.fires.kicad_sch

2 result(s)

Keep only some groups (having)

A comparison in the question filters facts, one at a time, before there is any group. To ask about the group itself you need having, which runs after the reduce. “Nets carrying more than one part”:

$ agni query regulator.fires.kicad_sch 'component-on-net(?r,?n) => ?n, count(?r) having count(?r) > 1'
n     count(r)  provenance
+24V  2         regulator.fires.kicad_sch

1 result(s)

The aggregate does not have to be a column. Drop count(?r) from the projection and keep the having, and the answer is the nets rather than the tally, which is usually what a coverage question wants:

component-on-net(?r,?n) => ?n having count(?r) > 1

Counting values instead of bindings (distinct)

An aggregate reduces one entry per ANSWER, not per distinct value. A question that joins two things produces one answer per combination, so a part appears once for every partner it was paired with, and count counts all of them. distinct reduces the values instead:

$ agni query regulator.fires.kicad_sch 'component-on-net(?r,?n), component-on-net(?other,?n) => ?n, count(?r), count(distinct ?r), list(distinct ?r)'
n     count(r)  count(distinct r)  list(distinct r)  provenance
+24V  4         2                  J1 U1             regulator.fires.kicad_sch
GND   1         1                  U1                regulator.fires.kicad_sch

2 result(s)

+24V carries two parts. Asking twice about what sits on a net pairs each with each, so there are four answers and count(?r) reports 4. count(distinct ?r) reports the 2 you meant, and list(distinct ?r) names them.

distinct works the same way on every aggregate, list included, so a projection carrying both count(?r) and list(?r) always describes the same set. If you are ever unsure which you have, select both spellings and compare, as above.

The other way to get there is a defined relation, which projects the extra column away before the group forms:

on(?r,?n) :- component-on-net(?r,?n), component-on-net(?other,?n);
on(?r,?n) => ?n, count(?r)

The defined relation keeps ?r and drops ?other, so its tuples are already one per part per net and a plain count matches count(distinct ?r). Drop ?r from the head too and you are counting nets, not parts, which is the mistake this idiom is easiest to make.

Search the board (any tier, one language)

Board facts query the same way. “Nets routed thinner than 0.3 mm”:

$ agni query board.kicad_pcb 'board.track_width(?net,?w), ?w < 0.3 => ?net, ?w'
net  w     provenance
SIG  0.25  board net SIG

1 result(s)

And you can join across the schematic, the datasheet, and the board in one question, “a net routed thin that carries a high-current part”:

board.track_width(?net,?w), component-on-net(?ref,?net), component.mpn(?ref,?mpn), param(?mpn,"IOUT",?i), ?w < 0.25 => ?net, ?ref, ?i

A single query can span copper, connectivity, and datasheets at once, and each answer stays traceable back to the layer and the datasheet page.

Follow a rail through passives (reaches)

reaches(from, net) walks connectivity through series passives (resistors, ferrites, fuses). It is how you ask “what does this rail actually feed after the filter”. “Everything reachable from GND”:

$ agni query board.kicad_pcb 'reaches("GND", ?n) => ?n'
n    provenance
GND  reaches from GND
SIG  reaches from GND

2 result(s)

See what the walk crossed (route)

reaches tells you a net is reachable. It does not tell you what stands between the two, so you end up opening the schematic to check an answer the tool already knew. route(from, net, path) is the same walk with that half kept: path binds the nets in crossing order with the part crossed between each pair, so an answer carries the evidence for itself.

$ agni query board.kicad_pcb 'route("GND", ?n, ?path) => ?n, ?path'
n    path                provenance
GND  GND                 route from GND
SIG  GND -> [R1] -> SIG  route from GND

2 result(s)

Read GND -> [R1] -> SIG as: the walk left GND, went through R1, and arrived at SIG. The names outside the brackets are nets and the one inside is the part. The first row is the reflexive one, since a net reaches itself at zero crossings and its route is its own name.

It answers with a route and not every route. The walk is breadth-first, so where two resistors bridge the same two nets you get the shorter one and no mention of the other. And a route never ends on a rail or a plane, because the walk refuses to enter one at all. For the pin-to-pin form, which does end on a rail and reports the test points sitting on each net along the way, use agni trace.

Asking under your own vocabulary

Some relations do not report what is in the file; they report what the engine believes. rail, feedback, and pin.type are resolved from a vocabulary at the moment the design is read.

That vocabulary is the built-in one unless you say otherwise, and it is anchored on the names most boards use. On a board that names rails function-first, what the built-in vocabulary returns can be badly wrong for your project.

Isolating that takes moving two things aside rather than one, which is itself worth knowing. The tutorial project declares its conventions.yaml, so naming the design applies it automatically and the before-state is otherwise unreachable. And a seeded datasheet’s pin functions establish the rail role on their own, so with params/ in place this board classifies all four rails whatever the naming vocabulary says. Move both and the question is about NAMING alone:

$ mv conventions.yaml conventions-off.yaml
$ mv params params-off
$ agni query designs/gateway/gateway.edn 'rail(?n) => ?n'
n    provenance
GND  designs/gateway/gateway.edn:GND

1 result(s)

Pass your own vocabulary and ask again, with the corpus still aside so the pair differs in exactly one thing:

$ mv params params-off
$ agni query designs/gateway/gateway.edn 'rail(?n) => ?n' --conventions conventions.yaml
n               provenance
GND             designs/gateway/gateway.edn:GND
PMIC_CORE_3V3   designs/gateway/gateway.edn:PMIC_CORE_3V3
PMIC_IO_1V8     designs/gateway/gateway.edn:PMIC_IO_1V8
PMIC_MAIN_12V0  designs/gateway/gateway.edn:PMIC_MAIN_12V0

4 result(s)

Nothing about the design changed. This is the loop to author a lexicon in: ask, compare against the rails you know the board has, fix the pattern, ask again. See Naming conventions.

The datasheet route is the other way to the same answer, and it needs no lexicon at all: that is what the corpus was doing before it was moved aside, and Datasheets is where it lives.

Only the config’s lexicon half is used here, since a query runs no rules.

In the viewer

The viewer has a Query panel that runs the same queries against the design you have open. Type a query, press Run (or ⌘/Ctrl+Enter), and the results appear as a table. Each row has a small toggle that expands its provenance, so the citations stay out of your way until you want them.

The panel runs the query on the server, over the open file, so you get the same answers as agni query without leaving the design. The vocabulary control in the top bar applies here too, and a query and a check in the same session then answer under the same vocabulary.

Datasheet (param) facts are not yet wired into the viewer. A query over param returns nothing there, and datasheet joins stay on the CLI for now.

You do not have to memorize the vocabulary. Below the query box the panel lists every relation as a click-to-insert chip, grouped by kind (Netlist, Board, Datasheet, Predicates, and any extension relations your deployment adds). Clicking a chip drops its template at your cursor, so component-on-net inserts component-on-net(?ref_des, ?net) ready to wire into the rest of the query. Hover a chip to see its full signature and a one-line description.

Every answer is checkable

The last column is always provenance, the source of the facts that produced the row: a schematic file, or a datasheet document, page, and table, or a board net. A query never gives you a number you cannot trace back to its source, so a search you run is one you can verify. In the viewer the provenance rides behind each row’s expand toggle instead of a trailing column.

Taking a view out of the tool

A question you keep asking is a view, and --format is how one leaves the tool.

agni query designs/gateway/gateway.edn \
  'component-on-net(?r,?n), component.class(?r,"test_point") => ?n, ?r' \
  --format markdown --title "Test point coverage" > tp-coverage.md

Five formats. text is the aligned terminal table and is the default. csv opens in a spreadsheet. json is for tooling. markdown and html are documents: they carry the title, the design, and the query itself, above the answer.

That last part is the point of the flag rather than a decoration. A table pasted into a ticket is a screenshot, and nobody downstream can re-run it, scope it differently, or disagree with it. A view carries the question that produced it, so it can be checked the way any other claim can.

Two behaviours worth knowing, both deliberate:

  • csv carries no preamble. No title, no query, no count, because its first row has to be the header for a spreadsheet or a script to bind to it. The document formats carry the question; csv carries the table.
  • An empty result still says something. csv writes the header alone, so a consumer can tell a view that matched nothing from a run that produced no file. markdown and html write a sentence rather than an empty table, because a table with no rows reads as an omission rather than an answer.

The design is named by its mount URI, never by the path on the machine that ran the query, so a view is safe to commit or mail.

agni query designs/gateway/gateway.edn 'rail(?n) => ?n' --format csv > rails.csv

Following a signal across the parts in the way

A query joins facts. It cannot follow a path, because a path is not a fact: it is a sequence, of unknown length, and there is no relation whose columns can hold one. So “which nets carry a resistor” is a query and “what does this pin go through to reach that one” is not.

agni trace is that second question. It walks from one pin to another through the series parts that split a net without breaking the path, and prints what it crossed.

$ agni trace designs/gateway/gateway.edn --from U3.10 --to U1.5
U3.10 (NRST) --> R3 --> U1.5 (PG)

route
  U3.10 (NRST)
  net MCU_NRST
  cross R3 (resistor) pin 1 --> pin 2
  net PMIC_PG
  U1.5 (PG)

routed: 1 crossing, 2 nets, searched to a radius of 6
$ agni trace designs/gateway/gateway.edn --from U1.2 --to U3.2
U1.2 (VOUT) --> U3.2 (VDDIO)

route
  U1.2 (VOUT)
  net PMIC_CORE_3V3
      probe: TP1.1
      also: C1.1 (capacitor), C2.1 (capacitor), U2.1 (ic), U4.1 (ic), U5.1 (ic)
  U3.2 (VDDIO)

routed: 0 crossings, 1 net, searched to a radius of 6
$ agni trace designs/gateway/gateway.edn --from U3.10 --to U1.1
no route: U3.10 (NRST) --> U1.1 (VIN)

  U3.10 (NRST) on net MCU_NRST
  U1.1 (VIN) on net PMIC_MAIN_12V0

no series path from MCU_NRST to PMIC_MAIN_12V0 within 6 crossings.
The walk crosses resistors, inductors, ferrites and fuses.
A capacitor is a DC block and is never crossed.
A rail or plane may END a route and is never passed through.

Three things in that output are the point of the command.

The part in the middle. R3 sits between the MCU’s reset pin and the PMIC’s power-good output, so the two pins are on different nets and no per-net question can see that they are joined. A series element splitting a net is the ordinary case, not an awkward one, which is why “are these connected” is so often answered wrongly by eye.

What else is sitting there. The parts on each net are listed, probe points first, because a reviewer reading a route is usually working out where to put a probe or which capacitor is in the way. Filtering the output down to the series elements would remove the most useful thing on the line.

A no is an answer. The third trace found nothing, and it says which nets the two pins are actually on, how far it searched, and what it will and will not cross. A capacitor is a DC block, so the walk never crosses one; a rail or plane can be where a route ENDS but is never passed through, since a supply joins everything to everything and a route through one would mean nothing.

What the command refuses to do is guess. A pin you name that the design does not have is an error rather than an empty result, because a name spelled wrong in a declaration and two pins that are genuinely unconnected are opposite problems, and reporting the first as the second sends you to look at the board instead of at what you typed.

What a query is not

A query reports, it does not judge. It has no notion of pass/fail, that is what checks are for. If you find yourself running the same query to catch a recurring problem, that is a sign it should become a rule.

Reference: the relations used here

These are the full cards for the relations the examples above use, inlined so you do not have to leave the page. They are the same text the relation catalog serves, and they are generated from the relation definitions themselves, so they cannot drift from what the engine actually projects.

Read the “absence is not a pass” section on any relation you scope a question by. An empty result means the fact is absent from your design, which is a different statement from “nothing matched”, and the cards say which is which.

net.max_voltage, a net's rail voltage

What it is

net.max_voltage(net, volts) yields one row per net that declares a rail voltage, pairing the net name with a number. It is emitted only where the design states a voltage: an explicit max_voltage attribute on the net wins, and otherwise the value is read from the net’s name (a rail named +5V, 3V3, or 12V0 names its own nominal). A net with neither channel produces no row, so the relation is a partial map over nets, not a value for every net.

For hardware engineers

These are the supply rails whose working voltage the netlist actually knows. Most signal nets carry no voltage claim, so they are simply absent here. During a review you query it to see which rails the tool can reason about numerically, and to feed a comparison against a part’s ratings: net.max_voltage is the design-side number the supply-exceeds-abs-max join checks against a datasheet absolute maximum. A rail you expected to appear but does not has a name the extractor could not read as a voltage (or two disagreeing tokens like 12V_TO_5V, which it refuses to guess between).

For software engineers

Think of it as a lookup that resolves a net to a declared constant, with a miss when nothing declares one. It is a projection over Nets(): for each net the extractor tries the explicit attribute first, then the name, and skips the net when both fail. Rows are 1:1 with the nets that have a declared voltage; an empty result means no net in the design states or names a voltage. The value is carried as a number, so a query can range or compare on it without re-parsing the 5V text form.

Go projector

netMaxVoltageFacts in check/facts.go walks Model.Nets() and, for each net, calls railMaxVoltage(n, n.Name) (in check/params.go). That helper returns the explicit max_voltage attribute when present, else the name-derived nominal (nominalVoltageFromName), and reports ok=false when neither yields a number. The projector emits a row only when ok is true, filling both the rendered 5V-style value and the numeric field. One row per net that declares a voltage; empty when no net declares or names one.

Datalog

List every net with a declared voltage:

net.max_voltage(?n, ?v) => ?n, ?v

Find the rails above 3 V and the parts sitting on them:

net.max_voltage(?n, ?v), ?v > 3, component-on-net(?r, ?n) => ?r, ?n, ?v
component.mpn, a part's manufacturer part number

What it is

component.mpn(ref_des, mpn) yields one row per component that resolves to a manufacturer part number: the design-side part identity. The mpn value is the exact orderable part (“BSS138”, “LM1117MPX-3.3”), not a class or a generic value. A component with no resolved part number produces no row.

For hardware engineers

This is the BOM answer for a reference designator: which physical part R7 or U3 will be built as. Two schematics can be electrically identical and place different parts; only the part number distinguishes them, and it is what the datasheet checks join against. You query it to list what a design actually orders, or to find the components a datasheet-backed rule can reach (a component with no part number has no datasheet to check against).

For software engineers

The part number is the lockfile entry (see ANALOGY.md): component-on-net and pin describe the graph structure, component.mpn binds a node to a concrete pinned artifact (lodash@4.17.21). Rows are 1:1 with components that carry a resolved part number, so it is a partial projection over Components() (unresolved components are simply absent). It is the design half of the datasheet join key; the datasheet half is param(mpn, symbol, max), keyed by the same string.

Go projector

componentMPNFacts in check/facts.go walks Model.Components() and emits a row for each component where Model.ComponentMPN(ref) is non-empty. ComponentMPN returns the BomLine part number when a BOM covers the ref-des, else the component’s MPN/mpn attribute, else “”. It never parses the free-text Value field, so the identity is only ever what the design declared. Each reader normalizes its own vendor part-number property into the canonical MPN attribute (OrCAD/Allegro carry it under Manufacturer_PN, which the EDIF reader maps to MPN), so this one relation reads uniformly across formats.

One row per component with a resolved part number. Empty result: the model was built without a params tier. The MPN index is populated only by NewModelWithParams, so the relation is empty unless agni was run with --params (an empty params directory is enough to build the index).

Datalog

Every component and its part number:

component.mpn(?r, ?m) => ?r

Join to the datasheet parameters seeded for that part (the components a datasheet rule can reach):

component.mpn(?r, ?m), param(?m, ?sym, ?max) => ?r
component.class, a device class the part is in

What it is

component.class(ref_des, class) yields the device classes a part belongs to. It emits ONE ROW PER TAG in the part’s class set, not a single most-specific class, so a part with a family tag answers more than once: a TVS diode answers both component.class(D1, "tvs") and component.class(D1, "diode"), an LED answers both led and diode, a ferrite bead answers both ferrite and inductor, and a thermistor answers both thermistor and resistor. The class string is the canonical lowercase name (resistor, capacitor, crystal, …). An unclassified component produces no row (no class is guessed).

For hardware engineers

This is the part’s kind, decided once at ingestion from its designator and library type. The family tags matter because a review often asks a family question, not a specific one: “every diode-family part on this signal” should catch the plain diodes, the LEDs, and the TVS clamps, because electrically they are all diodes. Querying the family tag gives you that set without having to enumerate every subtype.

For software engineers

Think of the class set as an interface hierarchy flattened onto each node: the part carries both its concrete type and every base type it satisfies. That is why the relation is 1:many with a component. Joining on the specific tag (component.class(?r, "tvs")) is instanceof TVS; joining on a family tag (component.class(?r, "diode")) is instanceof Diode and matches every subtype. The set is stamped at the read edge, so the relation is a projection over a precomputed field, not a re-derivation per query. Empty rows for a component mean the classifier had no evidence, distinct from “classified as none”.

Go projector

componentClassFacts in check/facts.go walks Model.Components() and, for each, emits one row per class in Model.Classes(ref) (the full device_classes set: the most-specific class plus its family tags). Model.ComponentClass(ref) returns just the most-specific one; the relation uses the full set on purpose so family joins work. One row per tag; empty for an unclassified component, and empty overall for a design the classifier could not tag.

Datalog

Every component and each class tag it carries:

component.class(?r, ?c) => ?c

Match the whole diode family by its family tag (catches plain diodes, LEDs, and TVS clamps) and report which nets they sit on:

component.class(?r, "diode"), component-on-net(?r, ?n) => ?n

Schematic

A TVS and an LED each carry the diode family tag; a ferrite carries the inductor family tag; each emits one component.class row per tag

component-on-net, a part sits on a net

What it is

component-on-net(ref_des, net) yields one row for each place a component connects to a net: the reference designator and the net name. A component with three pins on three different nets produces three rows; a net with eight parts on it produces eight. This is the netlist’s core adjacency, the link between the two entities every other netlist relation is keyed on.

It is the workhorse join. Most multi-relation queries pass through it to get from a component to its nets or from a net to its components, and the other relations (net.pin_count, net.bus_like, net.max_voltage, component.class) hang off one end or the other of this edge.

For hardware engineers

This is “what is connected to what,” the raw wire list. On its own a row just says R1 touches VBUS. Its value is in the joins: which parts share a rail, whether a connector and a clamp sit on the same signal, what is loaded onto ground. When you want to answer a connectivity question about a specific net or part, you start here and add relations that qualify one side.

For software engineers

This is the many-to-many edge table between components and nets, the adjacency list of the design graph (see ANALOGY.md: a net is a shared channel aliasing pins of many instances). A component maps to many nets and a net to many components, so neither column is unique. It is the natural join key: any query relating a component’s properties to a net’s properties (or vice versa) joins through it, the way you would join two tables through a link table. It is a projection over Nets() and their connection lists, so rows are 1:1 with connections; it is empty only for a design with no connections at all.

Go projector

componentOnNetFacts in check/facts.go walks Model.Nets() and, for each net, emits one row per entry in net.Connections (the component ref as subject, the net name as object). Cardinality is one row per (component, net) connection, so a part appears once per net it lands on and a net once per part on it. Empty only when no net carries any connection.

Datalog

Every component-to-net link:

component-on-net(?r, ?n) => ?r, ?n

Join to component.class to find every diode and the nets it sits on (the shape most rules build on, a property on one entity pulled through the edge to the other):

component-on-net(?r, ?n), component.class(?r, "diode") => ?r, ?n
reaches, nets reachable through passives

What it is

reaches(from, net) is true when net is reachable from from by walking THROUGH series pass elements: resistors, inductors, ferrite beads, and fuses. It is the transitive-closure predicate the protection rules use to answer “is there a component of class X somewhere on the path between these two nets?” without hard-coding a topology.

reaches(from, net, hops) is the same walk with the distance exposed, so a rule states the radius its own question needs. Read the next section before using it: hops is an exact count, not a budget.

Unlike the fact relations, reaches is computed on demand from the design graph rather than stored, so it is a datalog predicate (kind predicate in the catalog), the recursive counterpart to net.bus_like: net.bus_like names the nets the walk refuses to enter, and reaches is the walk itself.

Distance, and the trap in it

hops binds the ACTUAL number of series crossings, reflexive at 0 (a net reaches itself). Because a datalog argument binds by equality, putting a bare number in that slot means exactly that distance:

reaches(?n, ?rn, 2)           # exactly 2 crossings — SKIPS a part sitting 1 away
reaches(?n, ?rn, ?h), ?h <= 2 # within 2 crossings — what a protection question means

The first line is the spelling most people reach for and it is almost never what they want. Use the comparison form for a radius.

Why the radius belongs in the rule at all: the engine holds more than one of them, deliberately. The query built-in searches the whole neighborhood (topologyReachHops), because a topology question like “what is connected to what through passives” wants distant answers. The protection guards ask at check.ProtectionReachHops (2), and the power-entry walk at check.PowerPathReachHops (3), because “is a clamp electrically adjacent to this pin” wants only near ones. A discharge pushes through every series element before the clamp conducts, so a TVS six resistors away protects what is downstream of itself, not the pin. Composing a protection predicate out of the wide default would credit that distant TVS and report a genuinely unprotected pin as clean, which is a false pass rather than a missing result.

For hardware engineers

The walk crosses a two-terminal series part (a resistor, bead, or fuse joins exactly two nets, so crossing it is following the signal one hop along its path) and stops at anything that is not a point-to-point series node: a capacitor (a DC block, the signal does not continue through it), a ground plane or global rail or any rail-scale fan-out (a distribution node, not a path), and any part with more than two nets (a transceiver or connector, not a series element). This is how an ESD or input-protection review asks “does a clamp sit anywhere between this connector pin and the device pin?” while a resistor or bead in the middle of the path does not break the question.

For software engineers

reaches is transitive reachability over a filtered graph: the nodes are nets, an edge exists only through a two-net pass element, and net.bus_like nets are excluded so the traversal cannot leak into a global singleton and mark the whole design reachable. It is a bounded BFS (a hop cap guards pathological depth; fan-out and finiteness bound it anyway), so a query over it terminates.

Go projector

reaches has no stdlib/relations/facts.go projector because it is not a stored fact. The query engine evaluates it as a built-in in core/query/preds.go (bounded by topologyReachHops), delegating to check.Model.Reach in core/check/reach.go and the same IsBusLike stop predicate that net.bus_like exposes. It is the same walk the protection rules run, at a wider radius (see Distance above): same traversal, different question. The distance the third argument binds is Reach.Depth, recorded by the BFS as it goes rather than re-derived from the Parent chain, which can disagree where parallel passes bridge the same two nets. Because it is computed, not projected, it is outside the per-relation EDB doc requirement and is documented here as the reference behind the walk.

Datalog

Every net reachable from a starting net, through series parts:

reaches("VBUS_IN", ?net) => ?net

The components that sit on those reachable nets (what a protection walk would find):

reaches("VBUS_IN", ?net), component-on-net(?r, ?net) => ?r

The same question at a protection radius, the way a rule scoped like esd-protection asks it, over a TVS within two series crossings of the net:

reaches(?n, ?rn, ?h), ?h <= 2, component-on-net(?t, ?rn), component.class(?t, "tvs") => ?n, ?t

How far away each reachable net is, the query to run when a radius is not behaving as expected:

reaches("VBUS_IN", ?net, ?hops) => ?net, ?hops

Schematic

The walk crosses two-pin series parts (R, ferrite, fuse) and stops at a DC-blocking cap or a bus-like net

Where this is going

The hop-radius trap above, the per-caller radius constants, and the baked-in edge class are all symptoms of the same thing: a path question has no way to state itself, so each caller hand-codes a walk. Issue 374 designs a topology-pattern surface where the radius is a quantifier, the edge class is a character class, and a match carries the path it found. If that lands, reaches becomes one canned pattern and this page becomes the migration note.

route, the same walk, with the path it took

What it is

route(from, net, path) is the walk reaches makes, with the route it found bound as a value instead of discarded. It holds for the same pairs reaches(from, net) holds for, and path binds a readable rendering of what the walk crossed to get there:

VBUS -> [R5] -> VBUS_F -> [L1] -> VDD_3V3

The names outside the brackets are nets, in crossing order. The name inside each bracket is the series part the walk passed through between them. A net reaches itself at distance zero, so its route is its own name.

The engine could answer whether two points were connected long before it could show how, which meant a reviewer had no way to check the answer (agni issue 518). This is the query-side half of that: a connectivity answer that carries its own evidence, so a hundred rows can be read rather than re-asked one at a time.

For hardware engineers

A resistor, ferrite or fuse in the middle of a signal splits the net without breaking the connection, so “is this pin joined to that one” is a question about a path across whatever parts sit in the way. reaches answers it. This says which parts those were.

That is usually the part you actually wanted. “VDD_3V3 is reachable from VBUS” is a fact you then go and look up in the schematic; “VBUS -> [R5] -> VBUS_F -> [L1] -> VDD_3V3” is the same fact with the series bead and the sense resistor named, which is enough to decide whether that path is the intended one without opening anything.

Two things it deliberately does not do. A route never ends on a rail or a plane. The walk refuses a bus-like net outright, because a net every part on the board touches joins everything to everything and proves nothing. A capacitor is never crossed, because it is a DC block rather than a pass element, even though it sits on the net and appears in every connection list.

For the pin-to-pin form, which does end on a rail and reports the test points sitting on each net of the route, use agni trace instead. This relation is net-to-net, because that is what a query binds.

For software engineers

A generator over the same filtered graph reaches walks: nodes are nets, an edge exists only through a two-net pass element, and bus-like nets are excluded so the traversal cannot leak into a global singleton. path is a projection of the BFS tree, rendered.

One route per pair, not every route. The walk is a breadth-first search and the path is its tree path, so where two resistors bridge the same two nets the answer names one of them and says nothing about the other. It is a route and the shortest one, never an enumeration.

path is a string, so every query column stays scalar and a route survives into a csv cell, a markdown table and a rule’s finding without anything downstream learning a new type. Binding one and comparing two of them is legal and meaningless, which is equally true of the net names in the first two arguments.

A query cannot tell “no route” from “no such net.” Both are zero rows, which is ordinary datalog and is the right semantics for a rule (a rule asking about VBUS on a board that has none should stay silent, not fail). When that distinction is the thing you need, agni trace keeps the three outcomes apart and exits non-zero on an endpoint that names nothing.

Go projector

None. Like reaches, this is computed on demand from the design graph rather than stored, so it is a datalog predicate (kind predicate in the catalog) rather than an EDB relation with a projector in stdlib/relations/facts.go. The evaluator’s extendRoute (core/query/preds.go) drives check.Model.Reach and renders each answer with Reach.RouteLine, which is the fourth reading of one walk beside PathTo, ThroughOnPath and StepsTo. extendReaches and extendRoute share extendWalk, so the two cannot drift about what is connected.

An empty result means no series path within the walk’s radius, or a from this design does not have. See the note above about which.

Datalog

Everything a rail reaches, with the route to each:

route("VBUS", ?net, ?path) => ?net, ?path

Where a signal ends up and what stands in the way, as a document to save:

route("SPI_CS", ?net, ?path) => ?net, ?path

The route to every net that reaches a regulator’s output, joined to the parts sitting there. The path column is what makes the answer checkable without opening the schematic:

route(?from, ?net, ?path), component-on-net(?ref, ?net), component.class(?ref, "test_point")
  => ?from, ?net, ?ref, ?path

Lead with a bound or constant from wherever you can. An unbound first argument walks from every net on the board, which is the shape GeneratorFirstRules reports and which took one shipped rule from thirteen seconds to not finishing at all.

Where this is going

reaches and route are the same walk asked twice, which is a symptom rather than a design: a path question still has no way to state its own radius or its own edge class, so each caller hand-codes one. Issue 374 designs a topology-pattern surface where the radius is a quantifier and the edge class is a character class, and where a match carries the path it found as a matter of course. If that lands, both of these become canned patterns over it.

board.track_width, a net's thinnest copper track

What it is

board.track_width(net, mm) yields one row per net that has routed copper, giving the net’s MINIMUM track width in millimetres. net is the net name (the join key to ir.Net.name); mm is a number, so a query can compare it directly (?w < 0.2). A net routed with several widths reports only its thinnest segment, not every segment.

For hardware engineers

The narrowest copper on a net sets its worst-case current-carrying capacity, so that is the number a review cares about. A power or ground net that necks down to a thin trace somewhere is a current-density risk even if most of its copper is wide. Query this to find nets routed below a width floor, or to sanity-check that a high-current rail never drops under its intended minimum. The value is the physical track width, so the threshold you compare against is a real millimetre figure.

For software engineers

The routed net is a set of track segments, each with a width. board.track_width is an aggregate projection over that set: a reduce to the minimum width, keyed by net. Reporting the minimum (the safety-relevant extreme) rather than one row per raw segment keeps the relation a compact per-net answer rather than a full segment dump. Rows are 1:1 with nets that have at least one track; a net with pads or vias but no routed track contributes no row. The stored unit is nanometres; the projector converts to millimetres so a query reads a natural threshold.

Go projector

boardFacts in check/facts.go walks Model.BoardNets() and, for each net, calls the helper minSegmentWidthNm(bn.Segments), which returns the smallest Width across the net’s track segments (and a false ok when the net has no segments, in which case no row is emitted). The nanometre minimum is converted with nmToMM and emitted as board.track_width(net, mm) with the numeric value populated for comparison.

The board tier is EMPTY on a netlist-only design. Model.BoardNets() returns nothing unless the design was loaded with board geometry (NewModelWithBoard, fed a .kicad_pcb or IPC-2581 board sidecar). For a query this is silent-by-construction: board.track_width yields zero rows on any design without board geometry, the same posture the datasheet tier takes without --params. A query returning nothing does not mean every track is wide; it can mean the design carries no board at all.

Datalog

Every net and its minimum track width:

board.track_width(?n, ?w) => ?n, ?w

Nets whose thinnest track drops below 0.2 mm:

board.track_width(?n, ?w), ?w < 0.2 => ?n, ?w

Both need a board-bearing design (a .kicad_pcb or an IPC-2581 file); on a netlist-only load they return nothing.

Schematic

A net whose minimum track is wide versus one that necks down to a thin trace