Vignettes 1 and 2 worked on one genome. This one asks the questions that only make sense across several: how redundantly is a capability provided, which genome is its only provider, and which chains does the community complete that no single member does.
The community
Four illustrative genomes, built around a real curated chain: the stepwise breakdown of arabinoxylan, a plant cell-wall polysaccharide that reaches the large intestine undigested.
markers <- function(namespace, accession) {
data.frame(
gene_id = paste0("gene_", seq_along(accession)),
namespace = namespace, accession = accession,
stringsAsFactors = FALSE
)
}
# A: strips arabinose side chains off the xylan backbone.
debrancher <- evaluate_gifts(markers("KO", "K01209"))
# B: cleaves the exposed backbone and releases xylose outside the cell.
backbone_degrader <- evaluate_gifts(markers("CAZY", c("GH11", "GH39")))
# C and D: take up free xylose and catabolise it. Two genomes, same capability.
consumer <- evaluate_gifts(markers(
"KO", c("K10543", "K10544", "K10545", "K01805", "K00854")
))Each supports exactly what its name says:
lapply(
list(A = debrancher, B = backbone_degrader, C = consumer),
function(x) x$gifts$gift_id[x$gifts$complete]
)
#> $A
#> [1] "arabinoxylan_debranching"
#>
#> $B
#> [1] "xylan_degradation"
#>
#> $C
#> [1] "xylose_degradation_isomerase" "xylose_uptake_abc"These four capabilities are connected in the curated database already — this is not an arrangement invented for the tutorial:
chain <- c("arabinoxylan_debranching", "xylan_degradation",
"xylose_uptake_abc", "xylose_degradation_isomerase")
edges <- gift_graph()
edges[edges$from_gift %in% chain & edges$to_gift %in% chain,
c("from_gift", "shared_anchor", "to_gift")]
#> # A tibble: 3 × 3
#> from_gift shared_anchor to_gift
#> <chr> <chr> <chr>
#> 1 arabinoxylan_debranching XYLAN xylan_degradation
#> 2 xylan_degradation XYLOSE_EX xylose_uptake_abc
#> 3 xylose_uptake_abc XYLOSE_IN xylose_degradation_isomeraseStep 1: bind the genomes together
community <- gifter_community(
A = debrancher,
B = backbone_degrader,
C = consumer,
D = consumer
)
community
#> <gifter_community> 4 genomes
#> genomes: A, B, C, D
#> GIFTs evaluated: 163
#> database version: 2026.39.1The argument names become the genome identifiers, and the container
refuses genomes evaluated against different database releases, which
would silently produce nonsense. Abundances and completeness estimates
are not part of the community: they say how the calls are to be read, so
they belong to community_traits(), which can then read one
community under more than one threshold.
Starting from one annotation table
Real annotations rarely arrive as one object per genome. When they
arrive as a single table carrying a genome_id column,
evaluate_gifts_community() does the split, the evaluation
and the binding in one call:
annotations <- data.frame(
genome_id = c("A", rep("B", 2), rep(c("C", "D"), each = 5)),
gene_id = c("g1", "g1", "g2", rep(paste0("g", 1:5), 2)),
namespace = c("KO", "CAZY", "CAZY", rep("KO", 10)),
accession = c(
"K01209", "GH11", "GH39",
rep(c("K10543", "K10544", "K10545", "K01805", "K00854"), 2)
),
stringsAsFactors = FALSE
)
evaluate_gifts_community(annotations)
#> <gifter_community> 4 genomes
#> genomes: A, B, C, D
#> GIFTs evaluated: 163
#> database version: 2026.39.1The genomes are evaluated separately and in parallel, one worker per genome up to the number of cores. That is a statement about wall time only: a genome evaluated in a community is identical to the same genome evaluated alone, and the calls do not depend on how many workers ran them.
At an interactive console the run reports itself as it goes, in
genomes evaluated out of genomes to evaluate, with an estimate of the
time remaining. The count moves genome by genome whichever worker
produced each one, so it measures the community and not the way the work
was divided. A script or a document like this one is silent unless it
asks for the display with progress = TRUE.
The genome_id column is required and is never inferred
from column order. A misread gene column mislabels evidence inside a
genome, which is why one may be proposed for approval; a misread genome
column redraws the genomes themselves, and no call in the result would
look wrong afterwards. Name a differently titled column with
genome_id = "bin".
Pooling is what this avoids. Evaluated as one genome, the same table would report the whole arabinoxylan chain as a single capability — a true statement about the collection and a false one about every member of it.
Step 2: how is capability distributed?
traits <- community_traits(
community,
frames = list(reference_frame(label = "all curated GIFTs"))
)
metrics <- traits$metricsOne frame is named here to keep the output of this tutorial short.
Omit frames and the community is read within the full
default set instead — the whole catalogue, each type, each mode, each
resource strategy, and the bounded biomass-essential anabolic set that
community_coverage is reported for.
Every frame is read over every GIFT, every genome and every pair of
genomes. The per-GIFT and per-genome part is cheap: four hundred genomes
within all fourteen frames is thirteen thousand rows and a few seconds.
The pair part is quadratic — four hundred genomes are eighty-seven
thousand pairs and two thousand are two million — so it is the one thing
a large community may not want everywhere. pairwise = FALSE
drops repertoire_overlap and leaves every other metric
untouched, and the GIFTs behind each overlap are recorded only when
asked for with pair_trace = TRUE, being one row per pair
per shared GIFT.
At an interactive console a multi-frame walk reports itself too, in
reference frames summarised out of frames to summarise. A frame is not a
fixed quantity of work — one spanning the whole catalogue takes longer
than a narrow one — so the estimate is coarser than the genome count of
the evaluation. As above, a document like this one is silent unless it
asks with progress = TRUE.
Start with the community as a whole:
metrics[metrics$target_type == "community",
c("metric_id", "value", "numerator", "denominator")]
#> # A tibble: 4 × 4
#> metric_id value numerator denominator
#> <chr> <dbl> <int> <int>
#> 1 community_richness 4 4 NA
#> 2 mean_genome_richness 1.5 6 4
#> 3 assessable_fraction 1 163 163
#> 4 singleton_fraction 0.5 2 4Four capabilities are present somewhere, while the average genome carries 1.5. Both numbers are reported rather than only their ratio, because the ratio alone hides which of the two moved.
Now per capability — how many genomes provide each?
providers <- metrics[metrics$metric_id == "provider_count", ]
providers[, c("target_id", "value", "denominator")]
#> # A tibble: 4 × 3
#> target_id value denominator
#> <chr> <dbl> <int>
#> 1 arabinoxylan_debranching 1 4
#> 2 xylan_degradation 1 4
#> 3 xylose_degradation_isomerase 2 4
#> 4 xylose_uptake_abc 2 4Two of the four capabilities rest on a single genome. That is the
singleton_fraction above: 0.5. Which genomes are those
single points?
unique_rows <- metrics[metrics$metric_id == "unique_contribution", ]
unique_rows[, c("target_id", "value")]
#> # A tibble: 4 × 2
#> target_id value
#> <chr> <dbl>
#> 1 A 1
#> 2 B 1
#> 3 C 0
#> 4 D 0A and B each provide something nobody else does. C and D are duplicates of each other, so neither is anyone’s only provider.
Read that last sentence carefully. unique_contribution
is a property of this sample, not of the organism.
Sequence one more xylose consumer and C and D stay at zero; miss B in
your assembly and its unique contribution disappears along with it.
Step 3: presence and abundance are different questions
If you have relative abundances, supply them — but note that gifter keeps them in separate rows from the presence-based counts, deliberately.
weighted_metrics <- community_traits(
community, frames = list(reference_frame(label = "all curated GIFTs")),
abundance = c(A = 0.05, B = 0.10, C = 0.35, D = 0.50)
)$metrics
comparison <- weighted_metrics[
weighted_metrics$metric_id %in% c("provider_count", "abundance_coverage"), ]
comparison[, c("target_id", "metric_id", "value")]
#> # A tibble: 8 × 3
#> target_id metric_id value
#> <chr> <chr> <dbl>
#> 1 arabinoxylan_debranching provider_count 1
#> 2 arabinoxylan_debranching abundance_coverage 0.05
#> 3 xylan_degradation provider_count 1
#> 4 xylan_degradation abundance_coverage 0.1
#> 5 xylose_degradation_isomerase provider_count 2
#> 6 xylose_degradation_isomerase abundance_coverage 0.85
#> 7 xylose_uptake_abc provider_count 2
#> 8 xylose_uptake_abc abundance_coverage 0.85Xylan degradation has one provider carrying 10% of the community; xylose uptake has two carrying 85%. Those are different facts and neither substitutes for the other. A capability held only by a rare genome is a different situation from one held only by a dominant genome, and collapsing them into a single “importance” score would destroy the distinction.
abundance_coverage is the fraction of observed genome
abundance carrying the capability. It is not a fraction
of activity, transcription, flux or ecological effect.
Step 4: who could hand what to whom
network <- community_network(community)
network
#> <gifter_network> metabolic_handoff
#> genomes: 4
#> potential handoffs: 3 (3 exact, 0 compartment-inexact)
#> composition links: 1 within a genome, 2 completed only across the community
#> database version: 2026.39.1
network$edges
#> # A tibble: 3 × 6
#> from_genome to_genome from_gift to_gift shared_anchor edge_quality
#> <chr> <chr> <chr> <chr> <chr> <chr>
#> 1 A B arabinoxylan_debranc… xylan_… XYLAN exact
#> 2 B C xylan_degradation xylose… XYLOSE_EX exact
#> 3 B D xylan_degradation xylose… XYLOSE_EX exactThree edges: A feeds B, and B feeds both consumers. Each edge names the two GIFTs and the anchor molecule that connects them, so it can be checked.
network$nodes
#> # A tibble: 4 × 4
#> genome_id provider_degree recipient_degree handoff_richness
#> <chr> <int> <int> <int>
#> 1 A 1 0 1
#> 2 B 2 1 2
#> 3 C 0 1 1
#> 4 D 0 1 1B is the hub — it receives from A and provides to both C and D.
The rule that stops this from being nonsense
C and D both encode xylose uptake and xylose catabolism, and
those two are connected in the GIFT graph through
XYLOSE_IN. So why is there no C → D edge?
anchors <- get_gift_anchors("xylose_uptake_abc")
anchors[, c("anchor_id", "role", "compartment")]
#> # A tibble: 2 × 3
#> anchor_id role compartment
#> <chr> <chr> <chr>
#> 1 XYLOSE_EX input extracellular
#> 2 XYLOSE_IN output cytoplasmicXYLOSE_IN is cytoplasmic. A molecule
inside a cell cannot be handed to another organism, so a cross-genome
edge requires the producing GIFT’s output anchor to be declared
extracellular. Without that rule the network would assert
that one bacterium passes another a metabolite that never crosses a
membrane.
The same link inside a single genome is an ordinary composition step, and it is still reported as one:
coverage <- network$chain_coverage
coverage[, c("from_gift", "shared_anchor", "status", "within_genome")]
#> # A tibble: 4 × 4
#> from_gift shared_anchor status within_genome
#> <chr> <chr> <chr> <chr>
#> 1 arabinoxylan_debranching ARABINOSE_EX not_represented ""
#> 2 arabinoxylan_debranching XYLAN community_distributed ""
#> 3 xylan_degradation XYLOSE_EX community_distributed ""
#> 4 xylose_uptake_abc XYLOSE_IN within_genome "C, D"Four statuses are possible; three of them appear here.
-
within_genome— some genome holds both ends. C and D each complete the uptake-to-catabolism step internally. -
community_distributed— no genome holds both ends, both are present, and the molecule is extracellular. This is the interesting one: the community completes a chain no member completes. -
not_represented— one half is missing entirely. A releases arabinose and nobody in this community takes it up. -
not_transferable— the two halves are in different genomes but the molecule stays inside a cell, so nothing completes the link. It does not arise above, because C and D each hold both halves. Split them and it does:
uptake_only <- evaluate_gifts(markers("KO", c("K10543", "K10544", "K10545")))
catabolism_only <- evaluate_gifts(markers("KO", c("K01805", "K00854")))
split_coverage <- community_network(
gifter_community(U = uptake_only, V = catabolism_only)
)$chain_coverage
split_coverage[split_coverage$shared_anchor == "XYLOSE_IN",
c("status", "providers", "recipients")]
#> # A tibble: 1 × 3
#> status providers recipients
#> <chr> <chr> <chr>
#> 1 not_transferable U VBoth halves are encoded, by different organisms, and the community
still completes nothing: cytoplasmic xylose does not travel. Reporting
that as community_distributed would be the error the status
exists to prevent.
What an edge is not
An edge is a potential compatibility relationship between what one genome encodes and what another encodes. It is not evidence that:
- either GIFT is expressed;
- the molecule is released at a useful concentration;
- the two organisms co-occur in space or time;
- exchange actually happens.
Cross-feeding is a hypothesis an edge can support. It is never a conclusion an edge establishes. gifter will not name it cross-feeding, and neither should a figure legend built on this output.
Step 5: fragmented genomes in a community
Real communities are assembled from MAGs of uneven quality. As in vignette 2, completeness changes how a negative call is read — and here it is resolved per genome, so one fragmented member does not contaminate everyone’s denominators.
mixed <- gifter_community(
A = debrancher, B = backbone_degrader, C = consumer
)
mixed_metrics <- community_traits(
mixed, frames = list(reference_frame(label = "all curated GIFTs")),
quality = c(A = 0.98, B = 0.96, C = 0.45),
policy = "completeness", threshold = 0.9
)$metrics
fractions <- mixed_metrics[mixed_metrics$metric_id == "provider_fraction", ]
fractions[, c("target_id", "value", "numerator", "denominator")]
#> # A tibble: 4 × 4
#> target_id value numerator denominator
#> <chr> <dbl> <int> <int>
#> 1 arabinoxylan_debranching 0.5 1 2
#> 2 xylan_degradation 0.5 1 2
#> 3 xylose_degradation_isomerase 0.333 1 3
#> 4 xylose_uptake_abc 0.333 1 3arabinoxylan_debranching is divided by 2, not 3: C is
too fragmented to say it lacks debranching, so it is not counted as a
genome that was asked. Meanwhile C’s own positive calls survive
untouched — quality only ever changes the reading of absence.
Putting the first three workflows together
annotation table
| evaluate_gifts()
v
GIFT calls ------------------------------> trace_gift()
|
| genome_traits() within a declared reference_frame()
v
genome traits
|
| gifter_community() + community_traits()
v
community traits ---------------------------> community_network()
At every step the same discipline applies: the number carries the set it was counted over, the evidence that produced it stays attached, and nothing becomes more certain than the calls underneath. A GIFT call is what a genome encodes — not what an organism is doing.