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.net(ref, net) a part sits on a net the schematic
param.max(mpn, symbol, value) a datasheet limit --params (see Datasheets)
net.reaches(from, net) nets reachable through passives the connectivity
net.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), and 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. This page inlines the cards for the relations it uses at the bottom of this page; the complete set is the relation catalog.

A name before a dot is a module and the part after it is a member: net.pin_count is the pin_count member of the net module. No name is both, so these relations moved into modules (agni issue 751). A query saved before the move names them the old way and now gets “unknown relation”:

Was Now
component-on-net component.net
pin component.pin
param param.max
reaches, route net.reaches, net.route
rail, feedback, switching net.rail, net.feedback, net.switching
external_signal_net net.connector_signal
has_nc_channel, types_power_out, has_netclass, has_netclass_defs design. plus the same name
pin_net_conflict, ref_des_collision, unresolved_symbol reader. plus the same name
contains, prefix, suffix, glob, match str. plus the same name

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", taken exactly as written, since a string has no escapes. 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.

A query that does not parse says where it stopped. agni query prints the line with a caret under the place, and the viewer’s query panel shows the same under its error:

error: invalid argument: query: variable ?r ?n: a variable's name is letters, digits and _
  component.net(?r ?n) => ?r
                ^

The five-rung ladder

The panel offers these starter queries 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.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.net(?ref, ?net), net.max_voltage(?net, ?v), ?v > 3 => ?ref, ?net, ?v

Reusing ?net in two facts means “the same net in both”. In SQL that is a JOIN ... ON. A join connects 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 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.net(?ref, ?net), str.contains(?net, "USB") => ?ref, ?net

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

5. Reachable through series pass elements (recursion)

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

net.reaches walks connectivity transitively through series passives (a resistor, an inductor, a ferrite bead, a fuse). In SQL it is a recursive CTE / transitive closure over the connectivity graph. It answers “what does this rail actually feed after the filter”. 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 when you run it from its sample’s working directory. 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) names everything that exists. Every other relation ranges over a relationship, so a search built on one inherits its blind spots. A part that sits on no net has no row in component.net, so a search through component.net cannot find it.

$ agni query designs/gateway/gateway.edn 'entity(?name, ?kind), str.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"), str.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)

One of the string predicates matches the name: str.contains, str.prefix, str.suffix, str.glob or str.match. str.prefix cannot express a leading wildcard. A leading wildcard finds every net named for the rail it carries, whichever block named it:

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

1 result(s)

str.glob follows SQLite’s GLOB: * is any run of characters (including /, so a hierarchical name matches), ? is one character, and [...] is a character class, so DATA[0-3] matches DATA0 to DATA3. That makes a literal bracket special. A bus name such as DATA[1:0] is matched with the bracket inside a class, DATA[[]1:0]. Before jaala v0.1.8 a [ was literal, so a pattern written for a bracketed name before then now means something else.

The string functions build a name as well as test one. Each puts its result last, so it reads as an expression (?l = str.replace(?h, "CANH", "CANL")). They are str.concat, str.replace, str.lower, str.upper, str.capitalize and str.split. str.concat runs from any two of its three arguments. Given the whole name and its tail it binds the head, and holds for nothing when the name does not end that way. That form pairs the two halves of a differential pair by their shared base name:

$ agni query designs/gateway/gateway.edn 'entity(?h, "net"), str.concat(?base, "H", ?h), str.concat(?base, "L", ?l), entity(?l, "net") => ?base, ?h, ?l'
base      h          l          provenance
CAN1_CAN  CAN1_CANH  CAN1_CANL  designs/gateway/gateway.edn:CAN1_CANH ; designs/gateway/gateway.edn:CAN1_CANL

1 result(s)

str.split gives one row per part, or only the part at an index you bind, where a negative index counts from the end.

Only this relation reaches two cases worth finding during a review. The first is a part connected to nothing:

$ agni query fires.edn 'entity(?ref, "component"), not component.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.net(?any, ?net) => ?net'
no results

That empty answer is the answer. Ask for something the design does not carry and you get no rows rather than an invention. The relation cards spend their space on the same property.

Pins are not in entity, because a pin is named by two things rather than one. Enumerate pins with component.pin(?ref, ?pin), and use the same relation to find a pin. The viewer’s find-by-name box searches entity, so it will not turn one up.

The web viewer runs this search for you. The query panel has a Find by name mode. Type part of a name, and the panel writes the same query into the box with your text bound to its ?pattern. It runs the query and hands you back the query and the binding. Each hit is clickable, whatever sort of thing it turns out to be. So a search takes you to the drawing, and it leaves the query in the box for you to edit into a better question.

Reading a result cell in the viewer

A cell is a link when it names a component, a net or a bus, and 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. Past a handful, the interesting fact is how many sheets a thing spans rather than which. Clicking the +N expands it.

The table marks whichever entity is on the drawing right now, and it marks the chip for the sheet you are looking at. After a click sends the canvas somewhere, those marks tell you where you are. They follow the viewer rather than your last click here, so if you pick something on the drawing instead, 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.max), where it sits (component.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.max(?m,"VIN",?vmax), component.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. The datasheet join populates component.mpn, so pass --params to ask for “parts on a net that have no MPN”:

$ agni query regulator.fires.kicad_sch --params ./params/ 'component.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, so not component.mpn(?r,?m) asks whether THIS part has a part number. The ?m is free on purpose and means “for any value”.

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. That spelling was reaching for nets with no test point ON THEM, and that question needs both component.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.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 form for any “X with no related Y”. Most coverage questions take that form: 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.

Ask what a net IS (roles)

net.role gives you what the engine worked out about a net from its name. A net can carry more than one, so the relation yields a row per role:

net.role(?n, ?r) => ?n, ?r

Four of the six roles exist to say named after a rail, and not one. A regulator’s pins are conventionally named for the supply they serve, so a 12V converter’s nodes are 12V_FB, 12V_SW, 12V_MODE1 and 12V_VDRV, none of which carries 12V. Listing them is one clause:

net.role(?n, ?r), ?r != "rail", ?r != "ground" => ?n, ?r

net.rail(?n) and net.role(?n, "rail") are different sets. net.rail is a conclusion, true for a net that is asserted-driven or global or a ground or carries the rail role. net.role is what the naming lexicon stamped. On a board with a few regulators the second is the larger of the two, and a probe-point or decoupling question almost always wants the first.

Each role’s vocabulary is config, and you extend it in your conventions.

Ask what the design DECLARED about a net (attributes)

net.role is what the engine inferred. net.attr is what the file said:

net.attr(?n, "external", "true") => ?n

external marks a net whose full connectivity was not read, because it continues onto a sheet the read did not cover. Most connectivity rules exclude those rather than report a one-pin net that is an artefact of what was opened. It is the net-side twin of component.attr.

Count parts per net (aggregation)

count, min, max, sum, avg and list summarize. Group by the plain columns. The aggregate reduces the rest. list answers a list value, written as its JSON text (["R1","R2"]), so a script reading a cell parses it rather than splitting on spaces:

$ agni query regulator.fires.kicad_sch 'component.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)

With no plain column, the answer is always one row. The whole result is then a single group, and it exists even when nothing matched, so a count of something the design lacks answers 0 rather than “no results”:

net.ground(?n), component.net(?tp,?n), component.class(?tp,"test_point") => count(distinct ?tp)

count and sum over nothing are 0 and list is the empty list []. min, max and avg have no number to report, so their cells are absent. A projection that does group, such as => ?n, count(?r), still answers nothing when nothing matched, because there is no net to name a group after.

So a grouped count never lists the nets with nothing to count. Most reviews want that coverage table for test points, and the library member net.test_point_count gives every net a row and a 0 where it has none, so the uncovered nets sort to the top:

$ agni query netlist-audit.tel 'net.test_point_count(?n, ?c) => ?n, ?c order by ?c, ?n'
n         c  provenance
I2C_SCL   0  netlist-audit.tel
MCU_NRST  0  netlist-audit.tel
GND       1  netlist-audit.tel
I2C_SDA   1  netlist-audit.tel
VCC_3V3   1  netlist-audit.tel

5 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.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. A coverage question usually wants the nets:

component.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.net(?r,?n), component.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.

A defined relation also gets there. It projects the extra column away before the group forms:

on(?r,?n) :- component.net(?r,?n), component.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. That is the easiest mistake to make with this idiom.

Count inside a clause (body aggregates)

A grouped count leaves out every group with nothing to count. A body aggregate counts inside the clause instead. The rest of the clause gives the shared variable its values, and the aggregate counts once for each, so the empty groups answer 0. Every net with the number of test points on it:

entity(?n, "net"), ?c = count(distinct ?tp) : { component.net(?tp, ?n), component.class(?tp, "test_point") } => ?n, ?c

The braces share a variable with the clause (?n here). A relation outside the braces has to bind it, as well as one inside them. net.test_point_count in the shipped library is this clause.

Keep a row when part of it is missing (optional)

optional { ... } keeps the row whether or not the braces match. Where they do not match, a variable bound only inside the braces is absent. Every resistor with its part number, and the resistors that have none still listed:

component.class(?r, "resistor"), optional { component.mpn(?r, ?m) } => ?r, ?m

Without optional, a resistor with no part number would drop out of the answer. That answer reads the same as a design with no such resistor.

Sort and page an answer (order by, limit)

order by sorts by projected columns, desc reverses one, and limit keeps the first rows. With an order by, the evaluation holds only the page while it runs, so asking for the ten busiest nets on a large board does not build the whole answer first:

net.pin_count(?n, ?c) => ?n, ?c order by ?c desc, ?n limit 10

offset skips rows and after continues from the last row of the page before, by value. Both need an order by.

A limited answer says how many rows it left out, as 10 of 1617 result(s), 1607 left out by the limit under the table and as total in --format json. On the command line --limit 10 caps an answer without editing the query, and so does limit on the RunQuery request. Where the query writes its own limit, the smaller applies. Counting the total solves the whole goal, so a limit saves keeping, locating and sending the rows it leaves out, and does not save the solving.

Units

A voltage carries volts and a board width carries metres, shown in millimetres. When all of a relation’s numbers share a unit, the relation declares it. Comparing two different units is refused before the query runs, since volts are never less than metres:

net.max_voltage(?n, ?v), board.track_width(?n, ?w), ?v < ?w => ?n

A datasheet parameter’s unit varies from row to row, so its relations declare none and each value keeps its own. An aggregate can mix units, such as sum over one part’s limits in volts and amps. It then answers absent, and the cell says why (absent: mixed units: A, V). num.value and num.unit take a number apart when a question really does mean the bare number.

A constant can carry its unit, so ?v >= 3.3V and ?v > 3000mV ask the same thing, since a prefix is folded into the base unit before anything compares. A constant in the wrong unit is refused the same way a relation’s is, and ?v > 3mm against a voltage fails with compares number[V] with number[m], which never holds.

Arithmetic

A comparison can compute. +, -, * and / work on numbers, with parentheses and unary minus, and ?x = expression binds a new variable to the result:

net.max_voltage(?n, ?v), ?mv = ?v * 1000 => ?n, ?mv order by ?mv desc

An expression can sit on either side of any comparison, so net.pin_count(?n, ?c), ?c * 2 > 20 => ?n needs no variable of its own. Each operator is a function relation underneath (num.mul for *, and so on), and those run in whichever direction their bound values allow, so ?v = ?i * ?r finds whichever of the three is missing. For the same reason, it doesn’t matter in which order an expression is written.

A one-rule relation whose head ends in its result works as a function, called with that last argument left off:

headroom(?v, ?h) :- ?h = 5V - ?v; net.max_voltage(?n, ?v), ?h = headroom(?v), ?h > 0V => ?n, ?h

The same rule still works as a relation, headroom(?v, ?h).

An expression can also stand in the projection, where its column is named by the expression’s own text without the ?s:

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

answers in columns n and v * 1000. And it can stand in a rule’s head. There it computes the value as ?y = expression in the body would:

margin(?n, 5V - ?v) :- net.max_voltage(?n, ?v); margin(?n, ?m), ?m > 3V => ?n, ?m

A computed number compares as the constant it looks like, so 5V - 3.3V shows as 1.7 and net.max_voltage(?n, ?v), 5V - ?v = 1.7V finds the 3.3 V nets (fixed in panyam/jaala#216).

Use the shipped library (derived relations)

Some questions come up often enough that agni ships them as relations of their own, defined in Datalog over the base relations rather than projected from the design in Go. They sit in the same modules as the relations they extend, so a query calls them the same way:

component.probed_one(?part, ?probed, ?unprobed), component.mpn(?part, ?mpn) => ?mpn, ?part, ?unprobed
Member What it answers
net.has_test_point(net) a net at least one test point sits on
component.two_terminal(part, a, b) a part on exactly two nets, the nets in name order so each part answers once
component.probed_both(part) a two-terminal part with a test point on both nets, so an in-circuit tester can measure it
component.probed_one(part, probed, unprobed) a two-terminal part probed on one net only, naming the net that is missing a test point

agni query --relations prints everything a query can call, module by module, with each member’s argument types. Give it a module to list one (agni query --relations net), or a member to read its definition:

agni query --relations component.probed_both
component.probed_both(r: component)
  derived, defined in module component

  A two-terminal part with a test point on both of its nets, so it can be measured in circuit.

  defined as
    probed_both(?r: component) :- two_terminal(?r, ?a, ?b), net.has_test_point(?a), net.has_test_point(?b)

Each member’s page says what it counts and leaves out, why the question matters on a board, and how it is defined. The library lives in stdlib/lib, one file per module, and Adding a library member covers when a question belongs there. If only one report asks a question, the question belongs in that report’s query instead, as a rule ahead of its goal or in a query set’s preamble.

A project can carry its own library too, in a lib/ directory beside project.yaml, laid out the same way. The tutorial project’s lib/house.dl names the PMIC’s rails after that team’s PMIC_ convention, and a query on its board calls them like any other relation. Of the three, one has no test point:

$ agni query designs/gateway/gateway.edn 'house.pmic_rail(?n), not house.pmic_probe_point(?n) => ?n'
n               provenance
PMIC_MAIN_12V0  designs/gateway/gateway.edn:PMIC_MAIN_12V0

1 result(s)

The members exist only inside that project. agni query --relations house --design <design> lists them with their definitions, and Adding a library member covers the rules a project library follows.

See how a row was derived (witnesses)

A row’s provenance names the facts behind it, and for a query over library members that is not the whole reason. PMIC_MAIN_12V0 answers the query above because house.pmic_rail holds for it and house.pmic_probe_point does not, and neither is a fact the design states. --witnesses asks the engine to keep how each row was derived and prints it under the row:

$ agni query designs/gateway/gateway.edn 'house.pmic_rail(?n), not house.pmic_probe_point(?n) => ?n' --witnesses
n               provenance
PMIC_MAIN_12V0  designs/gateway/gateway.edn:PMIC_MAIN_12V0
    house.pmic_rail(PMIC_MAIN_12V0)  via house.pmic_rail(?n: net) :- net.rail(?n), str.prefix(?n, "PMIC_")
      net.rail(PMIC_MAIN_12V0)  [designs/gateway/gateway.edn:PMIC_MAIN_12V0]
      str.prefix(PMIC_MAIN_12V0, PMIC_)
    not house.pmic_probe_point(PMIC_MAIN_12V0)  (absent)

1 result(s)

Each line is one fact, and the lines under it are what proved it. When a member derived a fact, its line names the member’s rule after via, with each of its body facts filled in, in the order the rule is written. A fact from the design ends in its citation. A not that held reads not ... (absent), because it proves that something is missing and so has nothing to cite. A predicate such as str.prefix gets a line, and a comparison such as ?a < ?b does not, since the rule beside it already states it.

Witnesses cost nothing unless asked for. The engine keeps a derivation per derived fact only on a witnessed query, and a query without the flag runs exactly as it did, at the same work. An aggregate row has none, because a count merges many derivations into one number.

text and html print the tree under its row, markdown after the table under each row’s number, and json as each row’s witnesses, whose citations index the answer’s sources as a row’s do. csv refuses the flag, since a csv’s first row has to be its header and every other row a row of the table. --set asks every query in the set for witnesses. Over the API it is RunQueryRequest.witnesses (and RunQueriesRequest.witnesses).

A checklist item written as an inline query opens the same way in the viewer’s Review panel. The arrow beside one of its findings asks the item’s query again with the finding’s subject bound and witnesses on, and draws the tree under the finding. The run keeps the checklist it scored, and the query comes from that copy. So an older run asks its own question and not whatever the checklist file says today. A run older than the open design can name a finding that no longer holds, and the panel says so rather than drawing nothing. A finding from a rule, a tag or a profile has no arrow yet (agni issue 1183).

Ask the same question about different things (bindings)

Say a script asks about one part, then another. It should not build a new query string each time. Write the query once with a variable where the part goes, and bind the variable to a value with --bind name=value:

$ agni query designs/gateway/gateway.edn 'component.net(?ref, ?net), net.pin_count(?net, ?fanout) => ?net, ?fanout' --bind ref=U1
net             fanout  provenance
GND             13      designs/gateway/gateway.edn:GND
PMIC_CORE_3V3   8       designs/gateway/gateway.edn:PMIC_CORE_3V3
PMIC_EN         2       designs/gateway/gateway.edn:PMIC_EN
PMIC_MAIN_12V0  4       designs/gateway/gateway.edn:PMIC_MAIN_12V0
PMIC_PG         2       designs/gateway/gateway.edn:PMIC_PG

5 result(s)

A bound variable answers exactly as the same value written into the query as a constant, so the run above is component.net("U1", ?net), ... without the quoting. Each position declares a type, and a value is read as that type. A voltage, a count or a datasheet limit is a number, so --bind min=3 and --bind 'min="3"' both compare as 3, and --bind min=abc is refused as not a number. Where nothing in the query types the variable, as in ?a < ?b alone, a value that parses as a number binds a number and a quoted one (--bind 'a="3"') binds the text. --bind repeats, one per variable. Naming a variable the query does not use is an error, so a typo fails rather than leaving the variable free and widening the question.

Naming one variable twice binds it to a list, and the query answers for every value in it, as if the goal were joined with a relation holding exactly those values. So --bind n=I2C_SDA --bind n=I2C_SCL asks about both nets in one answer, an aggregate counts across both, and the document view states the binding as ?n in ["I2C_SDA", "I2C_SCL"].

A program sends bindings rather than building text, so a value needs no escaping and a name holding a quote is asked about exactly. Over the API they are RunQueryRequest.bindings, and the Python client builds them with agni.bindings({"ref": "U1"}), or agni.bindings({"ref": ["U1", "U2"]}) for a list, which travels as QueryValue.list. A viewer link carries a list as its bind value repeated, once per value, and opens with the list as one chip. The --format markdown and html views print them under the query, because the query text alone no longer states the whole question.

Ask many questions at once (query sets)

An audit is usually a workbook rather than a single question: which nets have no test point, how many ground test points there are, which passives a tester can measure. A query set keeps those questions in one YAML file, with their shared derived relations written once as a preamble, and agni query --set answers all of them over ONE read of the design:

title: Test point audit
preamble: |
    has_tp(?n) :- component.net(?tp, ?n), component.class(?tp, "test_point");
queries:
  - name: Net count
    query: entity(?net, "net") => count(?net)
  - name: Nets with no test point
    query: entity(?net, "net"), not has_tp(?net) => ?net
  - name: Ground test points
    query: net.ground(?net), component.net(?tp, ?net), component.class(?tp, "test_point") => count(distinct ?tp)
$ agni query netlist-audit.tel --set audit.yaml
Test point audit
mount://files/netlist-audit.tel

== Net count ==
count(net)  provenance
5           netlist-audit.tel

1 result(s)

== Nets with no test point ==
net       provenance
I2C_SCL   netlist-audit.tel
MCU_NRST  netlist-audit.tel

2 result(s)

== Ground test points ==
count(distinct tp)  provenance
1                   netlist-audit.tel

1 result(s)

The preamble holds rules only, and each query brings its own goal. A query may also carry a description, which the rendered report shows, and a bind: map of values for its own variables (bind: {ref: U1}, or bind: {ref: [U1, U2]} for a list). A bind: value means what --bind means for one query. YAML types each value, so 3.3 is a number and "3" is text. --format markdown and --format html write the whole set as one document with a section per query, and --format json writes the RunQueriesResponse wire message. csv is refused, because one csv file holds one table.

A query may be unanswerable, because it names a misspelled relation for instance. It is reported under its own name, and the rest still answer. One that does not parse also names the line and column in its own text, counted without the preamble. The command then exits non-zero, after writing everything, so a script sees both the answers and the gap. examples/netlist-audit is a full audit written this way, and it needs no preamble at all, because the shipped library (above) holds everything its tables share.

A project keeps its sets in queries/, one per .yaml file beside project.yaml, and --set takes a set’s name there as well as a file: agni query designs/gateway --set power-review runs the tutorial project’s queries/power-review.yaml. As with --checklist, a value ending in .yaml or .yml is a file and any other value is a name the project must keep. In the viewer, the query panel’s Sets menu lists the same sets and opens one as a tab per query, in the set’s order. Each tab answers over one read of the design. From a file… does the same for a set file on your disk. A query the engine cannot answer shows its error in its own tab.

A set holds TABLES. A question with a pass or fail answer belongs in a check or a review checklist, whose verdicts also say what passed and why.

Search the board (any tier, one language)

Board facts query the same way. A length is held in metres and every answer shows it in millimetres, the unit the board is drawn in, so a threshold over one writes its unit, ?w < 0.3mm. A bare 0.3 would be read as 0.3 m and every net would pass it (agni issue 1035). “Nets routed thinner than 0.3 mm”:

$ agni query board.kicad_pcb 'board.track_width(?net,?w), ?w < 0.3mm => ?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.net(?ref,?net), component.mpn(?ref,?mpn), param.max(?mpn,"IOUT",?i), ?w < 0.25mm => ?net, ?ref, ?i

Follow a rail through passives (reaches)

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

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

2 result(s)

See what the walk crossed (route)

net.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. net.route(from, net, path) is the same walk with that half kept. Its path binds the nets in crossing order, with the part crossed between each pair, so an answer carries its own evidence.

$ agni query board.kicad_pcb 'net.route("GND", ?n, ?path) => ?n, ?path'
n    path                provenance
GND  GND                 net.route from GND
SIG  GND -> [R1] -> SIG  net.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. agni trace is the pin-to-pin form. It does end on a rail, and it reports the test points sitting on each net along the way.

net.reaches and net.route are the walk the rules use, so a query and a rule agree on what is reachable. For a walk over a relation of your own, the engine’s graph.closure(rel, from, to, min, max, hops) and graph.paths take the step relation by name and walk no further than the hops you give them. Every net two resistor crossings or fewer from CAN1_CANH:

step(?a, ?b) :- component.class(?r, "resistor"), component.net(?r, ?a), component.net(?r, ?b), ?a != ?b;
graph.closure("step", "CAN1_CANH", ?to, 1, 2, ?h) => ?to, ?h

Unlike net.reaches, this walk enters rails, so a rail’s step ends the question only if your step relation says so.

Asking under your own vocabulary

Some relations report what the engine believes rather than what is in the file. net.rail, net.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 built from the names most boards use. On a board that names rails function-first, the built-in vocabulary can return badly wrong answers for your project.

Isolating that takes moving two things aside rather than one. First, the tutorial project declares a conventions: section in its project.yaml. Naming the design applies it automatically, so the before-state is otherwise unreachable, and the run below deletes the section from its copy of the project. Second, a seeded datasheet’s pin functions establish the rail role on their own. With params/ in place, this board classifies all four rails whatever the naming vocabulary says. Move both and the question is about NAMING alone:

$ sed -i.orig '/^conventions:/,/^$/d' project.yaml
$ sed -i.orig2 '/^params:/d' project.yaml
$ agni query designs/gateway/gateway.edn 'net.rail(?n) => ?n'
n    provenance
GND  designs/gateway/gateway.edn:GND

1 result(s)

Leave the project’s vocabulary in place and ask again, with the corpus still aside so the pair differs in exactly one thing:

$ sed -i.orig '/^params:/d' project.yaml
$ agni query designs/gateway/gateway.edn 'net.rail(?n) => ?n'
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. Author a lexicon in this loop. Ask, compare against the rails you know the board has, fix the pattern, and ask again. See Naming conventions.

The datasheet route reaches the same answer with no lexicon at all. The corpus was doing exactly that before these runs left it out. Datasheets covers that route.

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 how the row holds, so the citations are out of your way until you want them. When you open the first row, the panel asks the question again with witnesses. The row then shows the tree of rules and facts behind it. Each net and part in the tree links to the drawing, as a result cell does, and a not that held is marked absent. If nobody opens a row, the query never asks for witnesses.

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.

A drawer at the panel’s edge holds Examples & relations. It is agni query --relations with a mouse. It opens on the root of the namespace, and choosing a module (net ›) lists its members, each marked with its tier (Netlist, Board, Library and so on). Choosing a member shows its typed signature, which argument types the engine inferred, a library member’s definition and its reference page, and an Insert button that puts net.has_test_point(?n) into the query at the cursor. The breadcrumb above walks back up. The drawer lists the open design’s project library beside the shipped one, as --relations --design does.

Datasheet relations (param.max, param.typ) answer in the viewer when the design’s project has a params/ corpus or the server was started with --params, the same corpus agni query reads.

A click on the drawing selects what you clicked and describes it in the Properties panel, and leaves the query box alone. Properties lists a few Ask questions for what you selected (“What nets does it touch?”, “Which test points reach its nets?”), and each opens in a query tab of its own, named for the question and the part, so a query you were writing is still in its tab. + opens an empty tab. A search fills the box in the tab you are on. Either way the query’s variables are bound to what you picked or searched for. The values show as chips under the box, one per variable, so the query text does not change, whatever you picked. Edit a chip’s value to ask about something else, or remove it to leave the variable free and widen the question. Editing a bound variable out of the query drops its chip.

A question about one thing you picked asks for its first 50 rows, so a click on a ground net with a thousand pins draws quickly. The count under the answer then reads 50 of 1229 result(s) with a show all button that asks the same question again without the limit. A query you write and run yourself is never cut.

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.net inserts component.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. In the viewer the provenance sits behind each row’s expand toggle instead of in a trailing column.

Taking a view out of the tool

A question you keep asking is a view, and --format takes one out of the tool.

agni query designs/gateway/gateway.edn \
  'component.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 that carry the title, the design, and the query itself, above the answer.

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.

--server adds a link that asks the question again in the viewer, printed as look at it: on stderr, as check and trace do. The link carries the query, its --bind values and its --title rather than an answer, so following it re-asks the question of the design as it is now, and --server self serves the design from the same process until Ctrl-C. When a query comes from a project’s set (--set power-review), its link names the set and the query, one link per query. That keeps the link short however long the query is. A set read from a file is on no server, so its links carry each query’s text with the preamble in front.

The link opens the design on its first sheet and brings the Query panel forward with the answer in a tab of its own. When the answer has one row, the viewer draws it the way a click on its first part, net or pin would, on that entity’s sheet. A longer answer draws nothing until you click a cell, because the link cannot say which row it meant. A line above the table says the answer is there. A trace link brings the Trace panel forward the same way.

Two of these behaviours are 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 'net.rail(?n) => ?n' --format csv > rails.csv

Following a signal across the parts in the way

A query joins facts, and a path is a sequence of unknown length. A relation can hold a path only as a rendered string. net.route does that between two nets, one route per pair and never ending on a rail, so it cannot answer pin to pin. “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.

That output shows three things.

The part in the middle is R3. It 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 that splits a net is the ordinary case, not an awkward one. So people often get “are these connected” wrong by eye.

The output also lists what else sits on each net, 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 route can END on a rail or plane but never passes through one, since a supply joins everything to everything and a route through one would mean nothing.

The command does not guess. If you name a pin the design does not have, it reports an error rather than an empty result. A name spelled wrong in a declaration and two pins that are genuinely unconnected are opposite problems. Reporting the first as the second would send you to look at the board instead of at what you typed.

What a query is not

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

The relations used here

These are the full cards for the relations in the examples above, inlined so you do not have to leave the page. The relation catalog serves the same text. Both 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. That 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

The netlist actually knows the working voltage of these supply rails. 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, such as a join against a datasheet absolute maximum. Suppose a rail you expected does not appear. Then the extractor could not read its name as a voltage, or the name holds two disagreeing tokens like 12V_TO_5V, and the extractor refuses to guess between them.

For software engineers

It resolves a net to a declared constant, with a miss when nothing declares one. It is a projection over Nets() in which the extractor tries each net’s 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 stdlib/relations/facts.go walks Model.Nets() and, for each net, calls check.RailMaxVoltage(n, n.Name) (in core/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.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. It names the physical part that R7 or U3 will be built as. Two schematics can be electrically identical and place different parts. Only the part number distinguishes them, and the datasheet checks join against it. You query it to list what a design actually orders. You can also query it to find which components a datasheet-backed rule can reach, since a component with no part number has no datasheet to check against.

For software engineers

The part number is the lockfile entry (see the analogy guide). component.net and component.pin describe the graph structure, and 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.max(mpn, symbol, max), keyed by the same string.

Go projector

componentMPNFacts in stdlib/relations/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 field, else “”. It never parses the free-text Value field, so the identity is only ever what the design declared. The shared ingestion pass classify.StampMPN fills that field. It reads the component’s part-number attributes first, then its part type, so this one relation reads uniformly across formats. The part-number attributes are the lexicon’s attribute.mpn keys, and the built-ins cover OrCAD/Allegro’s Manufacturer_PN and KiCad’s Part Number.

One row per component with a resolved part number. It needs no --params: every model joins the part numbers the design carries (agni issue 748). An empty result means the design names no part numbers. It can also mean the design names them under an attribute missing from the attribute.mpn keys, and a project’s conventions can add that attribute.

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.max(?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, net_tie, …). An unclassified component produces no row (no class is guessed).

For hardware engineers

This is the part’s kind, decided at ingestion from its designator and library type, and widened by the datasheet’s own class when one is joined. 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, where the part carries both its concrete type and every base type it satisfies. So 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. When a component has no rows, the classifier had no evidence. That is distinct from “classified as none”.

Go projector

componentClassFacts in stdlib/relations/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.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.net, a part sits on a net

What it is

component.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. It links components and nets, and every other netlist relation is keyed on one of the two.

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 the analogy guide, where 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. When a query relates a component’s properties to a net’s properties, or the reverse, it joins through this relation, 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 stdlib/relations/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 connects to and a net once per part on it. Empty only when no net carries any connection.

Datalog

Every component-to-net link:

component.net(?r, ?n) => ?r, ?n

Join to component.class to find every diode and the nets it sits on. Most rules build on this shape, which pulls a property on one entity through the edge to the other:

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

What it is

net.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 a transitive-closure predicate. The protection rules use it to answer “is there a component of class X somewhere on the path between these two nets?” without hard-coding a topology.

net.reaches(from, net, hops) is the same walk with the distance exposed, so a rule states the radius its own question needs. hops is an exact count, not a budget, and the next section explains why that matters before you use it.

Unlike the fact relations, net.reaches is computed on demand from the design graph rather than stored, so it is a datalog predicate (kind predicate in the catalog). It is the recursive counterpart to net.bus_like. net.bus_like names the nets the walk refuses to enter, and net.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:

net.reaches(?n, ?rn, 2)           # exactly 2 crossings, so it SKIPS a part sitting 1 away
net.reaches(?n, ?rn, ?h), ?h <= 2 # within 2 crossings, which is what a protection question means

The first line reads like a radius and is almost never what a query means. Use the comparison form for a radius.

The engine deliberately holds more than one radius, so the radius belongs in the rule. 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. Suppose a protection predicate were composed out of the wide default. It would credit that distant TVS and report a genuinely unprotected pin as clean. That outcome 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 one follows the signal one hop along its path. The walk stops at anything that is not a point-to-point series node. A capacitor stops it, since a DC block does not pass the signal on. A ground plane, a global rail or any rail-scale fan-out stops it, since a distribution node is not a path. A part with more than two nets stops it too, since a transceiver or connector is not a series element. With this walk, an ESD or input-protection review can ask “does a clamp sit anywhere between this connector pin and the device pin?”, and a resistor or bead in the middle of the path does not break the question.

For software engineers

net.reaches is transitive reachability over a filtered graph whose nodes are nets. An edge exists only through a two-net pass element. 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, so a query over it terminates. A hop cap guards pathological depth, and fan-out and finiteness bound the walk anyway.

Go projector

net.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. It uses the same IsBusLike stop predicate that net.bus_like exposes. The protection rules run the same walk at a smaller radius (see Distance above). The third argument binds Reach.Depth. The BFS records that depth as it goes rather than re-deriving it from the Parent chain, because the chain can disagree where parallel passes bridge the same two nets. net.reaches is computed, not projected, so it is outside the per-relation EDB doc requirement. This page documents it as the reference behind the walk.

Datalog

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

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

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

net.reaches("VBUS_IN", ?net), component.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:

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

When a radius is not behaving as expected, ask how far away each reachable net is:

net.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

A path question has no way to state itself, so each caller hand-codes a walk. The hop-radius trap above, the per-caller radius constants, and the baked-in edge class are all symptoms of that. 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 ships, net.reaches becomes one canned pattern and this page becomes the migration note.

route, the same walk, with the path it took

What it is

net.route(from, net, path) makes the same walk as net.reaches, and binds the route it found as a value instead of discarding it. It holds for a pair exactly when net.reaches(from, net) holds, and path binds a readable rendering of what the walk crossed on the way:

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 relation is the query-side half of the fix. A connectivity answer now carries its own evidence, so a reviewer can read a hundred rows rather than re-ask them 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. net.reaches answers it. This says which parts those were.

Usually you wanted the parts. “VDD_3V3 is reachable from VBUS” is a fact, and you then go and look it up in the schematic. “VBUS -> [R5] -> VBUS_F -> [L1] -> VDD_3V3” is the same fact with the series bead and the sense resistor named. A reader can decide from it whether that path is the intended one without opening anything.

The walk deliberately leaves out two things. 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.

agni trace is the pin-to-pin form. It does end on a rail, and it reports the test points on each net of the route. This relation is net-to-net, because a query binds nets.

For software engineers

net.route and net.reaches generate over the same filtered graph. Its 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.

net.route gives 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 is still 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. The same is true of the net names in the first two arguments.

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

Go projector

None. Like net.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. Its generator genRoute (core/query/preds.go) drives check.Model.Reach and renders each answer with Reach.RouteLine. RouteLine is the fourth reading of one walk, beside PathTo, ThroughOnPath and StepsTo. genReaches and genRoute share walk, 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:

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

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

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

The route to every net carrying a test point, with the test point named. The path column lets a reader check the answer without opening the schematic:

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

An unbound from walks from every net on the board. agni’s evaluator plans each rule body, so a walk starts from whatever binds from first, whichever order the body is written in. Before the planner existed, one shipped rule opened with the walk, and it took agni check from thirteen seconds to not finishing at all (WS3-114). When nothing in the body binds from, the query asked for the full walk, and on a large board it costs accordingly. Each walk charges a query’s work budget for the connections it examines as well as for the rows it emits, so a budgeted full walk stops partway through instead of after the last start net.

Where this is going

net.reaches and net.route are the same walk asked twice because 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 ships, 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, held in metres and shown in mm. net is the net name (the join key to ir.Net.name); mm is a number, so a query compares it with a unit (?w < 0.2mm). 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 a review cares about that number. 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 you compare it against a real length, written with its unit.

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, reducing it to the minimum width keyed by net. The minimum is the safety-relevant extreme. Reporting it instead of one row per raw segment gives 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 metres, since the engine folds a query literal to that SI base, and a cell is shown in millimetres.

Go projector

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

The board tier is EMPTY on a netlist-only design. Model.BoardNets() returns nothing unless the design was loaded with board geometry (check.WithBoard, fed a .kicad_pcb or IPC-2581 board sidecar). A query sees no error in that case. board.track_width yields zero rows on any design without board geometry, as the datasheet tier does without --params. So 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.2mm => ?n, ?w

Write the threshold with its unit. A width is held in metres and shown in millimetres, so a bare 0.2 would mean 0.2 m and every net would answer.

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