Docs / FaultFlow ↔ OpenTestability Preflight Protocol

This document is for the FaultFlow session: it describes the machine interface OT exposes so FaultFlow can obtain structural reconvergence data before ATPG without inserting test points.


Overview

OT exposes an internal CLI subcommand _preflight (underscore prefix = not for end users). FaultFlow calls it as a subprocess, parses the stdout JSON line, and reads two output files:

opentest --yosys _preflight \
  -i /path/to/netlist.json \
  --scoap \
  -r \
  --reconv-algorithm advanced \
  -o /path/to/workspace

Recommended flags:

-i and -o may be relative: both are resolved against the caller’s working directory (FaultFlow’s scan pipeline passes cwd-relative paths such as output/<top>/.faultflow/intermediate/preflight). -i must name an existing file; it is never looked up in OpenTestability’s own data/ directories. Everything the run writes goes under -o (see Files Written).


Stdout Contract

OT prints exactly one JSON object on stdout as the last output line:

{
  "status": "ok",
  "manifest": "/abs/path/to/tpi_manifest.json",
  "reconv_ids": "/abs/path/to/fanout_design_reconv_ids.json",
  "tech": "sky130"
}

On error:

{"status": "error", "message": "...reason..."}

FaultFlow should:

  1. Run the command, capture stdout
  2. Find the last line starting with {
  3. Parse JSON, check status == "ok"
  4. Use the returned manifest and reconv_ids paths

Output Files

tpi_manifest.json

Written at <output_dir>/tpi_manifest.json. Schema version "1.0".

Key fields for FaultFlow:

{
  "schema_version": "1.0",
  "session_id": "ot_1234567890_abcd1234",
  "iteration": 1,
  "top_module": "fanout_design",
  "netlist_path": "/abs/path/to/netlist.json",
  "inserted_test_points": [],
  "structural_hints": {
    "fanout_points": [
      {"name": "net_foo", "yosys_net_id": 42},
      {"name": "net_bar", "yosys_net_id": 107},
      {"name": "internal_ot_name", "yosys_net_id": null}
    ],
    "reconvergences": [...],
    "enabled": true,
    "algorithm": "advanced"
  },
  "awaiting_oracle": true,
  "response_path": "/abs/path/to/oracle_response.json"
}

structural_hints.fanout_points

list[dict]; each entry is:

Field Type Notes
name string OT signal name (from circuit graph)
yosys_net_id integer \| null netnames[name]["bits"][0], the first integer in Yosys bits list. null if signal not in Yosys netnames or if all bits are string constants ("0", "1")

Mapping: FaultFlow maps yosys_net_id → its internal compiled net index via the existing Yosys net-ID lookup it already uses for other purposes. Signals with null net_id are OT internal synthesis names; skip or ignore them.

FaultFlow action (ordering only): before the ATPG round loop, push any fault whose net_id is in this set to the back of the ordering queue and skip the short-timeout tier (reconvergent-site faults tend to be hard; don’t burn short attempts on them). This changes the order in which SAT sees faults and nothing else: membership in this set is never evidence that a fault is redundant (see What the records mean).


<design>_reconv_ids.json

Written at <output_dir>/<design>_reconv_ids.json. This is the algorithm output enriched with Yosys net IDs on every reconvergence record.

The record shape depends on which --reconv-algorithm produced it (basic, simple, advanced, or dominator, echoed back as structural_hints.algorithm in tpi_manifest.json). These are not one shared format: three are flat per-pair dicts with different field names, and one (advanced) nests pairs one level deeper under each fanout site. Branch on structural_hints.algorithm, not on which fields happen to be present.

Comparison

  basic simple advanced (per entry in pairs[]) dominator
Grouping one dict per pair one dict per pair one dict per site, pairs nested in pairs[] one dict per (stem, site) pair
site ✓ ✓ ✓ (site-level, not per-pair) ✓
Branch fields branch1, branch2 (raw signal names) fanout1, fanout2 (raw signal names, not branch1/branch2) branch1, branch2 (synthetic branch-id strings, e.g. "sig_A_br0", not raw net names) branch1, branch2 (raw signal names)
stem - - ✓ ✓
path1 / path2 (node list) ✓ ✓ - ✓ (null if budget-exhausted)
path1_length / path2_length (int) - ✓ - ✓ (null if budget-exhausted)
path1_count / path2_count (int) - - ✓ (path-multiplicity counts from FOBL, a different concept from path*_length) -
cone (node list) - - - ✓ (null if budget-exhausted)
paths_materialized (bool) - - - ✓
Sibling data - - top-level fanout_branches[] (see below) -

basic and simple look similar but are easy to conflate incorrectly: basic has no stem and no length fields at all; simple uses fanout1/fanout2 instead of branch1/branch2 and adds integer path1_length/path2_length. Treat them as two distinct formats, not one “basic/simple” format.

basic: flat, minimal

{
  "site": "gate_Y", "site_net_id": 42,
  "branch1": "sig_A", "branch1_net_id": 55,
  "branch2": "sig_B", "branch2_net_id": 56,
  "path1": ["sig_A", "mid_1", "gate_Y"],
  "path2": ["sig_B", "mid_2", "gate_Y"]
}

simple: flat, with path lengths, different branch-field names

{
  "site": "gate_Y", "site_net_id": 42,
  "fanout1": "sig_A", "fanout1_net_id": 55,
  "fanout2": "sig_B", "fanout2_net_id": 56,
  "path1": ["sig_A", "mid_1", "gate_Y"],
  "path2": ["sig_B", "mid_2", "gate_Y"],
  "path1_length": 3, "path2_length": 3
}

There is no branch1/branch2 in this shape; the fanout-source fields are named fanout1/fanout2 instead.

{
  "site": "gate_Y", "site_net_id": 42,
  "pairs": [
    {
      "branch1": "sig_A_br0", "branch1_net_id": null,
      "branch2": "sig_A_br1", "branch2_net_id": null,
      "path1_count": 2, "path2_count": 1,
      "stem": "sig_A", "stem_net_id": 50
    }
  ]
}

branch1/branch2 here are synthetic branch-id strings minted by the FOBL algorithm (e.g. "sig_A_br0"), not raw net/signal names, so branch1_net_id/branch2_net_id will almost always come back null (the lookup searches Yosys netnames for the literal string and won’t find it). Don’t read a null here as “signal missing from netlist”; it’s expected for this shape. path1_count/path2_count are path-multiplicity counts, not path lengths; don’t conflate with simple’s path1_length/path2_length. There is no path1/path2 node list in this shape at all. stem/stem_net_id name the fanout stem whose two branches reconverge at site.

There is also a top-level fanout_branches array, a sibling of reconvergences (not nested inside it, specific to the advanced algorithm’s output), enumerating every fanout branch in the whole circuit (not pairs):

{
  "reconvergences": [ ... ],
  "fanout_branches": [
    {"branch_id": "sig_A_br0", "stem": "sig_A", "target": "gate_Y"}
  ]
}

The enrichment step only walks reconvergences (and pairs within it); it does not touch fanout_branches, so these entries pass through into <design>_reconv_ids.json with no *_net_id fields added, even though stem appears here too.

dominator: flat, one dict per (stem, site) pair, budget-aware

{
  "site": "gate_Y", "site_net_id": 42,
  "stem": "sig_A", "stem_net_id": 50,
  "branch1": "sig_A_b1", "branch1_net_id": 55,
  "branch2": "sig_A_b2", "branch2_net_id": 56,
  "path1": ["sig_A", "mid_1", "gate_Y"],
  "path2": ["sig_A", "mid_2", "gate_Y"],
  "path1_length": 3, "path2_length": 3,
  "cone": ["sig_A", "mid_1", "mid_2", "gate_Y"],
  "paths_materialized": true
}

If the detector’s path-materialization time budget is exhausted before reaching a given (stem, site) pair, the record is still emitted, with the path/cone fields nulled out:

{
  "site": "gate_Z", "site_net_id": 61,
  "stem": "sig_B", "stem_net_id": 70,
  "branch1": "sig_B_b1", "branch1_net_id": 80,
  "branch2": "sig_B_b2", "branch2_net_id": 81,
  "path1": null, "path2": null,
  "path1_length": null, "path2_length": null,
  "cone": null, "paths_materialized": false
}

site/stem/branch1/branch2 (and their _net_id counterparts) are always populated, even in the budget-exhausted case.

What gets a *_net_id field

The enrichment step adds a *_net_id field for exactly these source fields, wherever they appear on a record (recursing one level into advanced’s pairs[]): site, branch1, branch2, fanout1, fanout2, stem. It’s defensive across all four shapes: a field is simply skipped if absent from a given record, and it never raises on an unrecognized shape.

Never enriched (not signal names, so no net-ID lookup applies): path1, path2, path1_length, path2_length, path1_count, path2_count, cone, paths_materialized.

What the records mean

Every record is a structural ordering hint, never a redundancy proof. A record says only that two branches of a fanout stem meet again at site. None of the four detectors checks whether the reconverging paths cancel, or analyzes testability at all: advanced (Xu & Edirisuriya’s FOBL/RFOBL lists) enumerates reconvergent fanout-branch pairs and nothing more, and on picorv32a its stem set equals its fanout_points set. Reconvergence is necessary for a stem fault to be redundant, not sufficient. Treating these records as proof, e.g. certifying every sa0/sa1 fault on a reconvergent stem as UNSAT without running SAT, is unsound. On picorv32a all 1091 faults that rule would mark (36 stems plus their branches) are testable: SAT finds a simulation-verified test for every one.

So use the records to order work (as the ordering action under structural_hints.fanout_points above does) and leave every verdict to SAT: only an UNSAT result makes a fault redundant. FaultFlow’s own preflight consumer reads nothing but the yosys_net_ids of structural_hints.fanout_points.


Net ID Semantics

OT derives yosys_net_id as:

bits = netnames[signal_name]["bits"]
net_id = next((b for b in bits if isinstance(b, int)), None)

This matches Yosys’s internal bit numbering. String entries like "0" and "1" represent constant tie-offs and are skipped. The returned integer is directly usable as a Yosys net index.


Netlists without port directions

OT reads each cell’s data flow (which connected nets it reads, which it drives) from the cell’s port_directions. Yosys only writes those when it knows the cell’s interface, so a netlist written without the cell library loaded (no read_liberty -lib before write_json) has none: FaultFlow’s examples/picorv32_synth/picorv32a_sky130.json has 0 of 12,602. OT then takes each pin’s direction from, in order: the cell’s own port_directions; a module of that cell type defined in the netlist; and built-in pin tables for the four --tech standard-cell libraries (sky130_fd_sc_hd, IHP sg13g2, Nangate, osu035), generated from their Liberty files. --tech picks the table tried first; the others are still tried, since cell names are distinct across the four libraries.

Pins none of these cover are left out of the DAG and the metrics, and OT logs a warning on stderr naming the affected cell types and counts. The run still succeeds, but its results then cover only part of the design.

Test point insertion (analyze_and_add_tp, including the --atpg-loop flow) takes pin directions the same way, both to move a control point’s readers onto its output and to validate the result. It skips a control point whose net touches a pin none of these cover, since it cannot tell whether that pin reads the net; the skipped point is logged and listed under rejected_test_points in the TPI report.


Runtime on large designs

The advanced detector keeps, for every net, the fanout branches in its fan-in cone and, at every multi-input gate, pairs the entries of its inputs’ lists that share a stem. Each entry is looked up by stem instead of being compared with the whole other list, so the work grows with the total length of the lists, not with products of their lengths. On the full picorv32a netlist above (12,602 sky130 cells, 22,873 fanout branches, 2.5 million list entries in all) the detector takes about 3 s of CPU on an idle machine, and a whole --reconv-algorithm advanced preflight about 12 s (6 s of CPU), writing 15 MB (tpi_manifest.json is 2.5 MB of it). On a heavily loaded machine (load average ~28 on 10 cores) FaultFlow’s own call still returned in 93 s, inside the 120 s timeout it gives the subprocess. dominator finished the same netlist in 154 s wall / 70 s CPU on a heavily loaded machine, writing about 370 MB of JSON: the manifest, the reconv-IDs file and the DAG file each carry every reconvergence record. Small designs take seconds with either algorithm.


Full Example Invocation

# Preflight before ATPG: ordering hints for the fault queue
opentest --yosys _preflight \
  -i /workspace/design.json \
  --scoap \
  -r \
  --reconv-algorithm advanced \
  -o /workspace/preflight_out \
  --tech sky130

# Parse stdout to get paths
RESULT=$(opentest --yosys _preflight ... 2>/dev/null | tail -1 | python3 -c "
import sys, json
d = json.load(sys.stdin)
if d['status'] != 'ok':
    raise SystemExit(d['message'])
print(d['manifest'])
print(d['reconv_ids'])
")

Or in FaultFlow’s Rust/C++ subprocess handler:

  1. Spawn opentest --yosys _preflight ...
  2. Collect stdout lines
  3. Find last line matching /^\{/
  4. Parse JSON
  5. On status == "ok": read manifest and reconv_ids paths

Backward Compatibility


Files Written (summary)

File Content
<output_dir>/tpi_manifest.json Session manifest with enriched fanout_points
<output_dir>/<design>_reconv_ids.json Reconvergence records with *_net_id fields (only with -r)
<output_dir>/<design>_scoap.json SCOAP metrics (<design>_cop.json with --cop)
<output_dir>/<design>_yosys_dag_<algorithm>.json DAG with reconvergence data (only with -r); reused by the next run while it is newer than the netlist

Nothing is written outside <output_dir>, and the input netlist is never modified.