Skip to contents

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_isomerase

Step 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.1

The 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.1

The 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$metrics

One 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           4

Four 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           4

Two 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             0

A 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.85

Xylan 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     exact

Three 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                1

B 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 cytoplasmic

XYLOSE_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         V

Both 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           3

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

sum(mixed$matrix[, "C"] %in% TRUE)
#> [1] 2

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.