Finding your way around linf

Start with the result you need: a dominant feature for each sample, a supported grouping of samples, a deeper hierarchy, or assignments to a reference you have already fitted. These are different stopping points. You do not need to run the full pipeline to use L-infinity normalization.

The example datasets guide catalogs the data separately. For longer analyses, see the normalization and gut tutorial and vaginal microbiome tutorial.

Choose a task

Tables scroll horizontally on narrow screens. Focus a table and use the arrow keys, or swipe within it, to read every column without breaking function or dataset names.

Your task Entry point When to stop
Remove low-depth samples and low-prevalence features from counts filter.asv() You need filtered counts or unit-sum relative abundances.
Express abundance relative to each sample’s largest feature normalize.linf() You need scaled profiles, without grouping samples.
Name the largest feature in each sample linf.dominant.features() You need individual assignments, without a support threshold.
Retain supported dominance groups linf.csts() A depth-1 dCST partition answers your question.
Subdivide selected groups by remaining features refine.linf.csts() The chosen depth is sufficient; deeper is not automatically better.
Inspect stored low-support policies or select representatives dcst.view(), summary(), linf.landmarks() You need a descriptive summary of the fitted samples.
Assign new samples to an existing hierarchy transfer.dcsts() You need frozen-reference assignments, without learning new states.
Prepare coordinate charts or readable names linf.hypercube.embedding(), linf.feature.labels() You need a representation for display or further analysis.

A small workflow, with stopping points

These six samples have stable IDs that differ from the display names. Two depth-1 groups have support at least two. One parent can be split into two supported children.

X <- rbind(
  s1 = c(10, 8, 1), s2 = c(9, 7, 2),
  s3 = c(10, 1, 8), s4 = c(9, 2, 7),
  s5 = c(1, 10, 2), s6 = c(2, 9, 1)
)
colnames(X) <- c("asv_1", "asv_2", "asv_3")
labels <- c("Taxon A", "Taxon B", "Taxon C")
M <- normalize.linf(X)
dominant <- linf.dominant.features(M, feature.labels = labels)
dominant$id
#> [1] "asv_1" "asv_1" "asv_1" "asv_1" "asv_2" "asv_2"
#> asv_1
#> asv_1
#> asv_1
#> asv_1
#> asv_2
#> asv_2

fit1 <- linf.csts(M, feature.labels = labels, n0 = 2,
                  low.freq.policy = "pure")
fit2 <- refine.linf.csts(M, fit1, n0 = 2, refinement.factor = 2,
                         low.freq.policy = "pure", verbose = FALSE)
accounting <- summary(fit2)
fields <- c("depth", "n.lineages", "input.samples",
            "assigned.samples", "unassigned.samples", "rare.samples")
knitr::kable(accounting[, fields],
             col.names = c("Depth", "Groups", "Input", "Assigned", "Unassigned", "Rare"))
Depth Groups Input Assigned Unassigned Rare
1 2 6 6 0 0
2 3 6 6 0 0

Stop at M for normalized profiles or at dominant for sample-level maximum features. fit1 adds the support threshold; fit2 adds a level of subdivision. The two registered methods work through these objects: print(fit2) displays counts at each active depth, while summary(fit2) returns a data frame of lineage counts and sizes. The table accounts for all six samples at both depths: two groups become three after refinement. Summaries include separate input, assigned, unassigned and rare sample counts. Size statistics include rare groups and are NA when no samples are assigned, without warnings. For compatibility, total.samples continues to mean assigned samples; use input.samples for the full denominator.

Function catalog

On narrow screens, focus a table and scroll horizontally to read all columns. Function names remain intact for copying.

This guide describes the 0.3.1 interface: 13 explicit function exports, two registered S3 methods, and five bundled datasets. S3 methods are selected by R when you call print() or summary() on a fitted object. Neither method is also an explicit function export, so there is no overlap in these counts.

Each explicit export appears once in the catalog below. Use help("function.name", package = "linf") for complete arguments and examples, for example help("normalize.linf", package = "linf").

Prepare and assign

Function Purpose Expected input Principal result
filter.asv() Filter library size and feature prevalence. Dense samples-by-features counts; numeric data frames also accepted. List with counts, unit-sum rel, kept row/column indices, prevalence and thresholds.
normalize.linf() Divide rows by their maximum above tol. Finite, nonnegative dense or sparse matrix. Same-sized matrix; zero and below-tolerance rows remain unchanged.
linf.dominant.features() Assign each sample’s maximum feature. Finite, nonnegative matrix and optional aligned IDs/labels. Indices, IDs, labels and observed/full feature levels; optional row maxima.

Fit and inspect a hierarchy

Function Purpose Expected input Principal result
linf.csts() Retain dominance sample sets with support at least n0. Samples-by-features matrix, usually L-infinity normalized. Class linf.csts, with active, pure and absorb hierarchies.
linf.udcsts() Pool top-feature orders into unordered sets before applying support. Nonnegative samples-by-features matrix and a level. Pure set IDs and frequencies, assignments, and a sample mask; pure, absorb or filter policy.
linf.udcst.graph() Construct pair-state graphs supported by observed triples. Matrix or matching pure pair/triple fits; coverage or support selection. Nodes, unit edges, full incidence, witness similarities, faces, sample membership and components.
refine.linf.csts() Add one hierarchy level within selected active leaf lineages. Fitted object and its matrix in the original row/column order. Updated linf.csts; previous levels and feature metadata retained.
dcst.view() Activate a stored policy without recomputing it. Fitted object; view = "pure" or "absorb". Copy with active assignments switched and attached landmarks cleared.
linf.landmarks() Select observed samples by the lineage’s leaf-feature value. Fitted object, aligned matrix, depth and view. lineages metadata and landmarks selection table, with row indices/names.
linf.dcst.landmark.pipeline() Normalize, fit depth 1, refine once and select landmarks. Nonnegative matrix, feature metadata and stage-specific controls. Normalized matrix, two fitted objects, two landmark objects and parameters.

Reuse and display

Function Purpose Expected input Principal result
transfer.dcsts() Walk new samples through a frozen fitted hierarchy. Query matrix, reference fit, feature-matching and view controls. Label/ID assignment matrices, feature matches and stopping diagnostics; unassignable depths are NA.
linf.hypercube.embedding() Construct a zero-aware chart relative to one component. Nonnegative matrix with at least two features and a reference index, ID or label. Dense matrix with one fewer column; attributes record the reference and scaling.
linf.feature.labels() Format taxonomy for display while keeping separate stable IDs. Equal-length feature-ID and taxonomy vectors; optional aliases/abbreviations. Unique display labels.

Matrix and identity conventions

Rows are samples and columns are features. Give samples unique row names and features unique, nonmissing stable IDs. Keep taxonomy or other display text in a separate vector. feature.ids and feature.labels are positional: each must have length ncol(X) and describe columns in exactly that order. Metadata vector names do not perform a join. Defaults use column names as IDs, or V1, V2, … for unnamed columns, and use IDs as display labels. Fitting disambiguates duplicates with make.unique(..., sep = "_"); supplying unique IDs from the start avoids an order-dependent mapping.

For refinement and landmarks, reuse the fitted matrix’s row and column order. Both dimensions are checked. New fits save input.dimnames separately from custom feature IDs; if an axis is named in both the fit and the supplied matrix, its names/order must match. The error identifies the axis to align. There is no automatic reordering. Unnamed axes and older fits without saved input names retain the positional contract; prefer unique names because permutations within duplicated names cannot be detected. Align sample metadata with match(rownames(X), metadata$sample_id) and verify all matches before analysis. After filtering, use kept.sample.idx and kept.feature.idx to subset metadata. See the companion’s alignment example.

Core matrix functions accept ordinary dense numeric matrices and Matrix sparse matrices. backend = "auto" normally preserves sparse input; refinement and landmarks inherit the fitted backend when their backend argument is omitted. Explicit "dense" and "sparse" choose a conversion path. Sparse output is numeric compressed-column storage. Embeddings always return dense coordinates. filter.asv() is a separate dense count-filtering interface; it does not accept a sparse Matrix object directly.

Unordered dominance sets

linf.udcsts() groups rows by their highest-abundance feature sets, ignoring order. At level 1, its pure membership agrees with dominant-feature grouping. At level 2, (A, B) and (B, A) pool before applying n0. Calls at different levels are separate partitions, not necessarily a refinement hierarchy.

U <- rbind(c(A = 9, B = 5, C = 1), c(A = 8, B = 4, C = 1),
           c(A = 4, B = 9, C = 1), c(A = 3, B = 8, C = 1),
           c(A = 1, B = 3, C = 9))
unordered <- linf.udcsts(U, level = 2, n0 = 3, low.freq.policy = "filter")
unordered$frequency.table
#>    ID freq cumm.pct
#> 1 1,2    4       80
#> 2 2,3    1      100
U[unordered$keep, , drop = FALSE]
#>      A B C
#> [1,] 9 5 1
#> [2,] 8 4 1
#> [3,] 4 9 1
#> [4,] 3 8 1
unordered$feature.labels[unordered$feature.sets[1, ]]
#> [1] "A" "B"
#> A
#> B

Four rows belong to the pure set {A, B} and remain; the single {B, C} row is filtered. pure.id always retains every row’s original set. ID uses original column indices, freq counts rows before filtering, and cumm.pct is the percentage of all input rows in states with at least the current row’s frequency. Equal-size states have equal cumulative percentages.

The "pure" policy marks small states as rare while keeping their rows; "filter" excludes them through keep without reassignment. Both use missing active assignments for those rows and retain their original pure.id. "absorb" assigns a rare row to the retained set with greatest combined feature abundance. This set-based rule differs from ordered parent-local dCST absorption. Numerical tuple order resolves tied candidate scores. Absorption errors when no state survives; filtering may legitimately keep no rows. Subset all metadata with the same mask.

This constructor always uses original-column ties, rejects all-zero rows, and reports positive.features so that sets containing zero-valued ranks can be identified. It accepts counts or proportions without normalization. The output is not a linf.csts hierarchy and is not an input for its refinement, landmark, policy-view or frozen-transfer functions. See ?linf.udcsts for the full result fields and sparse-input behavior.

Filtering, scaling and grouping answer different questions

Count filtering removes samples/features using library size and prevalence. filter.asv() applies the library-size threshold first, computes prevalence among those retained samples, then drops samples left with no counts. Its rel matrix sums to one across the retained features in each row.

For a nonnegative row, L-infinity normalization instead divides by its largest entry when that maximum exceeds tol:

\[ M_{ij} = X_{ij}/\max_k X_{ik}\quad\text{when }\max_k X_{ik}>\mathrm{tol}. \]

It is neither unit-sum normalization nor a log-ratio transform. No pseudocount is added. At the default tol = 0, a positive row has maximum one; an all-zero row remains zero. With positive tolerance, a row whose maximum is at or below the tolerance is left unchanged, including its small nonzero entries. Such a row still has a dominant feature. Only exact-zero rows receive NA from linf.dominant.features().

The core matrix functions reject missing (NA, NaN), infinite and negative entries and empty matrices; they do not impute missing values. filter.asv() also validates finite nonnegative counts and threshold ranges. Fractional count-like values are accepted. Empty filtering results keep every documented field and indices that correspond to the returned matrices. With min.lib = 0, zero-total rows count toward prevalence but have no present features under a relative threshold; they are removed before returning rel.

Dominant-feature assignment and refinement depend on within-row rankings. Positive row scaling preserves those rankings. L-infinity scaling is useful for interpreting feature values but is not required to discover the maximum. The retained feature set, column order for ties, support thresholds and policy still affect the resulting grouping.

Ties and random seeds

The default tie.method = "first" selects the first exactly maximal column. "random" samples among exact ties using R’s current random-number state; these functions have no private seed and do not restore the caller’s state. Set set.seed() immediately before a reproducible random-tie call. Dense and sparse paths use the same column ordering for dominance and positive absorption ties. "error" stops at a positive tie. Zero rows do not trigger a dominance tie.

Refinement has no tie.method argument: its child fits use "first", even if the parent was fitted with random ties. The pipeline’s tie argument controls depth-1 fitting and landmark selection, not refinement. Landmark "first" selects the first tied sample in matrix row order. Frozen transfer has its own default, "support", described below.

What a dCST hierarchy stores

A dominant feature is one sample’s largest feature. Samples sharing it form a provisional dominance sample set. A set retained at support n0 becomes a named depth-1 dCST. A dominance lineage records successive feature choices as a group is refined. Depth counts stored hierarchy levels; it need not equal the number of features in every terminal lineage.

Under the pure policy, low-support samples enter a synthetic rare bucket (default RARE_DOMINANT). Under the absorb policy, they are reassigned to the retained feature with greatest abundance in that sample. If every retained feature is zero, fitting falls back to the state with greatest reference support, then lexical label order, regardless of tie method. Exact-zero rows also follow that fitting fallback. With no retained states, pure assignments are all rare and absorb assignments are all NA.

Both representations are stored. Unsuffixed fields are the active view: lineage.id/lineage.label hold current leaves; lineage.ids/lineage.labels hold a list of levels. Their .pure and .absorb counterparts preserve stored policies. dcst.view(fit2, "absorb") changes which stored hierarchy is active without fitting again, and clears attached landmarks so that summaries from a different view are not reused accidentally. size.table describes provisional depth-1 support, not current leaf sizes.

Refinement choices

With lineages.to.refine = NULL, automatic selection uses active leaf support at least refinement.factor * n0 (defaults: 2 * 50). An explicit character vector selects existing active leaf IDs, bypassing that parent size rule; it does not bypass the child support threshold. Unknown IDs and the top-level synthetic rare ID are rejected. For example:

explicit <- refine.linf.csts(
  M, fit1, lineages.to.refine = "asv_1", n0 = 2,
  low.freq.policy = "pure", verbose = FALSE
)
stopifnot(identical(explicit$lineage.ids, fit2$lineage.ids))

Within each selected active group, refinement removes features already used on its fitted path and fits the remaining columns. It appends a stored level on every call. Parents with no retained child, no remaining feature, or no selection keep their labels at that level. Repeated calls extend the hierarchy; they do not guarantee an additional feature for every sample. Parent-prefixed rare buckets at deeper levels are stored synthetic categories, not features.

Selection and child fitting use the active membership. The saved policy views are representations of that fitted history, not two independently refitted analyses. Choose the active policy before refining and keep low.freq.policy, rare.label and sep consistent across calls. Fitted objects store explicit feature paths and node keys, including IDs containing __. A real A__B feature and the refined path A then B are different nodes. When their readable strings collide, returned IDs/labels receive a [node ...] suffix; select the returned lineage.id when refining. A real feature named RARE_DOMINANT is distinct from a synthetic rare node. Synthetic or undefined parents stay terminal in their own policy view. Unambiguous older fits are upgraded on use; ambiguous legacy fits must be rebuilt from their matrices. Never split displayed strings to recover features.

Landmarks are selected observations

landmarks <- linf.landmarks(M, fit2, view = "absorb",
                           landmark.types = c("endpoint.min", "mean.rep"))
knitr::kable(landmarks$landmarks[, c(
  "lineage.label", "landmark.type", "point.name", "observed.value",
  "target.value"
) ], digits = 3)
lineage.label landmark.type point.name observed.value target.value
Taxon A__Taxon B endpoint.min s2 0.778 0.778
Taxon A__Taxon B mean.rep s1 0.800 0.789
Taxon A__Taxon C endpoint.min s4 0.778 0.778
Taxon A__Taxon C mean.rep s3 0.800 0.789
Taxon B endpoint.min s5 1.000 1.000
Taxon B mean.rep s5 1.000 1.000
# Retrieve the complete observed profiles, if needed:
profiles <- M[landmarks$landmarks$point.index, , drop = FALSE]

Landmarks target the leaf feature of each lineage at the requested depth. endpoint.max and endpoint.min select rows with the largest and smallest target-feature value. mean.rep and median.rep select the observed row closest to the mean or median of that one feature. None constructs a mean profile or a centroid across features. Several methods may choose the same row.

Values are on the scale of the supplied matrix. With M above they measure abundance relative to each sample’s row maximum, not a fraction of total abundance. At depth 1 under the pure policy, normalized dominant values are usually all one, so endpoint methods can reduce to tied selections. Under absorption the target need not be the sample’s true maximum. These summaries alone do not establish whole-community typicality or biological distinctness.

Inspect landmarks$lineages$landmarks.computable: rare categories have no unique target and are skipped. Target IDs come from explicit node paths, so separators and colliding readable paths cannot change the feature selected. Terminal lineages retain their last feature at later stored depths. Keep stable IDs and display labels separate; neither display labels nor concatenated lineage strings should be split to infer feature identities.

The optional pipeline

out <- linf.dcst.landmark.pipeline(
  X, feature.labels = labels, n0.depth1 = 2, n0.depth2 = 2,
  refinement.factor = 2, verbose = FALSE
)
names(out)
#> [1] "linf.rel"         "dcst.depth1"      "dcst.depth2"      "landmarks.depth1"
#> [5] "landmarks.depth2" "params"
#> linf.rel
#> dcst.depth1
#> dcst.depth2
#> landmarks.depth1
#> landmarks.depth2
#> params
stopifnot(identical(out$dcst.depth2$lineage.ids, fit2$lineage.ids))

Defaults are n0.depth1 = 50, n0.depth2 = 25, refinement.factor = 2, low.freq.policy = "pure", landmark.view = "absorb", tie.method = "first", sep = "__", and backend = "auto". All four landmark types are requested at both depths. The wrapper normalizes with default tolerance zero; it does not filter counts. It returns linf.rel, dcst.depth1, dcst.depth2, landmarks.depth1, landmarks.depth2 and params. Use explicit stages when you want another tolerance, selected lineages, more depths, or fewer results.

Transfer to a frozen reference

transfer.dcsts() assigns new rows to realized reference lineages, without changing their features, support counts or structure. It defaults to the absorb view, matching by feature IDs, and all fitted depths. Columns may be reordered; extra features are ignored and missing reference features are filled with zero. Pass query feature.ids in query-column order when column names are not those IDs. Label matching is optional. Unnamed matrices use synthetic V1, V2, … IDs, which require consistent positional meanings. Matching keys must be unique, nonmissing and nonempty. Duplicate keys are rejected; choose an explicit biological aggregation or disambiguation upstream. Repeated display labels remain allowed when matching by unique feature IDs.

At each depth, the query chooses among realized children using the abundance of the newly added feature. The default tie.method = "support" resolves positive ties using reference support, then lexical order. "first" uses frozen child order, not query-column order; "random" uses the current R seed; "error" stops on ties. A terminal label can recur only where it is actually stored, scored using its last feature. With no candidate or no positive candidate feature, this depth and subsequent depths are NA. There is no fitting-style zero-evidence fallback, even under view = "absorb". carry.forward.terminal.depths is retained for call compatibility; neither setting permits assignments outside the fitted hierarchy.

The original assignment matrices contain display labels even when matching by IDs. Their assignment.ids and all.depths.ids counterparts contain lineage IDs for joins using identifiers that do not encode display labels. The existing lexical display-label tie rule still applies: relabeling and refitting can change tied assignments even though the identifiers themselves are unchanged. assignment contains requested depths and all.depths contains all fitted depths. A pure rare bucket has no feature to score; it is not a learned query class. Reference fitting and frozen transfer thus need not reproduce every training assignment, especially for rare, zero or tied rows. The companion includes a separate reference/query example.

Inspect a transfer decision and saved refinement settings

query <- M[c("s1", "s5"), c("asv_2", "asv_1"), drop = FALSE]
transferred <- transfer.dcsts(query, fit2, view = "pure")
transferred$assignment.ids
#>    depth1  depth2        
#> s1 "asv_1" "asv_1__asv_2"
#> s5 "asv_2" "asv_2"
#> asv_1
#> asv_2
#> asv_1__asv_2
#> asv_2
knitr::kable(transferred$feature.match)
feature.id feature.label query.index matched
asv_1 Taxon A 2 TRUE
asv_2 Taxon B 1 TRUE
asv_3 Taxon C NA FALSE
knitr::kable(transferred$diagnostics[, c("sample.id", "assigned.depth", "reason")])
sample.id assigned.depth reason
s1 2 complete
s5 2 complete
fit2$history[[2]][c("n0", "selection.mode", "selected.lineage.ids", "tie.method")]
#> $n0
#> [1] 2
#> 
#> $selection.mode
#> [1] "automatic"
#> 
#> $selected.lineage.ids
#> [1] "asv_1"
#> 
#> $tie.method
#> [1] "first"
#> automatic
#> asv_1
#> first

The feature-match table shows that asv_3 is absent, even if both queries can complete this particular walk through other features. For an unassigned query, missing_candidate_features means every real candidate at the stopping depth was absent; no_positive_candidate_values means some candidates were present but none was positive. Check the present/missing candidate counts if some were absent. no_candidates and synthetic_only describe the frozen reference. These diagnostics describe every fitted depth even when fewer are requested. They explain the rule applied, not confidence or predictive accuracy.

The history list records the support threshold, selection, source/returned policy and tie handling for every new depth. refined.node.ids excludes selected parents that stayed terminal because no child could be retained. Switching views preserves this history. The top-level n0 remains the depth-1 setting; use the corresponding history entry for a later depth. Older fits can still be used; refining one marks previously unsaved history entries available = FALSE.

Embeddings and display labels

linf.hypercube.embedding(X, reference) needs a finite nonnegative matrix and one unambiguous reference component. For positive reference entries it forms ratios to that component, then maps their direction radially into the unit cube. For a zero reference it uses the L-infinity boundary extension; an all-zero sample maps to zero by convention. Its tolerance controls which reference entries and norms are treated as zero, unlike the leave-unchanged tolerance rule of normalize.linf().

With p input features it returns a dense samples-by-(p − 1) matrix, plus attributes recording reference identity, other feature IDs, lambda and finite/zero-reference row counts. By default lambda is scaled so the radial map reaches 0.95 at the 0.95 quantile of positive reference-ratio norms (fallback scale one if none exceed tolerance). Logarithmic calculations include norms outside the ordinary numeric range without overflow. For comparable charts of reference and query data, reuse the reference choice and log.lambda = attr(reference.embedding, "log.lambda"). The ordinary lambda attribute is NA if that scale is not representable as a positive finite number; the logarithmic value remains usable. -expm1(-t) preserves small positive radii. The result is dense by implementation choice; non-reference zeros stay zero.

This is a coordinate representation, not a clustering or predictive fit. Distances and apparent separation depend on the feature subset, reference and scaling; coordinates alone do not demonstrate biological groups or validated prediction. The companion shows a static chart. The optional interactive embedding article is website-only and is not an installed vignette.

Resource planning

Transfer and landmark calculations reuse prepared candidate positions and memberships. Embedding still returns a dense matrix even for sparse input; its numeric output alone occupies about 8 * nrow(X) * (ncol(X) - 1) bytes, before temporary storage. The installed performance note gives measured workloads, timings and memory limits of the comparison:

cat(readLines(system.file("PERFORMANCE.md", package = "linf", mustWork = TRUE)),
    sep = "\n")

Finding help and migrating older code

help(package = "linf")
help("linf-package", package = "linf")
browseVignettes("linf")
vignette("example-datasets", package = "linf")

Use lineage.id/lineage.label for leaf assignments and lineage.ids/lineage.labels for levels. The 0.2 release introduced these explicit policy hierarchies; rebuild pre-0.2 flat objects from their matrices. Use linf.dominant.features() for depth-1 assignment and dcst.view() for policy selection. Since 0.3, repeat refine.linf.csts() for additional depths, use the explicit filtering/normalization/fitting stages, and format summary tables with ordinary reporting tools. Removed cell-based aliases and former convenience functions are not part of the current API.

The organization follows the short purpose descriptions and help cross-references in Hmisc’s documentation and package overview, adapted to these few workflows. Maintainers can run make audit-guides to check the catalog against NAMESPACE, registered methods, help aliases and bundled data; vignette builds execute the examples independently.

Triple-supported pair-state graphs

linf.udcst.graph() constructs pure level-two state graphs. Both endpoint pair configurations must occur within their common leading triple. Counts always use the full input sample set, including pair states excluded by the coverage rule.

G <- rbind(c(A=6, B=3, C=1), c(A=6, B=1, C=3), c(A=1, B=6, C=3))
graph <- linf.udcst.graph(G, coverage=60, min.support=1)
graph$nodes
#>    ID freq cumm.pct node label feature.a feature.b component
#> 1 1,2    1      100    1 A + B         1         2         1
#> 2 1,3    1      100    2 A + C         1         3         1
#> 3 2,3    1      100    3 B + C         2         3         1
graph$edges[, c("pair.a", "pair.b", "triple", "min.support", "length")]
#>   pair.a pair.b triple min.support length
#> 1    1,2    1,3  1,2,3           1      1
#> 2    1,2    2,3  1,2,3           1      1
#> 3    1,3    2,3  1,2,3           1      1
graph$faces
#>   triple a b c min.support n.triple
#> 1  1,2,3 1 2 3           1        3

Coverage retains tied frequency groups together, so actual coverage can exceed the target. adjacency="shared_feature" provides a comparison without requiring both observed configurations. Increasing min.support removes edges while preserving isolated vertices. Each edge has unit path length; support and Jaccard similarities are separate attributes. A filled face requires all three pairs within a single witness triple. The function does not embed or repair the graph; use an embedding package on within-component graph distances.