Datasheet layer
How a part's datasheet becomes data a checker can compare a design against: the parameter and document contracts, the derivation that fills them, the demand-driven scheduling model, and the join into checks.
A datasheet is close to a type definition that also carries runtime limits. It states what a part is (an LDO, an N-channel FET) and the envelope inside which it behaves as specified. This layer turns that document into data a rule can compare a design against.
| What it models | Populated by | |
|---|---|---|
| parameter-IR | one extracted parameter with its conditions, limit kind, provenance | derivation, or a human |
| doc-IR | a source PDF as pages, tables, figures, text blocks | a document parser (docling today) |
| derivation | the deterministic function from document to parameter set | datasheet/derive |
| resolution | which parameters to extract, and when | the scheduler below (not yet built) |
| the join | parameter set to design, by part identity | check.Model’s params tier |
Each contract has one schema and many producers. Ingestion uses the same
pattern for format readers. datasheet/derive produces specs offline, but nothing populates the
parameter schema in production yet, and hand-encoded fixtures validate it today.
The engine reads the parameter contract and nothing else in this layer. Rules, relations and reviews
see a PartSpec through core/param and never import the document contract, the derivation or
the workbench behind them. So a hand-typed spec and a derived spec are the same input. C34 holds
that line with a dependency test, and the extraction side now lives in a module of its own,
datasheet/ (agni issue 744).
The parameter contract ¶
“RDS(on) = 3.5 Ω” is not a fact about a part. The fact is “RDS(on) max 3.5 Ω at VGS = 10 V, ID = 0.22 A, TJ = 25 °C, pulse-tested”. An engine can compare a design against datasheet limits safely only when the schema makes the shorter form impossible to hold without noticing.
| Feature | What it prevents |
|---|---|
RangeValue has no bare-scalar form, only a min/typ/max triple with explicit presence |
“max only” (an abs-max row), “typ only” (uncharacterized), and “min/max” (an ensured band) collapsing into a lone number |
LimitKind is an enum: ABSOLUTE_MAX (stress), RECOMMENDED_OPERATING (functional envelope), CHARACTERISTIC (measured under stated conditions). UNSPECIFIED fails validation |
A rule treating a stress rating as an operating limit. The three have different safe uses, and a rule dispatches on this field |
ConditionCoverage makes under-specification explicit. Anything not COMPLETE or UNCONDITIONAL is under-specified, and param.UnderSpecified says so |
Comparing against a conditions-stripped value, which produces confident wrong findings. Worse than no value |
The first two carry the absolute maximum rating distinction. A design gets that
distinction wrong quietly. Condition captures an exact point (eq), a range, or a one-sided
bound, always with the source text in raw, so an unstructured condition is retained rather than
dropped.
Provenance ¶
Every Parameter carries a ParamProvenance: source document (a SourceDoc naming vendor doc
number and revision), page, table or figure as titled, extraction method, and a confidence in the
range (0, 1]. If an engineer cannot verify a value against the exact page, review cannot use it, so
zero confidence is invalid by construction. Hand-encoded rows use method: "hand", confidence: 1.
SourceDoc.locator is corpus-local, because the deployment posture keeps a customer’s datasheets
inside their own boundary.
A FIXTURE IS NOT A CORPUS, and the difference is size. A fixture in core/param/testdata/
carries a few cited rows, enough for its tests, so make testall passes on a clean clone. A seeded
corpus is the part’s actual parameter set and lives OUTSIDE this repo with its source PDFs. Someone
transcribes a whole vendor table because it demonstrates better, and the fixture drifts into an
extracted parameter document. txb0104.textproto drifted that way to 389 lines before it was cut
back. If a new fixture is much larger than its neighbours, it has probably drifted the same way.
Anything user-facing uses SYNTHETIC parts.
Verification, and why it expires ¶
Provenance says how a value was PRODUCED, and cannot say whether anyone has since agreed with it.
Parameter.verification is the separate record of who checked, when, and against WHICH REVISION.
Parameter.verification is a message rather than a boolean so it can carry the revision. A value
checked against rev K is not thereby true of rev L. A stale verification is worse than none. An
unverified value claims nothing, while a stale-verified one is a confident wrong answer with a
person’s name attached. Readers trust it precisely because someone did once check it.
SourceDoc.content_hash records which revision the corpus holds, and
Verification.doc_content_hash records which one was checked. param.VerificationOfIn compares
them on every read. Staleness is derived,
never stored, and unknown is deliberately not folded into verified, because a caller that cannot
check must not be told the answer is fine. Each parameter is judged against the document IT cites,
since a spec routinely carries a datasheet and an application note and revising one must not
invalidate the other. The hash lives on SourceDoc rather than being resolved from locator on
demand, because a check must not do I/O to learn whether its evidence is current (C22).
Why the printed revision is recorded too, and why it is display-only
A hash decides staleness correctly and tells a person nothing. Verification.doc_revision is the
document’s identity AS PRINTED when it was verified, snapshotted from that day’s SourceDoc.title.
It turns a flag into a task:
Re-confirm VDD abs-max: verified against SCES650K, corpus now holds SCES650L, page 4.
It sits on Verification rather than SourceDoc, and the reason is easy to get backwards. A
re-seed REWRITES SourceDoc, hash and title both. That rewrite makes a verification stale. So the
rewrite would destroy a revision recorded on SourceDoc at the moment the revision becomes worth
having.
param.MarkVerified takes the SourceDoc itself rather than a hash and title separately, so the two
are read from one place and cannot disagree. Splitting them would let a caller pin one revision’s
hash beside another’s printed name. Such a verification goes stale correctly, and then names the
wrong document to the person asked to re-confirm.
The snapshot must never become a comparison input. Vendors reissue documents without moving the printed revision, and move the revision without changing content, so two files stamped “Rev K” may differ and two differing strings may describe identical bytes. It is not orderable either (K/L/M, but also 1.0/1.1, A/B, bare dates, “Rev K.1”), so “how many revisions behind” has no general answer.
Who writes a verification, and why hand transcription counts as one
Hand transcription in the workbench writes one. Typing a value off the page IS a confirmation, and
the layer had always said so implicitly by stamping confidence: 1.0 on a hand-entered row. It
could not say WHICH revision, so the claim never expired.
candidate.Accept takes the opposite posture. It refuses to mark a machine proposal verified, so “a
machine proposed this” never reads as “a person checked this”. Hand transcription is on the checked
side of that boundary.
Two consequences follow. First, suppose the corpus has not recorded a document’s revision. A
transcription against that document saves UNVERIFIED rather than being refused. Transcribing is
still worth doing, and claiming a confirmation that nothing can invalidate is not. Second,
SourceDoc.title is editable in the workbench. Producers fill it with a PART number (“LM1117”)
rather than the document number, which the field is specified to carry, and a snapshot of a part
number reads identically before and after a reissue.
The workbench saves a DRAFT. The draft is keyed by its MPN and cites its source datasheets, so a
family datasheet can feed one draft per part. Drafts live in the corpus store
(agnids serve --corpus) beside the published specs, and opening a datasheet lists the drafts that
cite it. Nothing is written until the author confirms an MPN, so browsing writes nothing. The rest of
the workbench is described in Web app.
A draft is saved without validation, so work is never lost. No check reads it, because
param.LoadSet loads *.textproto only and drafts are drafts/<MPN>.draft.json. A draft reaches
the engine through publishing, with
PublishDraft or agnids publish. Publishing runs param.Validate, refuses a draft that fails,
writes <mpn>.textproto into the corpus, and records it in the corpus’s index. The draft is kept as
the start of the next edit.
Publishing and the index belong to the datasheet service (datasheet/corpus), because publishing a
spec is the producer’s step, while the engine only reads specs through core/param (C34).
Comparison semantics ¶
Values, units, and symbols are STORED as printed, so the layer meets vendor variety and a seeded row can be checked against its page by eye. The three rules below share one posture. When a comparison cannot be made safely, the layer says nothing rather than improvise one.
A row has three trust states, not two. param.UnderSpecified says a row’s conditions are not
trustworthy at all, so skip it. A row can be fully captured and still carry a condition that exists
only as text. A human can evaluate that condition next to its provenance, and no machine can.
param.MachineComparable names the boundary, and the middle state is surfaced but never
auto-compared.
A prefixed unit is reduced to its SI base before comparison, in exactly one place.
param.InBaseUnit converts, an unrecognized unit is skipped rather than scaled by a guess, and no
rule contains a scale factor. The lookup is a closed vocabulary flattened at init rather than a
prefix parsed off the front, so a test can enumerate every accepted spelling as a key. It is
case-sensitive with no fallback, because mΩ and MΩ differ by nine orders of magnitude.
core/classify’s prefix table is deliberately NOT reused. It parses a component’s value text off
a design, where IEC 60062’s RKM code reads M as MEGA and case is not significant. It is correct
for a schematic value field and inverts three orders of magnitude on a printed unit symbol.
TestUnitVocabulariesAgree holds the two tables to each other without an import, since the datasheet
tier depends on nothing in core (C17). Vendor symbols never appear in rule text either, because
the same parameter prints as “VDC”, “WV”, or “Rated Voltage” depending on vendor. So symbol is the
per-vendor match key, and the lookup lives behind the join as a per-corpus alias map.
An alias set names one limit on one pair of terminals, and each extractor in core/check
constrains the limit KIND only where the datasheet does. The alias set, the unit, a stated max and
the two trust gates above apply to every extractor. The rest differs per limit.
| Extractor | Kind constrained | Why |
|---|---|---|
SupplyAbsMaxLimits |
ABSOLUTE_MAX |
it reads what a supply pin can WITHSTAND |
FetBreakdownLimits |
ABSOLUTE_MAX |
drain-source breakdown is the voltage past which the part stops being a switch, and a datasheet always prints it as an absolute maximum |
EsdRatingLimits |
ABSOLUTE_MAX |
an ESD rating is a maximum survivable stress |
OutputVoltageLimits, OutputCurrentLimits |
no | a regulator states its output as a recommended-operating or characteristic row, so filtering to one kind would find nothing on a real spec. A downstream part is exposed to the max |
The FET breakdown set holds VDSS, VDS, BVDSS and V(BR)DSS and deliberately excludes VGSS.
That is the gate-source rating, a different limit on a different pair of terminals and usually much
lower, so comparing a rail against it would misreport which rating a design violates.
An ESD rating is matched to the PART and not to the connector-facing PIN. A connector-facing net
counts as IC-protected when any part within the protection radius declares a rating of 2 kV or more
(icEsdFloorVolts), whichever of that part’s pins the net connects to. Matching the rating to the
pin is left for follow-up work.
The refusal that unit conversion replaced, and the five rule families it was silently breaking
Conversion used to be a refusal. Unlike unit strings were treated as under-specified and skipped, on the reasoning that conversion at a call site would become a second informal normalization layer. The refusal shipped a worse failure than the one it avoided.
An extractor that dropped the row left its rule with an empty list. The rule compared nothing and
reported nothing, and the runner scored that as a pass. Neither guard caught it, since
check.Available saw a params tier attached and the needs-data gate saw the symbol seeded.
Milliamps are the ordinary spelling for a sub-amp regulator. A spec transcribed as printed hit this
without doing anything unusual, and five rule families were silently passing designs with genuine
defects.
Conversion became safe by happening in one place rather than by being cautious, so one table lives in
core/param beside UnderSpecified and MachineComparable and every extractor reads through
it. Storage is unchanged, since the spec keeps the printed row and the extractor returns a converted copy,
mirroring ir.Quantity’s split on the design side.
Pin binding ¶
A parameter can name the pins it applies to. Without that, a part printing the same concept on
several terminals with different limits collapses into one. Then nothing can answer the commonest
review question about a power or interface net, “does this connection meet what this pin actually
requires”.
PartSpec carries pins and packages, and Parameter carries pin_refs.
Inside the spec the binding key is a spec-local Pin.id, neither the name nor a number, because
an opaque local id is unique by construction and lets param.Validate reject a parameter bound to
an undeclared pin. A dangling binding is worth catching at load, because downstream it does not look
like an error. The parameter stops applying to anything, and the rule that wanted it reports
nothing.
pin_refs is orthogonal to applies_to rather than a second spelling of it. applies_to narrows
which variant a row covers and pin_refs narrows which terminal. A row carrying both narrows by
the conjunction. An empty pin_refs still means a fact about the part as a whole.
A pin is a property of the part type, never of a placement. One PartSpec describes one MPN and
a design may place fifty instances, so no reference designator appears anywhere
in this contract. The rule fans a type-level pin fact out across instances.
Resolution leads with the pin NAME and uses the number only to break ties. A pin number is a fact about a package, and a datasheet parameter is a fact about the die, so the same silicon in a different body renumbers its terminals. Imagine a part seeded from one package and placed in another. A join keyed on pin numbers reports about the wrong terminal, evaluates cleanly, and looks healthy doing it. The seeded TXB0104 carries the real collision. Pin data is optional throughout, and a spec carrying none behaves exactly as it did before pin binding existed (C9).
What the number is still good for, and the three ways resolution refuses
The design side reaches a terminal as Connection{component_ref, pin_ref} to the section’s
PartType to an ir.Pin, which carries a name and a package-relative designator. The datasheet
side offers the same two channels.
A name comes off the same pin function table on both sides, so it survives repackaging. But a name
is not unique, and the number repairs that. The two sides spell a subscript differently, so names
compare on ident.PinName. It folds a split (V CCA, VCC_A), KiCad’s markup (VCC_{A}) and a
pin table’s parentheses (VCC(A)) to one key. It leaves KiCad’s overbar alone, since ~{RESET} is
a different, active-low pin. A part with several supplies binds each supply row to its pin, and the
per-pin rules then judge each supply against its own range (agni issue 1160). Where no package is
identified, param.ResolvePin still uses the number if every declared package agrees (VCCA is
number 1 in both the TSSOP and the UQFN) and refuses only where packages genuinely disagree.
Every refusal is a distinct sentinel, so a caller can tell “no pin data at all” (ErrNoPinData, the
older-corpus case, fall back to the part-level path) from “ambiguous” (ErrPinAmbiguous) from “the
channels contradict” (ErrPinConflict).
Constraints between two pins ¶
Some requirements hold between two terminals: a supply that must stay at or below another, a
reference that must sit a volt above its companion. A Parameter cannot say this, because
pin_refs already means “this row applies to these terminals” and a relation there would make it
mean two things depending on the row. PinRelation is the separate shape. It is held on
PartSpec rather than on a Pin, because a relation is between two terminals and neither owns it.
The bound is a value, not a comparison operator. The obvious shape for “VCC(A) <= VCC(B)” is a
subject, an operator and a reference. That shape breaks on the next document, since across four
vendors three of five instances carry a non-zero allowance. So the bound is on the DIFFERENCE,
subject minus reference, reusing RangeValue unchanged:
| The document says | difference |
|---|---|
VCCA <= VCCB |
max: 0 |
| never exceeds by more than 0.5 V | max: 0.5 |
| at least 1 V higher | min: 1 |
| within 0.3 V of | min: -0.3, max: 0.3 |
The bound is signed, so swapping the ORDER of the two ends inverts the requirement.
param.PinRelations returns relations from either end, so a caller must read subject_pin_ref
rather than assume its own pin is the subject.
Modality and regime, the two fields that stop a bound being misread
Modality is recorded because “shall never exceed” and “should be at least 1 V higher for best
operation” are different claims, and a consumer holding only the numbers cannot tell a defect from a
suboptimality. The vendor’s modal verb is the only evidence, and the printed sentence is kept in
raw regardless.
A regime is a Condition, not a new field. Two of the five instances scope their bound, one to
transient behaviour and explicitly not DC, another across power-up, power-down and normal operation.
Those are test conditions in the sense Condition already models, down to its raw fallback. A
bound recorded without its regime is wrong in both directions, over-applying a transient allowance to
steady state and under-applying a limit that the vendor extended across power-up.
What is deliberately absent ¶
A field goes into the contract when a second producer would populate it, and for parameters a second producer is a second vendor’s datasheet.
| Absent | Why |
|---|---|
| Canonical parameter ids and a symbol ontology | canonical_id exists and is left empty, while symbol and unit are as printed, so a row can still be checked against its page. Doing the ontology early bakes one vendor’s vocabulary in as canonical, and comparison needs none of it |
| Curve and graph data | Derating and SOA curves are real, and their shape should be designed against real extractor output rather than guessed |
| A verification WORKFLOW | Assignment, review queues and approval gates belong to the pipeline. Only the OUTCOME is in the contract, because a fact carries what a reader needs to trust it and nothing about how the checking was organised |
| Package GEOMETRY | Package carries an id, the printed name, and the orderable-MPN suffix, all that pin numbering needs. Land patterns join through the design IR’s footprint tier, and duplicating them here would create a second source of truth |
More than one PinRelationKind |
Only TRACKING had more than one producer across four vendors. An unpopulated enum member reads as a form that never occurs. That is a different and worse claim than having no evidence of the form |
| Power sequencing | Datasheets state it plainly, and a netlist carries no evidence of order in time, so no rule could evaluate one. The prose is kept on Pin.description |
Why proto, and the worked examples ¶
The layer is proto because the Go engine, the TypeScript viewer and future extractors share it, and
hand-written parallel types would drift where a shared schema cannot. It is agni.v1.param
rather than a corner of ir because it has different producers, consumers, and lifecycle. Three
fixtures are transcribed by hand from the cited revision, and param_test.go and pins_test.go
assert all three validate, so the examples are executable rather than prose.
| Fixture | What it demonstrates |
|---|---|
lm1117.textproto (TI LM1117 LDO, SNOS412Q) |
The three limit kinds on one part: abs-max VIN 20 V, recommended-operating VIN 15 V, dropout as a conditional characteristic. Its dropout rows show why differing condition sets are kept as distinct parameters |
bss138.textproto (BSS138 N-FET, rev C(W)) |
The canonical conditional parameter. RDS(on) specified three times, a table-header default encoded as an explicit condition rather than dropped, and the pulse-test footnote retained in attributes |
txb0104.textproto (TI TXB0104, SCES650K) |
Pin binding, with one part covering every case that pin binding must survive. Two supply terminals with genuinely different ranges, one die in three bodies where number 11 is a data I/O in one and a supply in another, a row bound to a group of terminals, a name printed on two pins (NC), pins absent from one package, and ball designators, so a pin number is a string rather than an integer |
The document contract ¶
The doc-IR is the intermediate artifact, a source document decomposed into pages, tables, figures and text blocks, with cell structure and bounding-box provenance. Many parsers produce it. Recipes, LLM proposal stages, the verification UI and revision diffing consume it, and none of them touch the source bytes. It is named for documents rather than tables or datasheets. Figures are provenance targets and the page text layer feeds search, so “table-IR” would mislead. App notes, errata and reference manuals decompose identically, so “datasheet-IR” would overfit today’s corpus.
| Property | Guarantee |
|---|---|
| Documents are keyed by the hash of their source bytes | A revision is a different Document, and nothing is mutated. Content-addressing, the same idea as a git blob |
Region ids (p2.t1) |
Deterministic within one derivation, and the address for crops and queue items. NOT stable across producer versions, because a detection change renumbers them |
Table content hashes (doc.TableHash) |
The cross-version identity, covering grid shape and cell position, span, text and footnotes, excluding bboxes, ids, confidence and header flags. Two derivations of the same printed table hash equal even when detection nudges coordinates. The prototype producer replicates the hash byte-for-byte in Python and the Go validator is the referee |
| Coordinates | Page-local, top-left origin, y-down, in PDF points. Producers reading PDF-native boxes flip at emit time |
Querying comes in two tiers. Tier 1 is datasheet/doc/, in-process and deterministic, used by
recipes, tests and the revision differ. TablesMatching(d, regexp) is the recipe primitive. It
selects by title pattern rather than by id, since ids are not version-stable. Beside it sit
addressed access (TableByID), grid access (CellAt, where a merged cell appears once at its
top-left), the full-text source (PageText), and FindTableForProv, which resolves a locator back
to a table. Tier 2
is deferred to the extraction store, so “not extracted yet” is searchable rather than a dead end.
Running datasheet/tools/pdf2doc (docling 2.x) over two real datasheets taught three things.
Structure is solid, with every table validating for grid consistency and hash match, including a
32x8 table. Table titles come back empty, because datasheet tables are headed rather than captioned,
so title attachment is recipe-layer work. And symbol text needs normalization, since subscripts
arrive space-split (“V GSS”). Normalizing is also a recipe concern, because doc-IR stores text as
extracted.
By design, doc-IR has no curve data, no semantic classification and no cross-document corpus structure. Figures carry a caption and a bbox so provenance can point at them. Classification is the recipe layer’s output, so doc-IR can be reused across recipe versions.
How a PartSpec is derived from a document ¶
PartSpec = f(document, toolchain, recipes, patches)
Every input is pinned, every output reproducible from a run manifest, re-runs incremental. A run has two outputs, what it extracted and what it declined:
flowchart LR
R["a derive run"] --> P["PartSpec<br/>what it extracted"]
R --> G["gap list<br/>what it declined, and why"]
P --> C["a rule compares<br/>a design against these"]
G --> W["a worklist for a human"]
| Stage | What it does |
|---|---|
| 1. Classification by candidate titles | Real producers emit datasheet tables untitled, and often fold the section band into the table as a merged header cell. Classification tries candidates in order: the producer-attached title, then embedded band cells, then heading-like blocks above the table (nearest first, with a small overlap tolerance because real detected boxes touch). The first candidate that matches a recipe rule becomes the title and limit kind. Unmatched tables go into the gap list rather than being silently skipped |
| 2. Header-row detection and tokenization | The column-header row is found by recognized names (Symbol, Parameter, Test Conditions, Min, Typ, Max, Units, Ratings), scanning past band rows. TI-shaped tables with an unlabeled row-label column fall back to column 0, leaving symbol empty rather than guessing. Value cells tokenize by shape, conditions parse two structured forms, and everything else is kept raw-only, which the comparison semantics above then correctly exclude |
| 3. Pin function and pin-mux tables | A separate path yielding terminals rather than values, and the one stage needing the recipe to declare something the document does not say. Below |
| 4. Patches, applied last | One pinned human correction to one cell of one exact document, keyed by document content hash plus pre-patch table hash, so a new revision or a re-detection stops it matching. Empty text clears a cell and a patch at an empty position inserts. So a clear-plus-insert pair corrects a producer cell-placement error, as in the real LM1117 case where docling put the abs-max 20 under MIN. Applying patches last means a verified fix cannot regress |
| 5. Validation and emission | The emitted PartSpec must pass param.Validate, and every parameter carries provenance with method: "derive/v0" and confidence 0.9, and only human verification grants 1.0 |
Stage 3’s layout comes in two shapes, both common. A flat header has one column labelled NO. or
PIN. A banded header puts a spanning PIN cell above several columns of designators, and those
sub-columns carry no role word, so the recipe must say what they MEAN, because the document does
not.
Read off the page, the two possibilities look identical:
| The sub-columns are | What that means |
|---|---|
package codes (D, PW | RUT | YZT) |
one die, numbered differently in each body |
part variants (ADS1113 | ADS1114 | ADS1115) |
three parts sharing one pinout |
The evidence does not settle it either. Over a 63-document corpus the banded shape appears 44
times. Six of those are variant columns and one is package columns. A rule inferring the axis from
header text would be fitted to that single example, and on the rest it would mint packages named
after part numbers. PinColumnAxis is therefore declared. The safe default extracts the pins and
records the unread columns as gaps.
Four things about real pin-table output, none visible on the printed page
| What arrives | Why it matters |
|---|---|
One header cell can name several packages (D, PW) |
One cell yields a PinNumber for each body |
| Designators split on commas only, never whitespace | Producers flatten a footnote marker into the cell, so 8 2 is pin 8 with footnote 2 while 2, 3 is two terminals. Splitting on whitespace invents a pin on every footnoted row |
| The absence marker is an ASCII hyphen, not the printed em-dash | A parser matching the typographic glyph reads every absence as an unparsed cell |
Subscripts flatten with an injected space (VCCA as V CCA) |
The name is the channel that resolves a design pin to a spec pin, so fragments are rejoined when every one is a short all-caps token. A multi-word label like Thermal pad keeps its spaces |
A row carrying several designators per package is ambiguous by construction. GND 2, 5, 7 is
one terminal bonded to three legs, while NC 6, 9 is two terminals sharing a printed name. The
table cannot tell them apart. The document states one function in words (“No connection. Not
internally connected.”), so the split keys on that function, and every such row is gapped whichever
way it went. Otherwise the type column is taken at face value. Real tables leave it blank on supply
and ground rows. Inferring POWER_INPUT from a name like VCCA would mean this stage invents a
classification, and the document declined to make one.
Pin-mux tables ¶
A microcontroller’s datasheet also says which peripheral functions each pin can be switched to, and
Pin.alternates holds them (agni issue 667). They come from a third table kind, MuxTableRule,
because the table answers a different question from the pin function table. That one says what kind
of pin a terminal is, and this one says what it can carry. intent/io-map-function reads it to check
a design’s declared pin map.
Two vendors’ tables were read before the shape was settled, per the field-discipline rule:
| NXP KW47, Table 58 “Package pinout” | TI MSP430FR2355, Table 4-1 “Pin Attributes” | |
|---|---|---|
| Layout | one row per pin, every mux slot in one cell (ALT0 - ADC0_B13 ALT1 - PTB3 ...) |
one row per function, several rows to a pin, numbered in four packages |
| Selector | the slot, ALTn |
PxSEL bits, sometimes with the pin’s direction, in separate port tables |
Both list functions per pin, and neither switches a whole bank at once, so a function belongs to the
pin. A package variant renumbers the pin and leaves its functions alone, and the existing
PinNumber already covers renumbering. The selector is left out of the contract. Both vendors
print one, in forms that share no shape. A legality check compares names only, so PinAlternate
holds a name alone until a consumer needs the selector.
The recipe names every column, because these tables share no header vocabulary to infer from. It
also declares the layout (MUX_LAYOUT_CELL_LIST or MUX_LAYOUT_ROW_PER_FUNCTION). A pin function
table may already have emitted the pin. A mux row joins that pin when the two share a designator in
a package, and creates the pin otherwise. Each name is kept as printed, footnote markers aside, so
ADC0_A10/CMP0_IN0 is kept as one slot and a comparison splits it through core/ident.
What real pin-mux output does, and the gap each case leaves
| What arrives | What derivation does |
|---|---|
| A pin broken over a page, its last slots in a row with no name and no designator | The row continues the previous table’s last pin |
A function named after its own label (LPTMR0_ALT3) |
The recipe’s label pattern carries word boundaries (\bALT\d+\b) |
| A designator placed at the vertical middle of its merged cell, or a span stretched over the next pin | In the row-per-function layout a pin begins on the row where the recipe’s pin_start_column is filled (TI’s reset-state column), and a designator belongs to the pin whose rows it starts in. Neither spans nor designators mark a pin’s rows reliably |
N/A as a reset state, and two dashes merged into - - |
N/A starts a pin, and a cell of dashes alone does not |
A header the producer folded other text into (Pinmux Assignment ALT5 - TPM1_CH2 ...) |
The column is not found, and the table is gapped as unparsed-mux-table rather than read with a guessed column |
| Two pins’ signals in one cell, so one pin collects two designators in a package | Gapped as mux-pin-boundary |
| A row whose function cells yield nothing | Gapped as unyielded-alternates, so a pin with no alternates reads as unread |
Two cases need a patch rather than a rule, and both show in the run’s gap list. On the
MSP430FR2355’s page 18 the producer put OFF against XOUT and left P2.5’s reset cell empty, so P2.5’s
functions are read onto a pin named XOUT. On its page 19 the PT and RHA headers arrive as one cell, and
the table is gapped. Derivation over the two full documents extracts 44 of the KW47’s 49
terminals, which are 48 pins and the exposed pad. It extracts 29 of the MSP430FR2355’s 48. Most of
the rest sit in one gapped table in each document.
Three records describe a microcontroller’s pins, and they live in two places because they answer to different authorities:
| Record | Says | Owned by | Lives in |
|---|---|---|---|
| Pin allocation | which net and function this board puts on each pin | the board’s designers, per design | the design’s intent, io_map (design intent) |
| Pin mux | which functions each pin of the part can carry | the silicon, per MPN | the PartSpec, Pin.alternates |
| Parameters | the part’s limits and ratings, bound to pins by id | the silicon, per MPN | the PartSpec, Parameter |
The mux table is a fact about the part and never about a board, so it is never written into intent,
and an allocation is never written into a spec. The two meet only in io-map-function.
Sometimes a mux table arrives as its own file rather than through derivation. agnids merge-mux
merges it into the published spec (agni issue 1200). The file is a PartSpec in YAML holding only
pins, bound through internal/yamlpb, so the mux table has no message of its own. The merge joins
pins the way derivation joins a mux row (param.MergePinMux). It adds to a joined pin’s alternates
unless told to replace them. The merged spec then goes through the same validate, write and index
step as a published draft. The merge happens before the corpus sees the file, so LoadSet, the
corpus index and PartSpecService still read one file per MPN and none of them joins two. A
PinAlternate carries no provenance, so a joined pin keeps the provenance of the table that defined
it. The mux reference is cited only on the pins the merge added. Usage is in
the datasheets guide.
Stage 5’s gate limits what may be added to param.Validate. param.Validate is Problems
joined, and Problems includes the COMPLETENESS half, so a derived spec must be complete rather than
merely well-formed. Any new completeness check therefore fails every derive run as soon as it
merges, and the run reports it as “a derive bug, not a data gap”. That failure is the right default,
and it is also a trap. If a first pass genuinely cannot supply something, the missing thing must be
recorded as a manifest gap, never as a completeness problem.
Document identity, the case that proved the trap real
A run states which part it derived and which revision it read. It does NOT state the document’s
printed identity, because SourceDoc.title is specified as the vendor’s document number and a
derivation cannot read that. SourceDoc.title used to be filled from the doc-IR’s own title.
Producers set that title to the PART number, so every citation named the part and none named a
revision, identically before and after a reissue.
That refusal is now an unidentified-document gap carrying the cover page’s opening prose. A
citation with no recorded identity reads datasheet for LM1117 (revision unrecorded) rather than
borrowing the part name.
param.Validate does report one wrong state, a title that repeats the MPN, because that title is an
assertion rather than an absence. The check is deliberately equality, not a guess at what a part
number looks like. The failure it corrects came from a plausible-looking value that nobody
challenged. A heuristic would also reject legitimate titles from vendors whose numbering nobody has
seen.
Trust defaults ¶
Rows from tables with no conditions channel are UNSPECIFIED, and under-specified until a human verifies them. They are never UNCONDITIONAL. Such a table may have a header default that qualifies every row, and this stage cannot prove it captured that default. Rows with a captured channel are COMPLETE. A raw-only member still makes its row machine-incomparable, and that is the intended middle state. Derived confidence is a constant 0.9.
Confidence is a SECOND signal rather than the record. param.MarkVerified raises confidence to 1
alongside writing the Verification, so a consumer reading only the older float is not misled. But
confidence cannot expire and a verification can, so code that decides whether to TRUST a value reads
the verification state. Judging on the float alone rates a verification of a superseded revision as
the most trustworthy data in the system, because the float still reads 1.0 after the document
moves.
The manifest, and why declining is recorded ¶
Every run emits a RunManifest: doc content hash, producer and derive version, part identity,
recipes matched, patches applied, and the gap list (unclassified tables, unparsed rows, raw-kept
conditions, unapplied patches, untyped pins). What the run did not extract is enumerated rather than
implied.
A narrow classifier plus a recorded decline beats a clever one. Pin typing is the worked example. A table routinely leaves its type column dashed on exactly the supply and ground rows and puts the fact in prose. So the stage reads the description, but only how it OPENS. It accepts a handful of near-universal phrasings and refuses everything else.
The temptation is to search the whole sentence for “ground”. One real table describes a thermal pad that “must either be connected to Ground or left electrically open”. The BOARD makes that decision. A greedy matcher would stamp a false ground onto the pad’s net, and every rail rule would then quantify over that net. An untyped pin costs nothing, and a wrongly typed one is a confident wrong answer. The trade works only because declining is recorded, so the narrow path produces a worklist rather than a silence.
The gap list is also the diagnostic instrument. One pin table had a header spanning two rows. The header hid both its type and description columns, so every pin came out named, numbered and untyped. That result looked the same as a datasheet that simply did not say. A silent extractor bug survives that way because its output is plausible. This one was found in minutes, because the manifest said which pins were declined and why. The gap detail also separates the two causes. An unknown token is a vocabulary gap that names its own fix, while a missing column is a judgement for a human.
derive_test.go asserts that deriving the raw-shaped BSS138 doc-IR reproduces every hand-encoded
row. Any change must keep that agreement or deliberately update the goldens. The render golden SVGs
follow the same regression discipline. An ensemble or VLM second path with agreement gating is still
deferred, and so are the verification queue, a persistence store (specs and manifests are files
today), realtime on-demand derivation, curves as data, and where the recipe catalog lives once a
second vendor family accumulates.
How resolution is scheduled ¶
This part is decided and not yet built. Everything above is contracts and a function over
them. The scheduler decides what drives PartSpec population and when. It is separate, sits on top
of the contracts, and changes none of them.
docling gives a faithful but semantically flat decomposition, and across vendors that layer has no reliable global structure, so there is no single pattern to search for. Given that, the obvious plan is eager extraction, classifying every table in every datasheet before running rules. It has two defects. Most of the work is wasted, since a datasheet carries hundreds of parameters and a schematic’s use of a part cares about a handful. And eagerly extracted params are unverifiable, because extracting 300 values with no design in hand gives no signal for which are wrong.
So resolution starts from the schematic. The rules being evaluated ask for exactly the parameters they need, each ask resolves against a cache, and a miss triggers resolution that backfills it. This mirrors how an engineer uses a datasheet, as a reference consulted with a question rather than a database memorized in advance.
The eager/lazy boundary sits at doc-IR to PartSpec. doc-IR is eager, because decomposing a PDF is a fixed one-time cost per file that produces no semantic claim. PartSpec population is lazy. So “extract on demand” means classify and transcribe on demand over already-faithful doc-IR, not run docling on demand. The usual objection to a just-in-time design pictures a developer clicking a rule and stalling on a heavy extraction, and keeping doc-IR eager removes that objection.
The query language and evaluators do not change. The new piece is a resolver, a lazy provider behind
the Model’s params tier that turns a miss into a run of the chain and an answer. Today that tier is
loaded eagerly and a miss means the rule silently skips. No new step enumerates which params a
rule needs either. When supply-exceeds-abs-max reads abs_max(part, "VIN") from the
Model, that read IS the query.
Two consequences follow. First, agni query cannot answer a datasheet question today unless the
set was preloaded. With the resolver, search could reach the datasheet at all. Second, over a
corpus the cache converges toward the eager one in priority order. It accretes into a PartSpec
built from actual demand, and every parameter in it has already been used against a real net.
A backend answers one query, (part, parameter, condition), with a PartSpec-shaped answer carrying
confidence and provenance. So the chain composes without any backend knowing about the others, and
it stops at the first confident answer. cache is a prior resolution. recipe is per-vendor
deterministic classification, precise and brittle across vendors. model is inference, general
and fuzzy. local few-shot embeds the region and suggests the nearest labeled prior. HITL is
a human, and only a human resolution grants confidence 1.0. The trust predicates already in the
parameter-IR are the gate between auto-use and show-a-human, so a low-confidence or UnderSpecified
answer is surfaced as a suggestion rather than committed as a silent fact.
The second query shape, recall, and the three flows this produces
The resolver serves two query shapes over the same doc-IR and the same chain. A scalar limit,
(part, parameter, condition), returns a value with its range and limit kind.
supply-exceeds-abs-max needs that shape. A pin table, (part), returns a pin-function mapping
of which pins carry which interface signals, such as a bus’s CLK/CMD/DAT lines, a memory byte-lane,
or a transceiver’s TXD/RXD. An interface-shape check can then derive its
required-signal list from the host part’s datasheet rather than hand-authoring it. A pin table is
already a doc-IR Table, so a recipe classifies it exactly as another recipe locates an abs-max
value, and only the result type differs.
Every resolution, automatic or human, is stored keyed by roughly
(part-family, parameter, vendor, region-shape). Nearest-prior over that store yields suggestions.
This uses the few-shot idea as a warm start rather than as auto-committed fact. The suggestions
speed up review and seed the model backends, and auto-commit still requires the chain to clear the
trust gate on its own. Coverage also gets a better grain, since eager extraction reports “we
extracted 300 of an unknowable total” while demand-driven reports “of the parameters your rules
queried, N answered automatically, M needed a human, K unresolved”.
- A check can drive it alone.
agni check --paramsreadsabs_max(LM1117, VIN), misses, the TI recipe locates the abs-max table and returns 20 V at confidence 0.9, the value materializes, and the rule compares the design’s +24 V supply against it, fires, and cites both sides. - A check can escalate at the point of demand. The same walk, but the recipe cannot confidently locate or parse the value. The resolver opens the workbench focused on the likely page and region, “confirm VIN abs-max for LM1117,” with a warm start from recall. Confirming caches the value with region-cited provenance and upgrades trust, and the rule then fires.
- Search can drive it too. A developer clicks a pin or runs a datalog search across parts. The same resolver runs underneath, so search and checks share one cache and one path.
Four points are still open. One is the resolver interface signature and where it lives. Another is async UX for the slow backends. A third is the recall store’s key and similarity metric. The last is whether multi-param whole-part analyses batch their queries while still being demand-driven. The home for a pin-function mapping is settled. It extends the parameter-IR as pin binding describes, so only the extraction is still open there, not the target.
How it joins into checks ¶
The join key is part identity, PartSpec.mpn matched against the design’s MPN, and
PartSpec.manufacturer takes no part in it. Readers and the design IR never import the parameter
layer, so the dependency points one way. When a
design carries no BOM or MPN data the join has no key and parameter checks skip, the same
skip-not-false-pass behaviour used for unseeded parts. The Model’s params tier is the join, set
with check.WithParamProvider on check.NewModel. It takes the BomLine MPN first, else the
component’s mpn field, and matches case-insensitively and nothing fuzzier.
That join is by part identity. pin-exceeds-abs-max and pin-out-of-recommended consume the finer
per-pin join (agni issue 190). They act only on a part whose spec carries pin bindings. Every other
part still uses the alias path, which reaches a terminal through a vendor-symbol table and so
cannot tell two supply pins of one part apart. param.pin_relation projects the pin-to-pin bounds.
Several rules use the layer, and two show its range. supply-exceeds-abs-max compares a
power-input pin’s rail nominal against the spec’s machine-comparable abs-max supply rows. Its
finding carries the design site in Prov and the datasheet citation in the message. It is already a
right-to-left query, so the scheduling model above generalizes an existing shape rather than
introducing a new one. cap-voltage is the first spec-authored datasheet rule with no Go twin.
Its body is a check.Spec. The join and the float compare live behind the cap_voltage_detail
SpecFunc. The FFI’s declared reads flow into derived metadata, so param.cap_rated_voltage,
net.max_voltage and component.mpn appear as named relations without hand-maintained lists.
A citation also carries the value’s verification state. The state is derived at citation time
from the revision recorded on the value’s SourceDoc. The review layer can then tell two fails
apart. One is backed by a confirmed value, and the other by a confirmation of a superseded revision.
isUnratified treats stale and unknown as untrustworthy, so the second item reads Provisional
(a re-confirm task) rather than a hard Fail. The state is derived at citation time rather than
stamped on the finding. So a re-seed changes every subsequent answer, and nothing has to be
re-stamped.
A user-facing walkthrough is in the datasheets guide, and the map from software concepts to the hardware nouns used here is in the analogy reference.