scripts/audit_causal_graphs.py reports DANGLING_EDGE (an edge naming a node that does not exist) and ORPHAN_NODE (a node no edge references at all). Neither fires when every node has at least one edge but the graph still splits into mutually unreachable components — which is the common case here, because a node picks up an edge to a neighbour long before it is wired back toward the trait node.
So the audit-graphs gate is green while most of the corpus is fragmented.
Measured
Over the full corpus on 2026-07-30 — 353 causal graphs, 4,136 nodes, treating edges as undirected for the connectivity test:
- 220 graphs (62%) have more than one connected component.
- 1,264 nodes (30%) sit outside their graph's largest component.
Worst cases, all with 12 nodes stranded outside the main body:
| trait |
nodes |
components |
physiology/lithotrophic.yaml |
19 |
7 |
environment/temperature_range_very_low.yaml |
15 |
7 |
environment/ph_delta_mid2.yaml |
15 |
7 |
morphology/black_pigmented.yaml |
18 |
6 |
environment/temperature_range_mid4.yaml |
17 |
6 |
environment/temperature_delta_high.yaml |
15 |
6 |
The generated environment/physiology traits dominate the tail.
This is a content gap — missing edges — not a schema defect. That is precisely why nothing currently reports it.
Two independent pieces of work
1. Add a connectivity defect to the audit. A FRAGMENTED_GRAPH and/or UNREACHABLE_FROM_TRAIT check alongside the existing two, reported in reports/causal_graph_audit.tsv.
It must land non-blocking first. At 62% it would fail just qc on day one, so shipping it as a hard gate is not an option until the backfill is well advanced.
A design decision to make explicitly: is the invariant "the graph has exactly one component", or the stronger "every node is reachable from the trait node"? The second is what actually matters for interpretation — a well-connected subgraph that never links back to the trait is still useless — but it is also the harder bar.
2. Backfill the missing edges, per trait, with real evidence. This is deep-research work (research-causal-graphs / deep-research-trait), not something to mass-generate: every added edge needs a reference and a snippet matching the existing ones. Mass-generating plausible edges would defeat the point of the graphs.
Worked example already in hand
data/traits/metabolism/cellulolysis.yaml was fixed as the first instance and is where this was found. Its graph split into 4 components with 9 of 14 nodes unreachable from the trait node. The repair added 7 evidence-backed edges (cellulose → cellobiose, cellobiohydrolase → cellobiose, endoglucanase / cellobiohydrolase / beta_glucosidase part_of cellulase, cellulosome → enables → trait, cellulolytic_genes → encodes → cellulase), grounded cellobiose to CHEBI:17057, and retyped cellulosome from CELLULAR_LOCALIZATION to GENE_OR_PROTEIN (it is a complex, not a location; its GO:0043263 grounding is unchanged).
Its own deferred remainder, worth carrying forward as the template for what "done" looks like per trait: the LPMO / CBM / transport / phosphorolysis branches, splitting cellobiohydrolase by chain end, and replacing the generic catabolite-repression edge with taxon-specific cip-cel evidence.
Reproducing the numbers
Load each causal_graphs block, build an undirected adjacency over nodes[].node_id from edges[].subject/object, and count components per graph. Recorded here so the baseline can be re-measured after any backfill batch.
scripts/audit_causal_graphs.pyreportsDANGLING_EDGE(an edge naming a node that does not exist) andORPHAN_NODE(a node no edge references at all). Neither fires when every node has at least one edge but the graph still splits into mutually unreachable components — which is the common case here, because a node picks up an edge to a neighbour long before it is wired back toward the trait node.So the
audit-graphsgate is green while most of the corpus is fragmented.Measured
Over the full corpus on 2026-07-30 — 353 causal graphs, 4,136 nodes, treating edges as undirected for the connectivity test:
Worst cases, all with 12 nodes stranded outside the main body:
physiology/lithotrophic.yamlenvironment/temperature_range_very_low.yamlenvironment/ph_delta_mid2.yamlmorphology/black_pigmented.yamlenvironment/temperature_range_mid4.yamlenvironment/temperature_delta_high.yamlThe generated environment/physiology traits dominate the tail.
This is a content gap — missing edges — not a schema defect. That is precisely why nothing currently reports it.
Two independent pieces of work
1. Add a connectivity defect to the audit. A
FRAGMENTED_GRAPHand/orUNREACHABLE_FROM_TRAITcheck alongside the existing two, reported inreports/causal_graph_audit.tsv.It must land non-blocking first. At 62% it would fail
just qcon day one, so shipping it as a hard gate is not an option until the backfill is well advanced.A design decision to make explicitly: is the invariant "the graph has exactly one component", or the stronger "every node is reachable from the trait node"? The second is what actually matters for interpretation — a well-connected subgraph that never links back to the trait is still useless — but it is also the harder bar.
2. Backfill the missing edges, per trait, with real evidence. This is deep-research work (
research-causal-graphs/deep-research-trait), not something to mass-generate: every added edge needs a reference and a snippet matching the existing ones. Mass-generating plausible edges would defeat the point of the graphs.Worked example already in hand
data/traits/metabolism/cellulolysis.yamlwas fixed as the first instance and is where this was found. Its graph split into 4 components with 9 of 14 nodes unreachable from the trait node. The repair added 7 evidence-backed edges (cellulose → cellobiose,cellobiohydrolase → cellobiose, endoglucanase / cellobiohydrolase / beta_glucosidasepart_ofcellulase,cellulosome → enables → trait,cellulolytic_genes → encodes → cellulase), groundedcellobiosetoCHEBI:17057, and retypedcellulosomefromCELLULAR_LOCALIZATIONtoGENE_OR_PROTEIN(it is a complex, not a location; itsGO:0043263grounding is unchanged).Its own deferred remainder, worth carrying forward as the template for what "done" looks like per trait: the LPMO / CBM / transport / phosphorolysis branches, splitting
cellobiohydrolaseby chain end, and replacing the generic catabolite-repression edge with taxon-specificcip-celevidence.Reproducing the numbers
Load each
causal_graphsblock, build an undirected adjacency overnodes[].node_idfromedges[].subject/object, and count components per graph. Recorded here so the baseline can be re-measured after any backfill batch.