--- title: "Finding your way around linf" output: rmarkdown::html_vignette: toc: true math_method: mathml vignette: > %\VignetteIndexEntry{Finding your way around linf} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include=FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>") library(linf) # Keep table rendering separate from the runnable analysis code. knitr::opts_chunk$set(render = function(x, options) { rendered <- knitr::knit_print(x, options = options) if (inherits(x, "knitr_kable")) { knitr::asis_output(paste0( '
\n\n', rendered, '\n\n
\n' )) } else rendered }) ``` 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](example-datasets.html) catalogs the data separately. For longer analyses, see the [normalization and gut tutorial](linf-intro.html) and [vaginal microbiome tutorial](linf-vaginal.html). ## 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](#filtering-scaling-and-grouping-answer-different-questions) | `filter.asv()` | You need filtered counts or unit-sum relative abundances. | | [Express abundance relative to each sample's largest feature](#a-small-workflow-with-stopping-points) | `normalize.linf()` | You need scaled profiles, without grouping samples. | | [Name the largest feature in each sample](#a-small-workflow-with-stopping-points) | `linf.dominant.features()` | You need individual assignments, without a support threshold. | | [Retain supported dominance groups](#what-a-dcst-hierarchy-stores) | `linf.csts()` | A depth-1 dCST partition answers your question. | | [Subdivide selected groups by remaining features](#refinement-choices) | `refine.linf.csts()` | The chosen depth is sufficient; deeper is not automatically better. | | [Inspect stored low-support policies or select representatives](#landmarks-are-selected-observations) | `dcst.view()`, `summary()`, `linf.landmarks()` | You need a descriptive summary of the fitted samples. | | [Assign new samples to an existing hierarchy](#transfer-to-a-frozen-reference) | `transfer.dcsts()` | You need frozen-reference assignments, without learning new states. | | [Prepare coordinate charts or readable names](#embeddings-and-display-labels) | `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. ```{r first-workflow} 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 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")) ``` 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](example-datasets.html#align-data-and-metadata). 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. ```{r unordered-states} 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 U[unordered$keep, , drop = FALSE] unordered$feature.labels[unordered$feature.sets[1, ]] ``` 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: ```{r explicit-refinement} 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 ```{r landmark-example} 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) # 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 ```{r pipeline} out <- linf.dcst.landmark.pipeline( X, feature.labels = labels, n0.depth1 = 2, n0.depth2 = 2, refinement.factor = 2, verbose = FALSE ) names(out) 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](example-datasets.html#reference-and-query). ### Inspect a transfer decision and saved refinement settings ```{r transfer-records} query <- M[c("s1", "s5"), c("asv_2", "asv_1"), drop = FALSE] transferred <- transfer.dcsts(query, fit2, view = "pure") transferred$assignment.ids knitr::kable(transferred$feature.match) knitr::kable(transferred$diagnostics[, c("sample.id", "assigned.depth", "reason")]) fit2$history[[2]][c("n0", "selection.mode", "selected.lineage.ids", "tie.method")] ``` 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](https://pgajer.github.io/linf/articles/valencia-hypercube-embedding.html) 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: ```{r performance-location, eval=FALSE} cat(readLines(system.file("PERFORMANCE.md", package = "linf", mustWork = TRUE)), sep = "\n") ``` ## Finding help and migrating older code ```{r help-navigation, eval=FALSE} 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](https://hbiostat.org/r/hmisc/) and [package overview](https://CRAN.R-project.org/package=Hmisc), 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. ```{r triple-graph} 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 graph$edges[, c("pair.a", "pair.b", "triple", "min.support", "length")] graph$faces ``` 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.