Nothing
#' @rdname build_lib
#' @title Build spectral libraries
#'
#' @description
#' Create reference libraries from source files and OpenSpecy objects.
#' When \code{output_dir} is supplied, \code{build_lib()} runs the official
#' end-to-end workflow and returns raw,
#' processed, medoid, model, and assessment artifacts in one object. Supporting
#' functions remain available for advanced composition.
#'
#' @details
#' \code{build_lib()} combines sources over their full wavenumber range,
#' optionally adds ordinary and hierarchical metadata, removes requested
#' identifiers, optionally generates stable source-stage duplicate IDs, and
#' applies named processing recipes. Source-stage IDs follow the reference
#' library's legacy hash recipe: each source spectrum is trimmed with
#' \code{\link{manage_na}(type = "remove")}, conformed at resolution 8,
#' smoothed, and hashed from the resulting wavenumber/intensity vectors before
#' later merging and range restriction. The older 100--4000 cm-1 hash is kept in
#' \code{sample_name_old} when \code{id_col = "sample_name"} so
#' \code{exclude_ids} can remove both current and legacy curated bad IDs.
#' Metadata column names are first converted to lowercase
#' underscore names and known aliases are coalesced using
#' \code{metadata_name_lookup}; see \code{\link{lib_clean_metadata}()} for
#' automatic and regular-expression matching. Metadata values can optionally be
#' normalized to lowercase trimmed character values before lookup joins.
#' \code{spectrum_identity} is also reduced to a basename when it is a
#' recognizable path, then trailing extensions supported by
#' \code{\link{read_any}()} are removed. The same normalization is applied to
#' exact lookup keys. Regex class rules belong in a separate table and can be
#' applied afterward with \code{\link{predict_class_reference}()}.
#' This keeps filenames usable as identities without treating file containers
#' as part of a material name. By default, each source is also
#' converted to absorbance before merging when its intensity units are known.
#' A nonempty \code{intensity_unit} object attribute takes precedence over the
#' per-spectrum \code{intensity_units} metadata column. Each recipe is either a
#' named list of arguments passed to \code{\link{process_spec}()} or a function
#' accepting one \code{OpenSpecy} object. An empty recipe returns an unprocessed
#' copy. Signal-to-noise is added by default, and optional
#' \code{\link{assess_spec}()} results are summarized into one metadata row per
#' spectrum.
#' Progress messages report named stages and elapsed time by default so
#' long-running builds remain observable.
#'
#' The official workflow requires explicit source paths and an output
#' directory. When \code{workflow_data} is omitted, \code{build_lib()} looks
#' for the curated helper tables under \code{data/} beside the calling script,
#' then under \code{data/} or \code{workflows/data/} in the current working
#' directory. It writes each completed stage under
#' \code{output_dir/checkpoints}, and promotes validated legacy-compatible files
#' into a versioned release directory. With \code{reuse = TRUE}, a checkpoint
#' is reused only when its manifest signature matches the source files, curated
#' tables, relevant arguments, package version, and builder implementation.
#' Full assessments use the complete candidate and legacy artifacts. Seeded
#' ten-percent holdouts are allocated independently within each source across
#' class/type strata after physical identifiers and exact spectral-content
#' duplicates have been joined into stable groups. Candidate artifacts are assessed on candidate data and
#' legacy artifacts on legacy data, so taxonomy changes do not require fuzzy
#' cross-version class matching. Query identifiers are removed from full and
#' references by group before matching to prevent transformed duplicate or
#' physical-replicate self-matches. Each medoid artifact separately identifies
#' its complete corresponding processed library, measuring the deployed medoid
#' search directly without another split. Model assessments do not retrain models:
#' each existing candidate or legacy model identifies its complete corresponding
#' source dataset once. This measures the deployed artifact directly and keeps
#' assessment generation bounded by prediction rather than model fitting.
#' After derivative and baseline-removal processing, FTIR spectra whose
#' 2200--2420 CO2-region maximum exceeds twice the 2420--2550 silent-region
#' maximum are flattened and reassessed; failed postconditions are removed.
#' High-tail checks use each spectrum's finite support;
#' tails are trimmed and reassessed, failed corrections are removed, and
#' spectra with running signal-to-noise below two are removed before pruning.
#' Full artifacts are then partitioned into Raman (200--4000), FTIR
#' (400--4000), and NIR (4000--12000) \code{OpenSpecy} objects.
#' After pruning and class reassignment, the official workflow derives
#' \code{material_form} from the curated form-regex CSV and all atomic metadata
#' values, then joins evidence-backed \code{common_use} by final material class.
#' Existing form values take precedence when uniquely standardized; conflicting
#' form categories remain missing and are audited. Common use may be
#' \code{"consumer"}, \code{"industrial"}, \code{"mixed"}, or missing;
#' quantitative mass shares are retained when available, while sourced
#' qualitative proposals remain explicit in the review table.
#' After full and medoid libraries are complete, metadata columns containing
#' only missing values are removed, except that these two standardized fields
#' are retained, and the remainder are stably ordered from the fewest to the
#' most missing values. Spectra, metadata rows, identifiers, axes, and object
#' attributes are unchanged.
#' Official class completion temporarily assigns unresolved identities to
#' \code{"other"}. By default, spectra with a blank identity or that unresolved
#' literal class are removed before quality control and retained in the
#' \code{other_review} assessment table. Reviewed \code{"other plastic"} and
#' \code{"other material"} rows stay in the reference library and enter
#' \code{prune_lib()}'s nearest-class semisupervised pathway.
#' When requested, \code{prune_lib()} first resolves high-correlation conflicts
#' between different reviewed classes within each source library. It repeatedly
#' removes the spectrum with the most active wrong-class neighbors, recalculates
#' after each round, and removes both endpoints of an adjacent maximum-score
#' tie. It then resolves between-library conflicts by the number of distinct
#' independent libraries supporting the opposing class. Generic and
#' unclassified labels do not participate. Official builds repeat closure on
#' the rounded typed and model-range views before deriving medoids, and retain
#' excluded spectra plus conflict provenance in
#' \code{quarantined_spectra.rds}. Pruning then reassigns generic classes by nearest same-technique
#' correlation: \code{"other"} may use any established class,
#' \code{"other plastic"} requires a plastic candidate, and
#' \code{"other material"} requires \code{"organic matter"} or
#' \code{"mineral"}. The matched material type and a correlation audit are
#' retained. Once those labels are resolved, each class/spectrum-type group with
#' fewer than \code{min_n} spectra is reassigned as a whole to its
#' most-correlated established class in the same technique pool and material
#' type. A group is removed only when no eligible correlated destination
#' exists. The report identifies its support, destination, class-level
#' correlation, action, reason, and affected spectrum identifiers.
#'
#' \code{make_lib_lookup_template()} creates a deduplicated table of metadata
#' values from an \code{OpenSpecy} or \code{Specs} object. Users can fill the
#' added columns in R or write the template to CSV and curate it elsewhere.
#'
#' \code{join_lib_metadata()} left-joins lookup columns onto object metadata and
#' reports unmatched metadata keys, duplicate lookup keys, and missing joined
#' values. Joins are exact; clean or harmonize values before calling this helper.
#'
#' \code{join_material_hierarchy()} joins user-defined hierarchical material
#' metadata. The supplied \code{levels} are tried from most-specific to
#' most-general so a material label can match any level in the hierarchy.
#'
#' \code{dedupe_spec()} hashes the current spectra and wavenumber axis to create
#' stable IDs and remove duplicated spectra. Process or conform spectra before
#' this step when that should affect duplicate detection.
#'
#' \code{reduce_lib()} uses PAM medoids to keep representative spectra within
#' each metadata group. It uses OpenSpecy's optimized correlation routine on
#' relative spectra whose missing values are temporarily replaced by each
#' spectrum's finite mean. Groups of at most 3,000 spectra use exact PAM;
#' oversized groups use five deterministic 1,000-spectrum PAM samples and keep
#' the candidate set with the best full-group correlation-distance objective.
#' Official medoids are then selected from the original object so their genuine
#' missing values are preserved.
#'
#' \code{train_spec_model()} trains either OpenSpecy's multinomial logistic
#' regression model (\code{method = "logistic_regression"}) or an experimental
#' probability random forest (\code{method = "random_forest"}). Logistic
#' regression uses inverse class weights and stratified cross-validation to
#' select the lambda with the highest out-of-fold macro class accuracy. Random
#' forest uses inverse-frequency balanced case sampling and out-of-bag
#' predictions. This improves minority-class representation in each bootstrap
#' sample without applying a second class-vote correction.
#' Treat the stored out-of-bag metrics as fit diagnostics: balanced resampling
#' can make them optimistic. The library workflow separately reports accuracy
#' from applying each deployed model to its complete corresponding dataset.
#' Missing training values are replaced with the finite mean at each
#' wavenumber. The returned model carries the same training-mean filler so
#' \code{\link{match_spec}()} can identify partially covered spectra.
#' \code{build_model_lib()} is the backward-compatible wrapper used by older
#' scripts; \code{build_lib()} calls the dedicated trainer for official models.
#'
#' \code{assess_lib()} returns a compact summary of object validity, library
#' size, class balance, and optionally nearest-neighbor class consistency.
#'
#' \code{rebuild_lib_artifacts()} starts from completed type-keyed libraries
#' and rebuilds only medoids, models, and assessments. Its input and output
#' locations are explicit, and every downstream component is checkpointed so a
#' compatible interrupted run can resume without repeating completed work.
#' Checkpoint and release payloads carry SHA-256 hashes, and an existing
#' versioned release path is accepted only when its payload is unchanged.
#'
#' @param x an \code{OpenSpecy} or \code{Specs} object for metadata helpers.
#' For \code{build_lib()}, one \code{OpenSpecy}, a nonempty list containing only
#' \code{OpenSpecy} objects, or a nonempty character vector of file paths.
#' The official workflow requires \code{x}; it does not guess source-library
#' locations.
#' Each RDS path may store either one \code{OpenSpecy} or a list of them; other
#' paths are read with \code{\link{read_any}()}.
#' Large same-axis source lists are prepared in bulk to avoid repeated legacy
#' object coercion.
#' For \code{rebuild_lib_artifacts()}, a completed four-part build object, its
#' RDS path, or a build/output directory containing completed \code{libraries}
#' checkpoints.
#' @param lookup a data.frame, data.table, or csv file path used as a metadata
#' lookup table.
#' @param by named character vector mapping metadata columns to lookup columns,
#' or an unnamed character vector when the names are the same in both tables.
#' @param columns metadata columns to deduplicate into a template.
#' @param add blank columns to add to a template.
#' @param path optional csv path. If \code{NULL}, template helpers return a
#' data.table.
#' @param hierarchy a data.frame, data.table, or csv file path with hierarchical
#' material metadata.
#' @param key_col metadata column containing material labels to match.
#' @param levels hierarchy columns ordered from most-specific to most-general.
#' @param output_names names to use for hierarchy columns added to metadata.
#' @param require_complete logical; if \code{TRUE}, incomplete joins fail.
#' @param return whether to return an updated \code{OpenSpecy} object, joined
#' table, report list, or selected ids depending on the helper.
#' @param suffixes suffixes used when joined metadata and lookup tables share
#' non-key column names.
#' @param id_col metadata column used as the spectrum identifier.
#' @param exclude_ids identifiers to remove before returning a library.
#' @param duplicate how duplicated generated identifiers should be handled.
#' @param scale numeric multiplier used before hashing intensity values.
#' @param algo hash algorithm passed to \code{\link[digest]{digest}()}.
#' @param recipes named list of \code{\link{process_spec}()} argument lists or
#' functions. Names become names of the returned libraries.
#' @param dedupe logical; whether to generate stable IDs and remove duplicated
#' spectra in \code{build_lib()}.
#' @param range,res wavenumber range and resolution passed to \code{c_spec()}
#' when \code{build_lib()} combines multiple sources.
#' @param restrict_range_args optional named list of arguments passed to
#' \code{\link{restrict_range}()} after unit conversion and source merging.
#' Supplying the list triggers restriction; \code{make_rel = FALSE} is used
#' unless explicitly overridden.
#' @param metadata_lookups a lookup table, csv path, or list of lookup tables and
#' paths. A lookup may instead be supplied as \code{list(lookup = x, by = key)}
#' to use an explicit key (including a named metadata-to-lookup key mapping).
#' \code{fill_only = TRUE} preserves existing nonblank metadata values while
#' filling gaps from the lookup. The older \code{fallback_by} lookup field is
#' deprecated; canonical metadata keys are now filled from reviewed internal
#' aliases before any external join.
#' If non-\code{NULL}, each is joined with
#' \code{join_lib_metadata()}. Automatic ordinary lookups use the single shared
#' column that has overlapping values and unique lookup keys. Lookups with no
#' usable shared key are skipped with a message; lookups with multiple usable
#' shared keys are considered ambiguous and stop. Lookup values that share
#' non-key metadata column names are coalesced back into those columns, with
#' non-missing lookup values taking precedence.
#' @param material_hierarchy hierarchy table or csv path used when
#' non-\code{NULL}. It is joined with \code{join_material_hierarchy()} using the
#' default \code{"material"} metadata key.
#' @param metadata_name_lookup a data.frame or data.table with
#' \code{canonical_name}, \code{source_name}, and optional \code{regex} columns.
#' The default is returned by \code{\link{lib_metadata_name_lookup}()}; use
#' \code{NULL} to clean names without coalescing aliases.
#' @param clean_metadata_values logical or \code{NULL}; whether
#' \code{build_lib()} should lowercase, trim, ASCII-normalize, and normalize
#' blank/unknown character metadata values before joining. \code{NULL} enables
#' cleaning for the official output workflow and disables it for composable
#' in-memory builds. Invalid byte sequences are always converted to UTF-8.
#' @param convert_intensity logical; whether to infer reflectance,
#' transmittance, or absorbance units from each source and convert known
#' non-absorbance spectra with \code{\link{adj_intens}()} before merging.
#' Object attribute \code{intensity_unit} is authoritative when supplied;
#' otherwise metadata column \code{intensity_units} is evaluated per spectrum.
#' @param signal_noise logical; whether to append the default
#' \code{\link{sig_noise}()} result as metadata column \code{sn}.
#' @param assess logical; whether to run \code{\link{assess_spec}()} on each
#' output library and append assessment summaries to its metadata.
#' @param prune \code{NULL}, or a named list mapping recipe names to argument
#' lists for \code{\link{prune_lib}()}. Selected recipes are pruned independently
#' after processing and assessment. \code{NULL} preserves unpruned outputs.
#' @param progress logical; whether \code{build_lib()} reports named processing
#' stages and elapsed time, or \code{reduce_lib()} reports group sizes and
#' correlation/PAM timings.
#' @param workflow_data optional directory containing the curated reference CSV
#' tables. If \code{NULL}, the directory is discovered beside the calling
#' script or under the current working directory.
#' @param output_dir \code{NULL} for the composable in-memory return, or a
#' directory that triggers the complete checkpointed workflow. The official
#' workflow requires an explicit output directory.
#' @param previous_library_dir directory containing the seven legacy artifacts
#' used for complete old/new assessment, \code{"system"}, or \code{NULL} to skip
#' external comparison. The end-to-end workflow defaults to \code{"system"}
#' and retrieves missing artifacts with \code{get_lib()}.
#' @param reuse logical; whether manifest-compatible completed checkpoints and
#' versioned release files may be reused.
#' @param remove_other logical; in the official end-to-end workflow, whether
#' spectra with blank \code{spectrum_identity} or the unresolved literal
#' \code{"other"} class are removed before quality control, medoid selection,
#' and model fitting. Reviewed broad \code{"other plastic"} and
#' \code{"other material"} categories remain in the reference libraries and
#' are eligible for \code{prune_lib()}'s constrained nearest-class
#' reassignment. Removed and reviewed rows remain visible in
#' \code{assessments$cleanup$summary}. Source-only composable builds do not
#' apply the official filter.
#' @param seed fixed seed for the grouped old/new assessment split.
#' @param holdout fraction of stable spectrum groups reserved for assessment.
#' @param group_cols metadata columns defining groups for reduction.
#' @param k maximum representatives to keep for groups larger than
#' \code{min_n}.
#' @param min_n For \code{prune_lib()}, the minimum spectra required for a
#' resolved class within one spectrum type across the complete input database,
#' not within each source library. Smaller groups are reassigned as a whole to
#' their most-correlated eligible class, or removed when no valid destination
#' exists; groups exactly at the threshold are retained. For \code{reduce_lib()},
#' groups with \code{min_n} or fewer spectra are kept whole. Model trainers
#' fit only classes meeting the threshold.
#' @param cross_class logical; whether \code{prune_lib()} should resolve
#' within-library and then independent-library high-correlation matches between
#' different non-generic material classes before generic-class reassignment.
#' This requires complete canonical
#' \code{library_name} metadata. The composable default is \code{FALSE}; the
#' official derivative and no-baseline workflow enables it.
#' @param cross_class_threshold numeric Pearson-correlation threshold in
#' \code{[0, 1]}. Cross-class correlations strictly greater than this value
#' are conflicts when \code{cross_class = TRUE}.
#' @param class_col,type_col metadata columns used for model labels.
#' @param nearest logical; if \code{TRUE}, \code{assess_lib()} compares each
#' spectrum with its highest-correlation neighbor and reports the fraction where
#' that neighbor has the same \code{class_col} value.
#' @param alpha alpha value passed to \code{\link[glmnet]{glmnet}()}.
#' @param method classifier to train: \code{"logistic_regression"} (the
#' backward-compatible default) or \code{"random_forest"}. The latter requires
#' the suggested \pkg{ranger} package.
#' @param seed random seed used before model training.
#' @param grouped logical; whether multinomial coefficients use grouped
#' penalties.
#' @param weights logical; whether to use inverse class-frequency weights for
#' logistic regression or inverse-frequency case sampling for random forest.
#' @param make_relative logical; whether to normalize model inputs with
#' \code{\link{make_rel}()}.
#' @param material_type_col metadata column used to require plastic candidates
#' for \code{"other plastic"} and to update the type after generic-class
#' reassignment. \code{"other material"} candidates are restricted by
#' \code{class_col} to \code{"organic matter"} or \code{"mineral"};
#' \code{"other"} may match any established class in the spectral pool.
#' @param exclude numeric length-two wavenumber interval excluded from pruning
#' correlations.
#' @param \ldots further arguments passed to the underlying operation.
#'
#' @return
#' Each library returned by \code{build_lib()} includes a
#' \code{spectrum_identity_cleanup_report} attribute listing changed original
#' and normalized identities with their counts.
#' In composable mode, \code{build_lib()} returns a named list of
#' \code{OpenSpecy} libraries. Its end-to-end mode returns one list containing
#' \code{libraries}, \code{medoids}, \code{models}, and \code{assessments}.
#' Official libraries and medoids are nested by recipe and then
#' \code{ftir}, \code{raman}, or \code{nir}. Models are nested by algorithm,
#' recipe, and spectrum type. FTIR and Raman medoids/models use
#' 800--3200 while the NIR interval is derived from finite coverage within
#' 4000--12000. Assessments use five ordered process lists:
#' \code{cleanup}, \code{ref_lib}, \code{medoid}, \code{model}, and
#' \code{functionality}, with no more than ten nonempty review tables in total.
#' Every spectrum receives a derived \code{library_name}: populated
#' \code{organization} first, otherwise \code{user_name}. The cleanup summary
#' includes source-library counts at each major stage and identifies the first
#' stage and reason whenever an entire source library is dropped. Accuracy
#' tables contain overall aggregate metrics only in long form, confusion tables
#' retain misidentifications only, and model error-mode tables compare accuracy
#' percentages with and without each automated-test flag. Review tables reject
#' columns with more than 10 percent missing values. Row-level tests, split manifests, model-training
#' diagnostics, and release manifests remain hash-addressed evidence attributes
#' rather than additional review leaves. Each in-memory training model contains
#' one \code{tests} data.table and a one-spectrum \code{fill} object. Versioned
#' release directories instead store global build and model diagnostics only in
#' \code{assessments.rds}; library, medoid, and model files retain only runtime
#' data, scientific attributes, and prediction state. The companion
#' \code{reference_library_build.rds} is a lightweight release index. The
#' companion \code{quarantined_spectra.rds} stores valid \code{OpenSpecy}
#' objects by recipe/type, a long conflict table, and build provenance for
#' spectra excluded by cross-class closure.
#' \code{join_lib_metadata()}, \code{join_material_hierarchy()},
#' \code{dedupe_spec()}, \code{prune_lib()}, and \code{reduce_lib()} return an updated spectral
#' object unless \code{return} requests a table, report, or ids.
#' \code{make_lib_lookup_template()} returns a data.table unless \code{path} is
#' supplied, in which case it writes the csv and invisibly returns the table.
#' \code{train_spec_model()} and \code{build_model_lib()} return a list suitable
#' for AI classification with
#' \code{\link{match_spec}()} and one tidy \code{tests} table instead of
#' separate accuracy/confusion summaries. It also contains typed
#' \code{lambda_metrics} and \code{support} tables. Random-forest results also
#' contain out-of-bag metrics and feature importance. \code{assess_lib()}
#' returns a data.table summary.
#'
#' @examples
#' wavenumber <- seq(100, 6100, by = 100)
#' base_a <- dnorm(seq(-3, 3, length.out = length(wavenumber)))
#' base_b <- rev(cumsum(seq_along(wavenumber)))
#' spectra <- cbind(base_a, base_a + 0.1, base_a + 0.2,
#' base_b, base_b + 0.1, base_b + 0.2)
#' colnames(spectra) <- paste0("s", seq_len(ncol(spectra)))
#' mini <- as_OpenSpecy(
#' wavenumber,
#' spectra = spectra,
#' metadata = data.table::data.table(
#' sample_name = colnames(spectra),
#' source = rep(c("A", "B"), each = 3),
#' label = c("nylon 6", "polyamides", "nylon 6",
#' "pet", "polyesters", "pet"),
#' material_class = rep(c("polyamides", "polyesters"), each = 3),
#' spectrum_type = rep("ftir", 6),
#' intensity_units = rep("absorbance", 6)
#' ),
#' attributes = list(intensity_unit = "absorbance")
#' )
#'
#' name_lookup <- lib_metadata_name_lookup()
#' name_lookup[name_lookup$canonical_name == "material_color", ]
#'
#' make_lib_lookup_template(mini, columns = "source", add = "library_type")
#'
#' source_lookup <- data.frame(
#' source = c("A", "B"),
#' library_type = c("lab", "field"),
#' material = c("nylon 6", "pet")
#' )
#' joined <- join_lib_metadata(mini, source_lookup, by = "source",
#' require_complete = TRUE)
#'
#' hierarchy <- data.frame(
#' material = c("nylon 6", "pet"),
#' material_class = c("polyamides", "polyesters"),
#' material_type = c("plastic", "plastic")
#' )
#' joined <- join_material_hierarchy(joined, hierarchy, key_col = "label",
#' require_complete = TRUE)
#'
#' deduped <- dedupe_spec(joined)
#' reduced <- reduce_lib(deduped, group_cols = "material_class",
#' k = 1, min_n = 1)
#' libs <- build_lib(
#' mini,
#' recipes = list(
#' raw = list(),
#' derivative = list(
#' conform_spec = FALSE,
#' smooth_intens = TRUE,
#' smooth_intens_args = list(window = 15, derivative = 1),
#' make_rel = TRUE
#' )
#' ),
#' metadata_lookups = source_lookup,
#' material_hierarchy = hierarchy,
#' restrict_range_args = list(min = 100, max = 6000),
#' assess = TRUE,
#' dedupe = FALSE
#' )
#'
#' model <- suppressWarnings(train_spec_model(
#' joined, class_col = "material_class", type_col = NULL, min_n = 2,
#' nlambda = 3
#' ))
#' assess_lib(libs$raw, class_col = "material_class", nearest = FALSE)
#'
#' @seealso \code{\link{match_spec}()} for deploying trained models and
#' \code{\link{plotly_spec}()} for logistic coefficient overlays.
#'
#' @author
#' Win Cowger
#'
#' @importFrom data.table as.data.table data.table fread fwrite rbindlist setorder
#' @export
build_lib <- function(x, recipes = .default_lib_recipes(), range = "full",
res = 6, id_col = "sample_name", exclude_ids = NULL,
dedupe = TRUE, metadata_lookups = NULL,
material_hierarchy = NULL,
metadata_name_lookup = lib_metadata_name_lookup(),
clean_metadata_values = NULL,
convert_intensity = TRUE, restrict_range_args = NULL,
signal_noise = TRUE, assess = FALSE, prune = NULL,
progress = TRUE,
workflow_data = NULL,
output_dir = NULL, previous_library_dir = "system",
reuse = TRUE, remove_other = TRUE,
seed = 123, holdout = 0.1,
...) {
if (missing(x)) {
stop(
"'x' must specify the source library file path(s) or OpenSpecy object(s)",
call. = FALSE
)
}
official_mode <- !is.null(output_dir)
if (is.null(clean_metadata_values)) {
clean_metadata_values <- official_mode
}
if (!is.logical(reuse) || length(reuse) != 1L || is.na(reuse)) {
stop("'reuse' must be TRUE or FALSE", call. = FALSE)
}
if (!is.logical(remove_other) || length(remove_other) != 1L ||
is.na(remove_other)) {
stop("'remove_other' must be TRUE or FALSE", call. = FALSE)
}
if (!official_mode) {
return(.lib_build_core(
x = x, recipes = recipes, range = range, res = res, id_col = id_col,
exclude_ids = exclude_ids, dedupe = dedupe,
metadata_lookups = metadata_lookups,
material_hierarchy = material_hierarchy,
metadata_name_lookup = metadata_name_lookup,
clean_metadata_values = clean_metadata_values,
convert_intensity = convert_intensity,
restrict_range_args = restrict_range_args,
signal_noise = signal_noise, assess = assess, prune = prune,
progress = progress, ...
))
}
workflow_data <- .lib_find_workflow_data(workflow_data)
.lib_build_reference(
x = x, recipes = recipes, range = range, res = res,
id_col = id_col, exclude_ids = exclude_ids, dedupe = dedupe,
metadata_lookups = metadata_lookups,
material_hierarchy = material_hierarchy,
metadata_name_lookup = metadata_name_lookup,
clean_metadata_values = clean_metadata_values,
convert_intensity = convert_intensity,
restrict_range_args = restrict_range_args,
signal_noise = signal_noise, assess = assess, prune = prune,
progress = progress, workflow_data = workflow_data,
output_dir = output_dir, previous_library_dir = previous_library_dir,
reuse = reuse, remove_other = remove_other,
seed = seed, holdout = holdout, ...
)
}
#' @rdname build_lib
#' @export
rebuild_lib_artifacts <- function(x, output_dir,
previous_library_dir = "system",
reuse = TRUE, seed = 123, holdout = 0.1,
progress = TRUE) {
.lib_rebuild_artifacts(
x = x, output_dir = output_dir,
previous_library_dir = previous_library_dir,
reuse = reuse, seed = seed, holdout = holdout, progress = progress
)
}
.lib_build_core <- function(x, recipes = .default_lib_recipes(), range = "full",
res = 6, id_col = "sample_name",
exclude_ids = NULL, dedupe = TRUE,
metadata_lookups = NULL,
material_hierarchy = NULL,
metadata_name_lookup = lib_metadata_name_lookup(),
clean_metadata_values = FALSE,
convert_intensity = TRUE,
restrict_range_args = NULL,
signal_noise = TRUE, assess = FALSE, prune = NULL,
progress = TRUE, ...) {
if (!is.logical(progress) || length(progress) != 1L || is.na(progress)) {
stop("'progress' must be TRUE or FALSE", call. = FALSE)
}
if (!is.logical(clean_metadata_values) ||
length(clean_metadata_values) != 1L ||
is.na(clean_metadata_values)) {
stop("'clean_metadata_values' must be TRUE or FALSE", call. = FALSE)
}
started <- proc.time()[["elapsed"]]
dot_args <- list(...)
hash_scale <- if (is.null(dot_args$scale)) 100 else dot_args$scale
hash_algo <- if (is.null(dot_args$algo)) "md5" else dot_args$algo
dedupe_duplicate <- if (is.null(dot_args$duplicate)) {
"first"
} else {
match.arg(dot_args$duplicate, c("first", "remove_all", "none"))
}
report <- function(stage) {
if (isTRUE(progress)) {
elapsed <- proc.time()[["elapsed"]] - started
message(sprintf("build_lib [%.1fs]: %s", elapsed, stage))
}
}
validate_sources <- function(source, label) {
if (is_OpenSpecy(source)) {
return(list(source))
}
if (is.list(source) && length(source) > 0L &&
all(vapply(source, is_OpenSpecy, logical(1)))) {
return(source)
}
stop(label, " must contain one OpenSpecy object or a nonempty list of ",
"OpenSpecy objects", call. = FALSE)
}
report("starting")
streamed_records <- NULL
if (is_OpenSpecy(x)) {
sources <- list(x)
report("using one in-memory OpenSpecy source")
} else if (is.character(x)) {
if (length(x) == 0L || anyNA(x) || any(!nzchar(x))) {
stop("'x' must contain one or more nonempty file paths", call. = FALSE)
}
streamed_records <- list()
source_index <- 0L
for (i in seq_along(x)) {
report(sprintf(
"reading path %d/%d (%s)",
i, length(x), basename(x[[i]])
))
if (grepl("\\.rds$", x[[i]], ignore.case = TRUE)) {
source <- readRDS(x[[i]])
} else {
source <- read_any(x[[i]])
}
path_sources <- validate_sources(source, paste0("File path ", i))
for (j in seq_along(path_sources)) {
source_index <- source_index + 1L
streamed_records[[source_index]] <- .lib_source_record(
path_sources[[j]],
source_index,
id_col = if (isTRUE(dedupe)) id_col else NULL,
hash_scale = hash_scale,
hash_algo = hash_algo
)
}
rm(source, path_sources)
gc(verbose = FALSE)
}
report(sprintf(
"streamed %d source object(s) from %d path(s)",
length(streamed_records), length(x)
))
} else {
if (!is.list(x) || length(x) == 0L ||
!all(vapply(x, is_OpenSpecy, logical(1)))) {
stop("'x' must be one OpenSpecy object, file path(s), or a nonempty ",
"list of OpenSpecy objects", call. = FALSE)
}
sources <- x
report(sprintf("using %d in-memory OpenSpecy source(s)", length(sources)))
}
if (is.null(streamed_records)) {
report(sprintf("preparing %d source object(s)", length(sources)))
lib <- .lib_prepare_sources(
sources,
range = range,
res = res,
metadata_name_lookup = metadata_name_lookup,
clean_metadata_values = clean_metadata_values,
convert_intensity = convert_intensity,
id_col = if (isTRUE(dedupe)) id_col else NULL,
hash_scale = hash_scale,
hash_algo = hash_algo,
report = report
)
} else {
lib <- .lib_prepare_records(
streamed_records,
range = range,
res = res,
metadata_name_lookup = metadata_name_lookup,
clean_metadata_values = clean_metadata_values,
convert_intensity = convert_intensity,
report = report
)
rm(streamed_records)
gc(verbose = FALSE)
}
build_stage_report <- data.table::data.table(
stage = "prepared", spectra = ncol(lib$spectra), removed = 0L
)
identity_cleanup_report <- data.table::data.table()
if ("spectrum_identity" %in% names(lib$metadata)) {
identity_before <- as.character(lib$metadata$spectrum_identity)
identity_after <- .lib_clean_spectrum_identity(identity_before)
changed <- xor(is.na(identity_before), is.na(identity_after)) |
(!is.na(identity_before) & !is.na(identity_after) &
identity_before != identity_after)
identity_cleanup_report <- data.table::data.table(
original = identity_before[changed],
spectrum_identity = identity_after[changed]
)[, .(n = .N), by = .(original, spectrum_identity)]
lib$metadata$spectrum_identity <- identity_after
if (any(changed)) {
report(sprintf("cleaned %d spectrum identity value(s)", sum(changed)))
}
}
lib$metadata <- .lib_assign_library_name(lib$metadata)
source_key_report <- .lib_fill_metadata_key(
lib$metadata, canonical = "organization", fallback = "user_name"
)
if (nrow(source_key_report) > 0L) {
filled <- source_key_report[problem == "filled_canonical_key", sum(n)]
conflicts <- source_key_report[problem == "canonical_key_conflict", sum(n)]
report(sprintf(
"standardized source keys (filled=%d; conflicts=%d)",
ifelse(length(filled), filled, 0L),
ifelse(length(conflicts), conflicts, 0L)
))
}
if (!is.null(restrict_range_args)) {
report("restricting the wavenumber range")
if (!is.list(restrict_range_args) ||
is.null(names(restrict_range_args)) ||
any(names(restrict_range_args) == "")) {
stop("'restrict_range_args' must be a named list", call. = FALSE)
}
args <- utils::modifyList(
list(make_rel = FALSE),
restrict_range_args
)
lib <- do.call("restrict_range", c(list(lib), args))
}
lookup_reports <- list()
if (nrow(source_key_report) > 0L) {
lookup_reports$canonical_source_keys <- source_key_report
}
if (!is.null(metadata_lookups)) {
lookups <- if (.lib_is_lookup_spec(metadata_lookups)) {
list(metadata_lookups)
} else if (is.character(metadata_lookups) &&
length(metadata_lookups) > 1L) {
as.list(metadata_lookups)
} else if (is.list(metadata_lookups) &&
!inherits(metadata_lookups, c("data.frame", "data.table"))) {
metadata_lookups
} else {
list(metadata_lookups)
}
for (i in seq_along(lookups)) {
report(sprintf("joining metadata lookup %d/%d", i, length(lookups)))
lookup <- lookups[[i]]
lookup_spec <- .lib_normalize_lookup_spec(lookup)
lookup_table <- lib_clean_metadata(.lib_read_lookup(lookup_spec$lookup),
metadata_name_lookup,
clean_values = clean_metadata_values)
if ("spectrum_identity" %in% names(lookup_table)) {
lookup_table$spectrum_identity <- .lib_clean_spectrum_identity(
lookup_table$spectrum_identity
)
}
lookup_key <- lookup_spec$by
key_merge_report <- data.table::data.table()
if (is.null(lookup_key)) {
auto_key <- .lib_auto_lookup_key(lib$metadata, lookup_table)
if (length(auto_key$shared) == 0L) {
report(sprintf(
"skipping metadata lookup %d/%d; no shared metadata column",
i, length(lookups)
))
next
}
if (length(auto_key$candidates) == 0L) {
report(sprintf(
"skipping metadata lookup %d/%d; no usable shared key values in: %s",
i, length(lookups), paste(auto_key$shared, collapse = ", ")
))
next
}
if (length(auto_key$candidates) > 1L) {
stop("Each automatic metadata lookup must have exactly one usable ",
"shared key. Candidate columns were: ",
paste(auto_key$candidates, collapse = ", "),
". Supply list(lookup = x, by = key) for an explicit join",
call. = FALSE)
}
lookup_key <- auto_key$candidates
}
if (!is.null(lookup_spec$fallback_by)) {
warning(
"'fallback_by' is deprecated; standardize canonical metadata keys ",
"before supplying external lookup tables",
call. = FALSE
)
metadata_key <- if (is.null(names(lookup_key)) ||
all(names(lookup_key) == "")) {
unname(lookup_key)
} else {
names(lookup_key)
}
if (length(metadata_key) != 1L) {
stop("'fallback_by' requires a one-column explicit lookup key",
call. = FALSE)
}
.lib_require_cols(lib$metadata,
c(metadata_key, lookup_spec$fallback_by),
"metadata")
primary <- as.character(lib$metadata[[metadata_key]])
fallback <- as.character(lib$metadata[[lookup_spec$fallback_by]])
primary_blank <- is.na(primary) | !nzchar(trimws(primary))
fallback_present <- !is.na(fallback) & nzchar(trimws(fallback))
filled <- primary_blank & fallback_present
primary[filled] <- fallback[filled]
lib$metadata[[metadata_key]] <- primary
key_merge_report <- data.table::data.table(
problem = "fallback_metadata_key",
column = metadata_key,
value = lookup_spec$fallback_by,
n = sum(filled)
)
}
coalesce_cols <- intersect(
names(lib$metadata),
setdiff(names(lookup_table), unname(lookup_key))
)
lib <- join_lib_metadata(lib, lookup_table, by = lookup_key)
join_report <- attr(lib, "join_report")
report(sprintf(
"metadata lookup %d/%d complete (matched=%d; unmatched=%d)",
i, length(lookups),
nrow(lib$metadata) - sum(join_report[
problem == "unmatched_metadata_key", n
]),
sum(join_report[problem == "unmatched_metadata_key", n])
))
lookup_reports[[paste0("lookup_", i)]] <- data.table::rbindlist(
list(key_merge_report, join_report), fill = TRUE
)
lib$metadata <- .lib_coalesce_joined_metadata(
lib$metadata, coalesce_cols,
lookup_precedence = !isTRUE(lookup_spec$fill_only)
)
}
}
if (!is.null(material_hierarchy)) {
report("joining the material hierarchy")
hierarchy <- lib_clean_metadata(.lib_read_lookup(material_hierarchy),
metadata_name_lookup,
clean_values = clean_metadata_values)
lib <- join_material_hierarchy(lib, hierarchy)
hierarchy_report <- attr(lib, "join_report")
report(sprintf(
"material hierarchy complete (unmatched=%d)",
sum(hierarchy_report[problem == "unmatched_metadata_key", n])
))
}
retention_stages <- .lib_library_stage_counts(
list(source = lib), stage = "prepared"
)[, artifact := NULL]
identity_index <- data.table::data.table(
sample_name = as.character(.lib_ids(lib, "sample_name")),
spectrum_identity = if ("spectrum_identity" %in% names(lib$metadata)) {
as.character(lib$metadata$spectrum_identity)
} else {
NA_character_
}
)
if (!is.null(exclude_ids)) {
before <- ncol(lib$spectra)
lib <- .lib_filter_excluded(lib, exclude_ids, id_col = id_col)
build_stage_report <- data.table::rbindlist(list(
build_stage_report,
data.table::data.table(
stage = "excluded_identifiers", spectra = ncol(lib$spectra),
removed = before - ncol(lib$spectra)
)
))
report(sprintf("removed %d excluded identifier(s)",
before - ncol(lib$spectra)))
}
retention_stages <- data.table::rbindlist(list(
retention_stages,
.lib_library_stage_counts(
list(source = lib), stage = "post_exclusion"
)[, artifact := NULL]
))
if (dedupe) {
before <- ncol(lib$spectra)
report("generating identifiers and removing duplicate spectra")
existing <- .lib_dedupe_existing_ids(
lib,
id_col = id_col,
duplicate = dedupe_duplicate
)
lib <- if (is.null(existing)) {
dedupe_spec(lib, id_col = id_col, ...)
} else {
existing
}
if (!is.null(exclude_ids)) {
lib <- .lib_filter_excluded(lib, exclude_ids, id_col = id_col)
}
build_stage_report <- data.table::rbindlist(list(
build_stage_report,
data.table::data.table(
stage = "deduplicated", spectra = ncol(lib$spectra),
removed = before - ncol(lib$spectra)
)
))
report(sprintf("deduplication complete (removed=%d; retained=%d)",
before - ncol(lib$spectra), ncol(lib$spectra)))
}
retention_stages <- data.table::rbindlist(list(
retention_stages,
.lib_library_stage_counts(
list(source = lib), stage = "core"
)[, artifact := NULL]
))
retained_ids <- as.character(.lib_ids(lib, "sample_name"))
dropped_identities <- identity_index[!sample_name %in% retained_ids &
!is.na(spectrum_identity) & nzchar(trimws(spectrum_identity)),
.(spectrum_identity = sort(unique(spectrum_identity)))]
apply_recipe <- function(recipe, recipe_name, recipe_index) {
report(sprintf(
"processing recipe %d/%d (%s)",
recipe_index, length(recipes), recipe_name
))
out <- if (is.function(recipe)) {
recipe(lib)
} else if (length(recipe) == 0L) {
lib
} else {
do.call(process_spec, c(list(lib), recipe))
}
if (!is_OpenSpecy(out)) {
stop("Each recipe must return an OpenSpecy object", call. = FALSE)
}
if (isTRUE(signal_noise)) {
report(sprintf("calculating signal-to-noise (%s)", recipe_name))
out$metadata$sn <- sig_noise(out, step = 10)
}
if (isTRUE(assess)) {
report(sprintf("assessing spectra (%s)", recipe_name))
assessment <- assess_spec(out)
out$metadata[, `:=`(
assessment_flag = FALSE,
assessment_issue_count = 0L,
assessment_checks = NA_character_,
assessment_issues = NA_character_,
assessment_potential_fixes = NA_character_
)]
if (nrow(assessment) > 0L) {
summary <- assessment[, .(
assessment_flag = TRUE,
assessment_issue_count = .N,
assessment_checks = paste(unique(get("check")), collapse = "; "),
assessment_issues = paste(unique(get("issue")), collapse = "; "),
assessment_potential_fixes = paste(unique(get("potential_fix")),
collapse = "; ")
), by = "spectrum_id"]
idx <- match(colnames(out$spectra), summary$spectrum_id)
found <- !is.na(idx)
assessment_cols <- setdiff(names(summary), "spectrum_id")
assessment_values <- summary[idx[found], assessment_cols, with = FALSE]
out$metadata[found, (assessment_cols) := assessment_values]
}
}
if (!is.null(prune) && recipe_name %in% names(prune)) {
report(sprintf("pruning library (%s)", recipe_name))
prune_args <- prune[[recipe_name]]
if (is.null(prune_args)) prune_args <- list()
if (!is.list(prune_args) ||
(!is.null(names(prune_args)) && any(names(prune_args) == ""))) {
stop("Each selected 'prune' recipe must contain a named argument list",
call. = FALSE)
}
prune_args$return <- "object"
if (is.null(prune_args$progress)) prune_args$progress <- progress
out <- do.call(prune_lib, c(list(out), prune_args))
}
if (length(lookup_reports) > 0L) {
attr(out, "metadata_lookup_reports") <- lookup_reports
}
attr(out, "spectrum_identity_cleanup_report") <- identity_cleanup_report
attr(out, "build_stage_report") <- build_stage_report
attr(out, "dropped_spectrum_identities") <- dropped_identities
recipe_retention <- data.table::copy(retention_stages)
recipe_retention[, artifact := recipe_name]
data.table::setcolorder(
recipe_retention, c("stage", "artifact", "library_name", "spectra")
)
attr(out, "library_retention_stages") <- recipe_retention
out
}
if (is.null(names(recipes)) || any(names(recipes) == "") ||
anyDuplicated(names(recipes))) {
stop("'recipes' must be a uniquely named list", call. = FALSE)
}
if (!is.null(prune)) {
if (!is.list(prune) || is.null(names(prune)) ||
any(is.na(names(prune)) | names(prune) == "") ||
anyDuplicated(names(prune))) {
stop("'prune' must be NULL or a uniquely named list", call. = FALSE)
}
unknown_prune <- setdiff(names(prune), names(recipes))
if (length(unknown_prune) > 0L) {
stop("'prune' names must identify recipes: ",
paste(unknown_prune, collapse = ", "), call. = FALSE)
}
}
out <- lapply(seq_along(recipes), function(i) {
apply_recipe(recipes[[i]], names(recipes)[[i]], i)
})
names(out) <- names(recipes)
report("complete")
out
}
.lib_clean_spectrum_identity <- function(x) {
out <- trimws(as.character(x))
missing <- is.na(out)
windows_path <- grepl("^[A-Za-z]:[\\\\/]", out) |
grepl("\\\\", out)
unix_path <- grepl("^(?:/|\\./|\\.\\./)", out, perl = TRUE)
out <- gsub("\\\\", "/", out)
extensions <- .supported_spectrum_extensions()
pattern <- paste0(
"\\.(?:", paste(extensions, collapse = "|"), ")$"
)
suffixed_path <- grepl("/", out, fixed = TRUE) &
grepl(pattern, out, ignore.case = TRUE, perl = TRUE)
path <- windows_path | unix_path | suffixed_path
out[path] <- sub(".*/", "", out[path])
repeat {
cleaned <- sub(pattern, "", out, ignore.case = TRUE, perl = TRUE)
if (identical(cleaned, out)) break
out <- cleaned
}
out <- trimws(out)
out[missing | !nzchar(out)] <- NA_character_
out
}
#' Predict blank class-reference values with reviewed regex rules
#'
#' Applies a separate regex reference only to rows whose `material` is blank.
#' Populated exact materials are authoritative and are never overwritten, even
#' when a regex also matches them. A blank row is filled only when all matching
#' patterns name one material. Distinct-material matches remain blank and are
#' reported as clashes. Exact/regex overlaps are allowed and reported for QA.
#' If present, `match_identity` is used for pattern matching while
#' `spectrum_identity` remains the reported exact identity.
#'
#' @param metadata A data.frame or data.table with `spectrum_identity` and
#' `material` columns. Row order is preserved.
#' @param regex_reference A data.frame or data.table with unique `pattern` and
#' nonblank `material` columns.
#' @param return Return the updated table or an audit containing `data`,
#' `summary`, `predictions`, `clashes`, and `overlaps`.
#'
#' @return An updated data.table, or an audit list when `return = "report"`.
#' @export
predict_class_reference <- function(metadata, regex_reference,
return = c("table", "report")) {
return <- match.arg(return)
out <- data.table::as.data.table(data.table::copy(metadata))
rules <- data.table::as.data.table(data.table::copy(regex_reference))
.lib_require_cols(out, c("spectrum_identity", "material"),
"metadata")
.lib_require_cols(rules, c("pattern", "material"), "regex reference")
out$spectrum_identity <- as.character(out$spectrum_identity)
out$material <- as.character(out$material)
rules$pattern <- as.character(rules$pattern)
rules$material <- as.character(rules$material)
blank <- function(value) {
is.na(value) | !nzchar(trimws(as.character(value)))
}
if (any(blank(rules$pattern)) || any(blank(rules$material))) {
stop("Regex-reference patterns and materials must be nonblank",
call. = FALSE)
}
if (anyDuplicated(rules$pattern)) {
stop("Regex-reference patterns must be unique", call. = FALSE)
}
match_value <- if ("match_identity" %in% names(out)) {
value <- as.character(out$match_identity)
value[blank(value)] <- out$spectrum_identity[blank(value)]
value
} else {
out$spectrum_identity
}
missing_identity <- blank(match_value)
match_value[missing_identity] <- ""
hits <- matrix(FALSE, nrow = nrow(out), ncol = nrow(rules))
if (nrow(rules) > 0L && nrow(out) > 0L) {
hits <- vapply(seq_len(nrow(rules)), function(i) {
tryCatch(
grepl(rules$pattern[[i]], match_value, perl = TRUE),
warning = function(w) {
stop("Invalid class regex '", rules$pattern[[i]], "': ",
conditionMessage(w), call. = FALSE)
},
error = function(e) {
stop("Invalid class regex '", rules$pattern[[i]], "': ",
conditionMessage(e), call. = FALSE)
}
)
}, logical(nrow(out)))
if (is.null(dim(hits))) hits <- matrix(hits, ncol = 1L)
}
if (any(missing_identity) && ncol(hits) > 0L) {
hits[missing_identity, ] <- FALSE
}
matched_materials <- lapply(seq_len(nrow(out)), function(i) {
unique(rules$material[which(hits[i, ])])
})
matched_patterns <- lapply(seq_len(nrow(out)), function(i) {
rules$pattern[which(hits[i, ])]
})
blank_material <- blank(out$material)
unique_match <- lengths(matched_materials) == 1L
clash <- lengths(matched_materials) > 1L
predict <- blank_material & unique_match
out$material[predict] <- vapply(
matched_materials[predict], `[[`, character(1), 1L
)
prediction_rows <- which(predict)
predictions <- data.table::data.table(
spectrum_identity = out$spectrum_identity[prediction_rows],
match_identity = match_value[prediction_rows],
material = out$material[prediction_rows],
patterns = vapply(matched_patterns[prediction_rows], paste,
character(1), collapse = "; ")
)[, .(n = .N), by = .(spectrum_identity, match_identity, material, patterns)]
clash_rows <- which(blank_material & clash)
clashes <- if (length(clash_rows) == 0L) {
data.table::data.table(
spectrum_identity = character(), match_identity = character(),
materials = character(), patterns = character(), n = integer()
)
} else {
data.table::data.table(
spectrum_identity = out$spectrum_identity[clash_rows],
match_identity = match_value[clash_rows],
materials = vapply(matched_materials[clash_rows], paste,
character(1), collapse = "; "),
patterns = vapply(matched_patterns[clash_rows], paste,
character(1), collapse = "; ")
)[, .(n = .N),
by = .(spectrum_identity, match_identity, materials, patterns)]
}
overlap_rows <- which(!blank_material & lengths(matched_materials) > 0L)
overlaps <- data.table::data.table(
spectrum_identity = out$spectrum_identity[overlap_rows],
exact_material = out$material[overlap_rows],
regex_materials = vapply(matched_materials[overlap_rows], paste,
character(1), collapse = "; "),
patterns = vapply(matched_patterns[overlap_rows], paste,
character(1), collapse = "; ")
)[, agreement := vapply(seq_len(.N), function(i) {
exact_material[[i]] %in% strsplit(regex_materials[[i]], "; ", fixed = TRUE)[[1L]]
}, logical(1))][, .(n = .N),
by = .(spectrum_identity, exact_material, regex_materials,
patterns, agreement)]
summary <- data.table::data.table(
rows = nrow(out), regex_rules = nrow(rules),
existing = sum(!blank_material), predicted = sum(predict),
clashes = length(clash_rows), overlaps = length(overlap_rows),
unmatched = sum(blank(out$material))
)
if (return == "report") {
return(list(data = out, summary = summary, predictions = predictions,
clashes = clashes, overlaps = overlaps))
}
out
}
# Complete official reference-library class coverage while retaining uncertain
# identities as an explicit review queue. This stays internal because the two
# normalization rules and the reviewed one-percent cap are specific to the
# official library, not a general package API.
.lib_complete_reference_classes <- function(x, classes, hierarchy,
enforce_other_limit = TRUE) {
metadata <- data.table::copy(x$metadata)
blank <- function(value) {
value <- as.character(value)
is.na(value) | !nzchar(trimws(value))
}
missing_before <- blank(metadata$material_class)
identity <- as.character(metadata$spectrum_identity)
user <- as.character(metadata$user_name)
lookup_key <- identity
assignment <- ifelse(missing_before, NA_character_, "existing_or_exact_lookup")
exact_classes <- classes
normalized_key <- rep(NA_character_, nrow(metadata))
direct <- missing_before & !is.na(identity) &
!is.na(match(identity, exact_classes$spectrum_identity))
normalized_key[direct] <- identity[direct]
gicquel <- missing_before & is.na(normalized_key) & !is.na(user) &
user == "gicquel et al. 2024" & !is.na(identity)
normalized_key[gicquel] <- sub(
"_ref(?:_0)?(?:[.]csv)?$", "", identity[gicquel], perl = TRUE
)
mffrc <- missing_before & is.na(normalized_key) & !is.na(user) &
user == "elise granek and kellie teague" & !is.na(identity)
mffrc_base <- sub("[.][0-9]+$", "", identity[mffrc])
mffrc_base <- sub("^mffrc[0-9]+_", "", mffrc_base)
mffrc_base <- sub("_.*$", "", mffrc_base)
mffrc_family <- trimws(sub("[[:space:]]*[(].*$", "", mffrc_base))
mffrc_key <- mffrc_base
use_family <- is.na(match(mffrc_key, exact_classes$spectrum_identity)) &
!is.na(match(mffrc_family, exact_classes$spectrum_identity))
mffrc_key[use_family] <- mffrc_family[use_family]
normalized_key[mffrc] <- mffrc_key
normalized_material <- exact_classes$material[
match(normalized_key, exact_classes$spectrum_identity)
]
resolved <- missing_before & !blank(normalized_material)
lookup_key[resolved] <- normalized_key[resolved]
metadata$material[resolved] <- normalized_material[resolved]
hierarchy_material <- match(metadata$material[resolved], hierarchy$material)
resolved_rows <- which(resolved)
material_rows <- resolved_rows[!is.na(hierarchy_material)]
material_lookup <- hierarchy_material[!is.na(hierarchy_material)]
if (length(material_rows) > 0L) {
metadata$material_class[material_rows] <- hierarchy$material_class[material_lookup]
metadata$material_type[material_rows] <- hierarchy$material_type[material_lookup]
}
class_pairs <- unique(hierarchy[, .(material_class, material_type)])
missing_class_rows <- which(
blank(metadata$material_class) & !blank(metadata$material)
)
reviewed_class_rows <- integer()
if (length(missing_class_rows) > 0L) {
class_lookup <- match(metadata$material[missing_class_rows],
class_pairs$material_class)
reviewed_class_rows <- missing_class_rows[!is.na(class_lookup)]
metadata$material_class[reviewed_class_rows] <-
metadata$material[reviewed_class_rows]
metadata$material_type[reviewed_class_rows] <-
class_pairs$material_type[class_lookup[!is.na(class_lookup)]]
}
source_resolved <- resolved & !blank(metadata$material_class)
assignment[source_resolved] <- "reviewed_source_key"
reviewed_class_key <- seq_len(nrow(metadata)) %in% reviewed_class_rows &
!source_resolved
assignment[reviewed_class_key] <- "reviewed_class_key"
unresolved <- blank(metadata$material_class)
metadata$material[unresolved] <- "other"
metadata$material_class[unresolved] <- "other"
metadata$material_type[unresolved] <- "other"
lookup_key[unresolved] <- identity[unresolved]
assignment[unresolved] <- "unresolved_identity"
other <- !blank(metadata$material_class) &
tolower(trimws(metadata$material_class)) == "other"
other_fraction <- if (nrow(metadata)) sum(other) / nrow(metadata) else 0
if (isTRUE(enforce_other_limit) && other_fraction > 0.01) {
stop(sprintf(
paste0(
"The 'other' class is %.2f%% of the library (%d/%d), above the ",
"reviewed 1%% maximum; review exact class mappings for: %s"
),
100 * other_fraction, sum(other), nrow(metadata),
paste(head(unique(identity[other]), 20L), collapse = ", ")
), call. = FALSE)
}
metadata[, `:=`(class_lookup_key = lookup_key,
class_assignment_reason = assignment)]
report <- data.table::data.table(
stage = c("before", "after"),
populated_class = c(sum(!missing_before), sum(!blank(metadata$material_class))),
reviewed_source_key = c(0L, sum(source_resolved)),
reviewed_class_key = c(0L, sum(reviewed_class_key)),
unclassified = c(0L, 0L), unresolved_other = c(0L, sum(unresolved)),
other = c(0L, sum(other)), other_fraction = c(0, other_fraction),
total = nrow(metadata)
)
stopifnot(report[stage == "after", populated_class] == nrow(metadata))
x$metadata <- metadata
attr(x, "class_coverage_report") <- report
x
}
.lib_other_review_schema <- function() {
data.table::data.table(
artifact = character(), source_row = integer(), spectrum_id = character(),
spectrum_identity = character(), sample_id = character(),
file_name = character(), library_name = character(),
organization = character(), user_name = character(),
citation = character(), other_info = character(), material = character(),
material_class = character(), material_type = character(),
spectrum_type = character(), generic_fields = character(),
reason = character(), action = character()
)
}
.lib_other_filter_schema <- function() {
data.table::data.table(
artifact = character(), action = character(), before = integer(),
candidates = integer(), missing_identity = integer(),
generic_label = integer(), removed = integer(), after = integer()
)
}
.lib_apply_other_policy <- function(libraries, remove_other, report = NULL) {
generic_labels <- c("other", "other plastic", "other material")
reviews <- list()
summaries <- list()
value <- function(metadata, column) {
if (column %in% names(metadata)) as.character(metadata[[column]]) else
rep(NA_character_, nrow(metadata))
}
for (artifact in names(libraries)) {
object <- libraries[[artifact]]
metadata <- object$metadata
identity <- value(metadata, "spectrum_identity")
missing_identity <- is.na(identity) | !nzchar(trimws(identity))
label_columns <- c("material", "material_class", "material_type")
generic_matrix <- vapply(label_columns, function(column) {
tolower(trimws(value(metadata, column))) %in% generic_labels
}, logical(nrow(metadata)))
if (is.null(dim(generic_matrix))) {
generic_matrix <- matrix(generic_matrix, ncol = length(label_columns))
}
generic <- rowSums(generic_matrix) > 0L
candidates <- missing_identity | generic
unresolved <- rowSums(vapply(label_columns, function(column) {
label <- tolower(trimws(value(metadata, column)))
!is.na(label) & label == "other"
}, logical(nrow(metadata)))) > 0L
remove <- isTRUE(remove_other) & (missing_identity | unresolved)
if (any(candidates)) {
rows <- which(candidates)
triggered <- apply(generic_matrix[rows, , drop = FALSE], 1L, function(x) {
paste(label_columns[x], collapse = "; ")
})
review <- data.table::data.table(
artifact = artifact,
source_row = as.integer(rows),
spectrum_id = as.character(.lib_ids(object, "sample_name"))[rows],
spectrum_identity = identity[rows],
sample_id = value(metadata, "sample_id")[rows],
file_name = value(metadata, "file_name")[rows],
library_name = value(metadata, "library_name")[rows],
organization = value(metadata, "organization")[rows],
user_name = value(metadata, "user_name")[rows],
citation = value(metadata, "citation")[rows],
other_info = value(metadata, "other_info")[rows],
material = value(metadata, "material")[rows],
material_class = value(metadata, "material_class")[rows],
material_type = value(metadata, "material_type")[rows],
spectrum_type = value(metadata, "spectrum_type")[rows],
generic_fields = triggered,
reason = data.table::fcase(
missing_identity[rows], "missing_spectrum_identity",
unresolved[rows], "unresolved_other_label",
default = "reviewed_broad_category"
),
action = if (isTRUE(remove_other)) {
ifelse(remove[rows], "removed", "retained_reviewed_broad_category")
} else {
rep("retained_for_semisupervised_reassignment", length(rows))
}
)
reviews[[artifact]] <- review
}
before <- ncol(object$spectra)
if (any(remove)) {
if (all(remove)) {
stop("Removing blank identities and unresolved classes would remove every ",
"spectrum from ", artifact, call. = FALSE)
}
object <- filter_spec(object, !remove)
}
summary <- data.table::data.table(
artifact = artifact,
action = if (isTRUE(remove_other)) {
"remove_unresolved_retain_reviewed_broad"
} else {
"retain_for_semisupervised_reassignment"
},
before = as.integer(before),
candidates = as.integer(sum(candidates)),
missing_identity = as.integer(sum(missing_identity)),
generic_label = as.integer(sum(generic)),
removed = as.integer(sum(remove)),
after = as.integer(ncol(object$spectra))
)
summaries[[artifact]] <- summary
attr(object, "other_review_report") <- if (is.null(reviews[[artifact]])) {
.lib_other_review_schema()
} else {
reviews[[artifact]]
}
attr(object, "other_filter_report") <- summary
libraries[[artifact]] <- object
if (is.function(report)) report(sprintf(
paste0("generic review %s: candidates=%d (missing identity=%d; ",
"generic label=%d); action=%s; affected=%d; library retained=%d"),
artifact, sum(candidates), sum(missing_identity), sum(generic),
if (isTRUE(remove_other)) "reviewed/removed unresolved" else
"queued for reassignment",
sum(remove),
ncol(object$spectra)
))
}
list(
libraries = libraries,
review = if (length(reviews)) {
data.table::rbindlist(reviews, fill = TRUE)
} else {
.lib_other_review_schema()
},
summary = if (length(summaries)) {
data.table::rbindlist(summaries, fill = TRUE)
} else {
.lib_other_filter_schema()
}
)
}
.lib_prepare_sources <- function(sources, range, res, metadata_name_lookup,
clean_metadata_values, convert_intensity,
id_col, hash_scale, hash_algo, report) {
records <- vector("list", length(sources))
for (i in seq_along(sources)) {
if (i == 1L || i == length(sources) || i %% 2500L == 0L) {
report(sprintf("preparing source object %d/%d", i, length(sources)))
}
records[[i]] <- .lib_source_record(
sources[[i]],
i,
id_col = id_col,
hash_scale = hash_scale,
hash_algo = hash_algo
)
}
.lib_prepare_records(
records,
range = range,
res = res,
metadata_name_lookup = metadata_name_lookup,
clean_metadata_values = clean_metadata_values,
convert_intensity = convert_intensity,
report = report
)
}
.lib_prepare_records <- function(records, range, res, metadata_name_lookup,
clean_metadata_values, convert_intensity,
report) {
metadata <- .lib_combined_metadata(
records,
metadata_name_lookup,
clean_metadata_values = clean_metadata_values
)
if (isTRUE(convert_intensity)) {
converted <- .lib_convert_records_intensity(
records,
metadata,
source_label = "source list"
)
records <- converted$records
metadata <- converted$metadata
}
shared_axis <- .lib_same_wavenumber(records)
if (length(records) > 1L) {
if (shared_axis) {
report(sprintf(
"combining %d same-axis source object(s)",
length(records)
))
} else {
report(sprintf(
"merging %d source object(s) with c_spec()",
length(records)
))
}
}
if (length(records) == 1L) {
return(.lib_combine_same_axis_records(
records,
metadata,
range = NULL,
res = res
))
}
if (shared_axis) {
return(.lib_combine_same_axis_records(
records,
metadata,
range = range,
res = res
))
}
.lib_combine_variable_axis_records(
records,
metadata,
range = range,
res = res
)
}
.lib_source_record <- function(source, index, id_col = NULL,
hash_scale = 100, hash_algo = "md5") {
if (!is_OpenSpecy(source)) {
stop("Source ", index, " must be an OpenSpecy object", call. = FALSE)
}
spectra <- .as_spectra_matrix(source$spectra, message_conversion = FALSE)
wavenumber <- source$wavenumber
if (!is.numeric(wavenumber) || !is.vector(wavenumber)) {
stop("Source ", index, " wavenumber must be a numeric vector",
call. = FALSE)
}
if (length(wavenumber) != nrow(spectra)) {
stop("Source ", index, " wavenumber length must match spectra rows",
call. = FALSE)
}
ord <- order(wavenumber)
if (!identical(ord, seq_along(wavenumber))) {
wavenumber <- wavenumber[ord]
spectra <- spectra[ord, , drop = FALSE]
}
metadata <- data.table::as.data.table(data.table::copy(source$metadata))
if (nrow(metadata) != ncol(spectra)) {
stop("Source ", index, " metadata rows must match spectra columns",
call. = FALSE)
}
if (!is.null(id_col)) {
source_ids <- .lib_source_hash_ids(
wavenumber,
spectra,
metadata,
scale = hash_scale,
algo = hash_algo
)
metadata[[id_col]] <- source_ids$current
metadata[[paste0(id_col, "_old")]] <- source_ids$old
colnames(spectra) <- source_ids$current
}
object_unit <- attr(source, "intensity_unit", exact = TRUE)
object_unit <- as.character(object_unit)
object_unit <- object_unit[!is.na(object_unit) & trimws(object_unit) != ""]
if (length(object_unit) > 1L) {
stop("Source ", index,
" attribute 'intensity_unit' must contain at most one value",
call. = FALSE)
}
attribute_names <- c("intensity_unit", "derivative_order", "baseline",
"spectra_type")
attrs <- lapply(attribute_names, attr, x = source)
names(attrs) <- attribute_names
list(
wavenumber = wavenumber,
spectra = spectra,
metadata = metadata,
n = ncol(spectra),
intensity_attr = if (length(object_unit) == 1L) object_unit else NA_character_,
attrs = attrs
)
}
.lib_source_hash_ids <- function(wavenumber, spectra, metadata, scale = 100,
algo = "md5") {
ignored_info <- .spectra_ignore_info(spectra, lead_tail_only = TRUE,
ig = c(NA))
if (any(!ignored_info$has_valid)) {
stop("All intensity values are NA, cannot remove or ignore with manage na.",
call. = FALSE)
}
keep <- rowSums(ignored_info$ignored) == 0L
if (!any(keep)) {
ids <- lapply(seq_len(ncol(spectra)), function(i) {
valid <- !ignored_info$ignored[, i]
current <- .lib_hash_processed_matrix(
wavenumber[valid], spectra[valid, i, drop = FALSE],
range = NULL, res = 8, scale = scale, algo = algo
)
old <- .lib_hash_processed_matrix(
wavenumber[valid], spectra[valid, i, drop = FALSE],
range = c(100, 4000), res = 8, scale = scale, algo = algo,
min_rows = 3L, short_value = "new format"
)
list(current = current[[1L]], old = old[[1L]])
})
return(list(
current = vapply(ids, `[[`, character(1), "current"),
old = vapply(ids, `[[`, character(1), "old")
))
}
wavenumber <- wavenumber[keep]
spectra <- spectra[keep, , drop = FALSE]
current <- .lib_hash_processed_matrix(
wavenumber,
spectra,
range = NULL,
res = 8,
scale = scale,
algo = algo
)
old <- .lib_hash_processed_matrix(
wavenumber,
spectra,
range = c(100, 4000),
res = 8,
scale = scale,
algo = algo,
min_rows = 3L,
short_value = "new format"
)
list(current = current, old = old)
}
.lib_hash_processed_matrix <- function(wavenumber, spectra, range = NULL,
res = 8, scale = 100, algo = "md5",
min_rows = 1L,
short_value = NA_character_) {
conformed <- .lib_conform_hash_matrix(wavenumber, spectra, range = range,
res = res)
if (nrow(conformed$spectra) < min_rows) {
return(rep(short_value, ncol(spectra)))
}
smoothed <- .sgfilt_matrix(conformed$spectra, p = 3, n = 11, m = 1)
smoothed <- make_rel(abs(smoothed))
.lib_hash_spectra_columns(conformed$wavenumber, smoothed, scale, algo)
}
.lib_conform_hash_matrix <- function(wavenumber, spectra, range = NULL,
res = 8) {
if (is.null(range)) range <- wavenumber
range2 <- c(max(min(range), min(wavenumber)),
min(max(range), max(wavenumber)))
wn <- conform_res(range2, res = res)
spectra <- .conform_intens_matrix(
x = wavenumber,
y = spectra,
xout = wn
)
list(wavenumber = wn, spectra = spectra)
}
.lib_hash_spectra_columns <- function(wavenumber, spectra, scale = 100,
algo = "md5") {
vapply(seq_len(ncol(spectra)), function(i) {
digest::digest(
list(as.integer(wavenumber), as.integer(spectra[, i] * scale)),
algo = algo
)
}, FUN.VALUE = character(1))
}
.lib_combined_metadata <- function(records, metadata_name_lookup,
clean_metadata_values = FALSE) {
metadata <- data.table::rbindlist(
lapply(records, `[[`, "metadata"),
fill = TRUE
)
metadata <- .lib_normalize_metadata_encoding(metadata)
metadata <- lib_clean_metadata(
metadata,
metadata_name_lookup,
clean_values = clean_metadata_values
)
metadata <- metadata[, setdiff(names(metadata), c("x", "y")), with = FALSE]
metadata
}
.lib_normalize_metadata_encoding <- function(metadata) {
metadata <- data.table::as.data.table(data.table::copy(metadata))
for (column in names(metadata)) {
value <- metadata[[column]]
if (!is.character(value) && !is.factor(value)) next
value <- iconv(as.character(value), from = "", to = "UTF-8", sub = "")
data.table::set(metadata, j = column, value = value)
}
metadata
}
.lib_same_wavenumber <- function(records) {
if (length(records) <= 1L) return(TRUE)
first <- records[[1L]]$wavenumber
all(vapply(records[-1L], function(record) {
identical(record$wavenumber, first)
}, logical(1)))
}
.lib_common_record_attributes <- function(records) {
attribute_names <- c("intensity_unit", "derivative_order", "baseline",
"spectra_type")
out <- lapply(attribute_names, function(nm) {
values <- lapply(records, function(record) record$attrs[[nm]])
if (all(vapply(values[-1L], identical, logical(1), values[[1L]]))) {
values[[1L]]
} else {
NULL
}
})
names(out) <- attribute_names
out
}
.lib_make_open_specy <- function(wavenumber, spectra, metadata, attributes) {
metadata <- data.table::as.data.table(data.table::copy(metadata))
metadata$col_id <- colnames(spectra)
structure(
list(wavenumber = wavenumber, spectra = spectra, metadata = metadata),
class = c("OpenSpecy", "list"),
intensity_unit = attributes$intensity_unit,
derivative_order = attributes$derivative_order,
baseline = attributes$baseline,
spectra_type = attributes$spectra_type
)
}
.lib_combine_same_axis_records <- function(records, metadata, range, res) {
spectra <- do.call(cbind, lapply(records, `[[`, "spectra"))
colnames(spectra) <- make.unique(
unlist(lapply(records, function(record) colnames(record$spectra)),
use.names = FALSE),
sep = "."
)
attrs <- .lib_common_record_attributes(records)
lib <- .lib_make_open_specy(
records[[1L]]$wavenumber,
spectra,
metadata,
attrs
)
if (!is.null(range)) {
conform_range <- if (is.numeric(range)) {
range
} else if (length(range) == 1L && range %in% c("common", "full")) {
base::range(lib$wavenumber)
} else {
stop("If range is specified it should be numeric, 'full', or 'common'",
call. = FALSE)
}
lib <- conform_spec(
lib,
range = conform_range,
res = res,
allow_na = identical(range, "full") || is.numeric(range)
)
}
as_OpenSpecy(lib)
}
.lib_combine_variable_axis_records <- function(records, metadata, range, res) {
if (is.null(range)) {
stop("wavenumbers need to be identical between spectra; specify how; use ",
"'range' to specify how wavenumbers should be merged", call. = FALSE)
}
pmin <- vapply(records, function(record) min(record$wavenumber), numeric(1))
pmax <- vapply(records, function(record) max(record$wavenumber), numeric(1))
if (is.numeric(range)) {
target_range <- range
allow_na <- TRUE
} else if (length(range) == 1L && range %in% c("common", "full")) {
if (range == "common" &&
(any(max(pmin) > pmax) || any(min(pmax) < pmin))) {
stop("data points need to overlap in their ranges", call. = FALSE)
}
target_range <- if (range == "common") {
c(max(pmin), min(pmax))
} else {
c(min(pmin), max(pmax))
}
allow_na <- identical(range, "full")
} else {
stop("If range is specified it should be numeric, 'full', or 'common'",
call. = FALSE)
}
target_wn <- if (is.null(res)) {
sort(unique(unlist(lapply(records, function(record) {
record$wavenumber[
record$wavenumber >= min(target_range) &
record$wavenumber <= max(target_range)
]
}), use.names = FALSE)))
} else {
conform_res(target_range, res = res)
}
total_columns <- sum(vapply(records, `[[`, integer(1), "n"))
spectra <- matrix(
NA_real_, nrow = length(target_wn), ncol = total_columns
)
source_names <- unlist(lapply(records, function(record) {
colnames(record$spectra)
}), use.names = FALSE)
colnames(spectra) <- make.unique(source_names, sep = ".")
starts <- cumsum(c(1L, head(vapply(records, `[[`, integer(1), "n"), -1L)))
for (i in seq_along(records)) {
record <- records[[i]]
local_range <- c(
max(min(target_range), min(record$wavenumber)),
min(max(target_range), max(record$wavenumber))
)
local_wn <- if (is.null(res)) {
target_wn[target_wn >= min(record$wavenumber) &
target_wn <= max(record$wavenumber)]
} else {
conform_res(local_range, res = res)
}
conformed <- .conform_intens_matrix(
x = record$wavenumber,
y = record$spectra,
xout = local_wn
)
if (!allow_na && !identical(local_wn, target_wn)) {
stop("data points need to overlap in their ranges", call. = FALSE)
}
rows <- match(local_wn, target_wn)
columns <- starts[[i]] + seq_len(record$n) - 1L
spectra[rows, columns] <- conformed
records[[i]]$spectra <- NULL
rm(record, conformed)
}
.lib_make_open_specy(
target_wn,
spectra,
metadata,
.lib_common_record_attributes(records)
) |>
as_OpenSpecy()
}
.lib_convert_records_intensity <- function(records, metadata,
source_label = "source") {
attr_units <- unlist(lapply(records, function(record) {
rep(record$intensity_attr, record$n)
}), use.names = FALSE)
has_attr_unit <- !is.na(attr_units)
if (any(has_attr_unit)) {
if (!"intensity_units" %in% names(metadata)) {
metadata$intensity_units <- NA_character_
}
metadata$intensity_units[has_attr_unit] <- attr_units[has_attr_unit]
}
declared <- if ("intensity_units" %in% names(metadata)) {
as.character(metadata$intensity_units)
} else {
rep(NA_character_, nrow(metadata))
}
canonical <- .lib_canonical_intensity_unit(declared)
starts <- cumsum(c(1L, head(vapply(records, `[[`, integer(1), "n"), -1L)))
for (i in seq_along(records)) {
cols <- starts[[i]] + seq_len(records[[i]]$n) - 1L
records[[i]]$spectra <- .lib_convert_spectra_matrix(
records[[i]]$spectra,
canonical[cols]
)
resolved_i <- !is.na(canonical[cols])
if (all(resolved_i)) {
records[[i]]$attrs$intensity_unit <- "absorbance"
}
}
resolved <- !is.na(canonical)
if (any(resolved)) {
if (!"intensity_units" %in% names(metadata)) {
metadata$intensity_units <- NA_character_
}
metadata$intensity_units[resolved] <- "absorbance"
}
.lib_warn_unknown_intensity(declared, resolved, source_label)
list(records = records, metadata = metadata)
}
.lib_convert_spectra_matrix <- function(spectra, canonical) {
for (type in c("reflectance", "transmittance")) {
idx <- which(canonical == type)
if (length(idx) > 0L) {
spectra[, idx] <- switch(
type,
reflectance = (1 - spectra[, idx, drop = FALSE] / 100)^2 /
(2 * spectra[, idx, drop = FALSE] / 100),
transmittance = log(1 / .matrix_adj_neg(
spectra[, idx, drop = FALSE], na.rm = TRUE
))
)
}
}
spectra
}
.lib_warn_unknown_intensity <- function(declared, resolved, source_label) {
if (!any(!resolved)) return(invisible(NULL))
unresolved <- trimws(as.character(declared[!resolved]))
unresolved[is.na(unresolved) | unresolved == ""] <- "<missing>"
counts <- sort(table(unresolved), decreasing = TRUE)
details <- paste0(names(counts), " (", as.integer(counts), ")")
warning(
"Automatic intensity conversion skipped ", sum(!resolved),
" spectrum/s in ", source_label, " with unknown units: ",
paste(details, collapse = ", "),
". Set attr(x, 'intensity_unit') or metadata$intensity_units to ",
"'absorbance', 'reflectance', or 'transmittance'; use ",
"convert_intensity = FALSE to preserve units without conversion.",
call. = FALSE
)
invisible(NULL)
}
.lib_canonical_intensity_unit <- function(x) {
value <- trimws(tolower(iconv(as.character(x), to = "ASCII", sub = "")))
value[is.na(value) | value == ""] <- NA_character_
out <- rep(NA_character_, length(value))
out[!is.na(value) & grepl("absorb", value)] <- "absorbance"
out[!is.na(value) & grepl("reflec", value)] <- "reflectance"
out[!is.na(value) & grepl("transm", value)] <- "transmittance"
out
}
.lib_convert_intensity <- function(x, source_label = "source") {
x <- as_OpenSpecy(x)
object_unit <- attr(x, "intensity_unit", exact = TRUE)
object_unit <- as.character(object_unit)
object_unit <- object_unit[!is.na(object_unit) & trimws(object_unit) != ""]
if (length(object_unit) > 1L) {
stop("Object attribute 'intensity_unit' must contain at most one value",
call. = FALSE)
}
from_attribute <- length(object_unit) == 1L
declared <- if (from_attribute) {
rep(object_unit, ncol(x$spectra))
} else if ("intensity_units" %in% names(x$metadata)) {
as.character(x$metadata$intensity_units)
} else {
rep(NA_character_, ncol(x$spectra))
}
canonical <- .lib_canonical_intensity_unit(declared)
for (type in c("reflectance", "transmittance")) {
idx <- which(canonical == type)
if (length(idx) > 0L) {
converted <- adj_intens(
filter_spec(x, idx),
type = type,
make_rel = FALSE
)
x$spectra[, idx] <- converted$spectra
}
}
resolved <- !is.na(canonical)
if (any(resolved)) {
if (!"intensity_units" %in% names(x$metadata)) {
x$metadata$intensity_units <- NA_character_
}
x$metadata$intensity_units[resolved] <- "absorbance"
}
if (all(resolved)) {
attr(x, "intensity_unit") <- "absorbance"
} else if (!from_attribute) {
attr(x, "intensity_unit") <- NULL
}
if (any(!resolved)) {
unresolved <- trimws(as.character(declared[!resolved]))
unresolved[is.na(unresolved) | unresolved == ""] <- "<missing>"
counts <- sort(table(unresolved), decreasing = TRUE)
details <- paste0(names(counts), " (", as.integer(counts), ")")
warning(
"Automatic intensity conversion skipped ", sum(!resolved),
" spectrum/s in ", source_label, " with unknown units: ",
paste(details, collapse = ", "),
". Set attr(x, 'intensity_unit') or metadata$intensity_units to ",
"'absorbance', 'reflectance', or 'transmittance'; use ",
"convert_intensity = FALSE to preserve units without conversion.",
call. = FALSE
)
}
x
}
#' Create and apply metadata-name lookup rules
#'
#' @description
#' \code{lib_metadata_name_lookup()} returns the default editable rules used to
#' merge synonymous metadata columns. \code{lib_clean_name()} converts names to
#' lowercase underscore form. \code{lib_clean_metadata()} cleans table names and
#' coalesces columns that map to the same canonical name.
#'
#' @details
#' Exact rules determine a column's target before automatic matching that can
#' ignore underscores and a single terminal plural \code{s}. When values are
#' coalesced, canonical and mechanically equivalent canonical names come before
#' semantic aliases. Regular-expression rules are applied last to names that
#' remain unmatched. Regex patterns are evaluated against names after
#' \code{lib_clean_name()} has been applied.
#'
#' Matching options selected in \code{lib_metadata_name_lookup()} are stored
#' with the returned table and used by \code{lib_clean_metadata()}. User rules
#' supplied through \code{...} are merged with the defaults. Set
#' \code{defaults = FALSE} to construct a lookup from only user rules.
#'
#' @param ... named character vectors of exact aliases, where each argument name
#' is the canonical name, or data.frame/data.table rule tables with
#' \code{canonical_name}, \code{source_name}, and optional \code{regex} columns.
#' @param regex an optional named character vector or named list of regular
#' expressions. Names identify the canonical metadata names.
#' @param defaults logical; whether to include OpenSpecy's default semantic
#' aliases before merging user rules.
#' @param match_without_underscores logical; whether names that differ only by
#' underscores should match automatically.
#' @param match_singular_plural logical; whether names that differ only by one
#' terminal \code{s} should match automatically.
#' @param x a character vector of names for \code{lib_clean_name()}, or a
#' data.frame/data.table for \code{lib_clean_metadata()}.
#' @param name_lookup a table returned by \code{lib_metadata_name_lookup()} or a
#' compatible rule table. Use \code{NULL} to clean names without alias merging.
#' @param clean_values logical; whether \code{lib_clean_metadata()} should also
#' lowercase, trim, ASCII-normalize, and normalize blank/unknown character or
#' factor metadata values.
#'
#' @return \code{lib_metadata_name_lookup()} returns a data.table of rules.
#' \code{lib_clean_name()} returns a character vector.
#' \code{lib_clean_metadata()} returns a data.table with cleaned, coalesced
#' columns.
#'
#' @examples
#' lib_clean_name(c("User Name", "Laser (%)", "Method...3"))
#'
#' name_lookup <- lib_metadata_name_lookup(
#' project_code = "campaign name",
#' regex = list(instrument_mode = "^method_[0-9]+$")
#' )
#' metadata <- data.frame(
#' UserName = c("A", NA),
#' user_name = c(NA, "B"),
#' Campaign.Name = c("one", "two"),
#' Method.23 = c("ftir", "raman")
#' )
#' lib_clean_metadata(metadata, name_lookup)
#'
#' @export
lib_metadata_name_lookup <- function(..., regex = NULL, defaults = TRUE,
match_without_underscores = TRUE,
match_singular_plural = TRUE) {
aliases <- if (isTRUE(defaults)) list(
sample_name = character(),
file_name = character(),
library_type = character(),
contact_info = character(),
organization = character(),
citation = character(),
spectrum_identity = c("substance", "interpretation"),
spectrum_type = character(),
material_form = c("description", "form_factor", "shape",
"form_film_foam_pliable_hard", "form", "state",
"morphology"),
material_producer = character(),
material_purity = character(),
material_quality = c("source_type"),
material_color = c("color", "colour"),
material_other = character(),
cas_number = c("cas_registry_no"),
instrument_used = c("spectrometer_datasystem"),
instrument_accessories = c("instrumentaccesories",
"external_diffuse_reflectance_accessory"),
instrument_mode = c("spectralcollectionmode", "method_3", "method_23"),
intensity_units = c("y_unit"),
data_type = c("datatype"),
wavenumber_units = c("xunits", "x_unit"),
spectral_resolution = c("resolution"),
laser_light_used = c("laser_nm", "laser_frequency"),
number_of_accumulations = c("number_of_sample_scans", "coadded_scans"),
total_acquisition_time_s = c("collection_length", "acq_time_s"),
data_processing_procedure = c("preprocessing", "data_processing",
"data_processing_proceedure"),
level_of_confidence_in_identification = character(),
other_info = c("otherinformation", "otherinfo", "comment", "comments",
"notes"),
baseline_correction = c("baseline"),
smoother = c("smooth"),
user_name = character(),
sample_id = character(),
spectrum_id = c("spectrumid"),
location_description = c("locationdescription"),
longest_dimension = character(),
width = character(),
source = c("source_database", "origin", "nist_source"),
date = c("longdate", "timestamp"),
phase_correction = character(),
apodization = c("apodization_function")
) else list()
rules <- lapply(names(aliases), function(canonical) {
data.table::data.table(
canonical_name = canonical,
source_name = c(canonical, aliases[[canonical]]),
regex = NA_character_
)
})
additions <- list(...)
addition_names <- names(additions)
if (is.null(addition_names)) addition_names <- rep("", length(additions))
for (i in seq_along(additions)) {
addition <- additions[[i]]
if (inherits(addition, c("data.frame", "data.table"))) {
addition <- data.table::as.data.table(data.table::copy(addition))
.lib_require_cols(addition, "canonical_name", "metadata name rule")
if (!"source_name" %in% names(addition)) {
addition$source_name <- NA_character_
}
if (!"regex" %in% names(addition)) addition$regex <- NA_character_
rules[[length(rules) + 1L]] <-
addition[, c("canonical_name", "source_name", "regex"), with = FALSE]
} else {
canonical <- addition_names[i]
if (is.na(canonical) || canonical == "") {
stop("Exact alias additions in '...' must be named or supplied as ",
"rule tables", call. = FALSE)
}
aliases_i <- unlist(addition, use.names = FALSE)
if (!is.character(aliases_i)) {
stop("Exact alias additions in '...' must be character vectors",
call. = FALSE)
}
rules[[length(rules) + 1L]] <- data.table::data.table(
canonical_name = canonical,
source_name = c(canonical, aliases_i),
regex = NA_character_
)
}
}
if (!is.null(regex)) {
if (!is.list(regex)) regex <- as.list(regex)
regex_names <- names(regex)
if (is.null(regex_names) || any(is.na(regex_names) | regex_names == "")) {
stop("'regex' must be a named character vector or named list",
call. = FALSE)
}
regex_rules <- lapply(seq_along(regex), function(i) {
patterns <- unlist(regex[[i]], use.names = FALSE)
if (!is.character(patterns)) {
stop("Each 'regex' entry must contain character patterns",
call. = FALSE)
}
data.table::data.table(
canonical_name = regex_names[i],
source_name = NA_character_,
regex = patterns
)
})
rules <- c(rules, regex_rules)
}
lookup <- if (length(rules) == 0L) {
data.table::data.table(
canonical_name = character(),
source_name = character(),
regex = character()
)
} else {
data.table::rbindlist(rules, use.names = TRUE, fill = TRUE)
}
lookup$canonical_name <- lib_clean_name(lookup$canonical_name)
exact <- !is.na(lookup$source_name)
lookup$source_name[exact] <- lib_clean_name(lookup$source_name[exact])
empty_regex <- !is.na(lookup$regex) & lookup$regex == ""
lookup$regex[empty_regex] <- NA_character_
lookup <- unique(lookup)
has_source <- !is.na(lookup$source_name)
has_regex <- !is.na(lookup$regex)
if (any(has_source == has_regex)) {
stop("Each metadata name rule must contain exactly one of 'source_name' ",
"or 'regex'", call. = FALSE)
}
attr(lookup, "match_without_underscores") <-
isTRUE(match_without_underscores)
attr(lookup, "match_singular_plural") <- isTRUE(match_singular_plural)
lookup
}
.lib_fill_metadata_key <- function(metadata, canonical, fallback) {
if (!inherits(metadata, "data.table")) data.table::setDT(metadata)
if (!fallback %in% names(metadata)) return(data.table::data.table())
if (!canonical %in% names(metadata)) metadata[[canonical]] <- NA_character_
primary <- trimws(as.character(metadata[[canonical]]))
alternate <- trimws(as.character(metadata[[fallback]]))
primary_present <- !is.na(primary) & nzchar(primary)
alternate_present <- !is.na(alternate) & nzchar(alternate)
conflicts <- primary_present & alternate_present & primary != alternate
filled <- !primary_present & alternate_present
primary[filled] <- alternate[filled]
data.table::set(metadata, j = canonical, value = primary)
data.table::rbindlist(list(
data.table::data.table(
problem = "filled_canonical_key", column = canonical,
value = fallback, n = sum(filled)
)[n > 0L],
data.table::data.table(
problem = "canonical_key_conflict", column = canonical,
value = fallback, n = sum(conflicts)
)[n > 0L]
), fill = TRUE)
}
.lib_assign_library_name <- function(metadata) {
metadata <- data.table::as.data.table(metadata)
value <- function(column) {
if (column %in% names(metadata)) {
trimws(as.character(metadata[[column]]))
} else {
rep(NA_character_, nrow(metadata))
}
}
organization <- value("organization")
user_name <- value("user_name")
blank <- is.na(organization) | !nzchar(organization)
organization[blank] <- user_name[blank]
blank <- is.na(organization) | !nzchar(organization)
organization[blank] <- NA_character_
data.table::set(metadata, j = "library_name", value = organization)
metadata
}
.lib_library_stage_counts <- function(libraries, stage) {
rows <- list()
collect <- function(value, artifact) {
if (is_OpenSpecy(value)) {
metadata <- .lib_assign_library_name(data.table::copy(value$metadata))
names <- as.character(metadata$library_name)
names[is.na(names) | !nzchar(trimws(names))] <- "(missing)"
rows[[length(rows) + 1L]] <<- data.table::data.table(
stage = stage, artifact = artifact, library_name = names
)[, .(spectra = .N), by = .(stage, artifact, library_name)]
} else if (is.list(value)) {
for (name in names(value)) collect(value[[name]], artifact)
}
}
for (artifact in names(libraries)) collect(libraries[[artifact]], artifact)
if (!length(rows)) {
return(data.table::data.table(
stage = character(), artifact = character(),
library_name = character(), spectra = integer()
))
}
data.table::rbindlist(rows)[
, .(spectra = sum(spectra)), by = .(stage, artifact, library_name)
]
}
.lib_library_retention_report <- function(stages) {
if (inherits(stages, c("data.frame", "data.table"))) stages <- list(stages)
stages <- data.table::rbindlist(stages, fill = TRUE)
stage_order <- c(
"prepared", "post_exclusion", "core", "post_other", "post_quality",
"post_prune", "post_transform", "final"
)
if (!nrow(stages)) {
out <- data.table::data.table(artifact = character(),
library_name = character())
for (stage in stage_order) out[[paste0(stage, "_n")]] <- integer()
out[, `:=`(status = character(), reason = character())]
return(out)
}
stages <- stages[, lapply(.SD, sum),
by = c("stage", "artifact", "library_name"),
.SDcols = "spectra"]
wide <- data.table::dcast(
stages, stats::as.formula("artifact + library_name ~ stage"),
value.var = "spectra", fill = 0L
)
for (stage in stage_order) {
if (!stage %in% names(wide)) wide[[stage]] <- 0L
}
data.table::setcolorder(wide, c("artifact", "library_name", stage_order))
data.table::setnames(wide, stage_order, paste0(stage_order, "_n"))
wide <- data.table::copy(wide)
count_columns <- paste0(stage_order, "_n")
reason_by_stage <- c(
post_exclusion = "known_bad_identifier",
core = "deduplication",
post_other = "unresolved_other_policy",
post_quality = "quality_control",
post_prune = "class_support_or_correlation_pruning",
post_transform = "post_transform_flat_spectrum",
final = "type_range_or_flat_partition"
)
wide[["status"]] <- data.table::fcase(
wide[["final_n"]] == 0L, "dropped",
wide[["final_n"]] < wide[["prepared_n"]], "partially_retained",
default = "retained"
)
wide[["reason"]] <- NA_character_
for (i in 2:length(stage_order)) {
stage <- stage_order[[i]]
previous <- count_columns[[i - 1L]]
current <- count_columns[[i]]
rows <- wide$status == "dropped" & is.na(wide$reason) &
wide[[previous]] > 0L & wide[[current]] == 0L
wide$reason[rows] <- unname(reason_by_stage[[stage]])
}
partial <- wide[["status"]] == "partially_retained"
wide[["reason"]][partial] <- "one_or_more_spectra_removed_during_cleanup"
unexplained <- wide[["status"]] == "dropped" & is.na(wide[["reason"]])
wide[["reason"]][unexplained] <- "not_retained"
data.table::setorder(wide, artifact, library_name)
wide[]
}
#' @rdname build_lib
#' @export
make_lib_lookup_template <- function(x, columns, add = NULL, path = NULL) {
if (inherits(x, "FileSpecs"))
.filespec_stop_unsupported("make_lib_lookup_template()")
if (!(is_OpenSpecy(x) || is_Specs(x))) {
stop("'x' must be an OpenSpecy or Specs object", call. = FALSE)
}
metadata <- data.table::as.data.table(data.table::copy(x$metadata))
.lib_require_cols(metadata, columns, "metadata")
template <- data.table::as.data.table(metadata[, columns, with = FALSE])
template <- unique(template)
if (!is.null(add)) {
for (col in add) {
if (!col %in% names(template)) template[[col]] <- NA_character_
}
}
if (!is.null(path)) {
data.table::fwrite(template, path, na = "")
return(invisible(template))
}
template
}
#' @rdname build_lib
#' @export
join_lib_metadata <- function(x, lookup, by, require_complete = FALSE,
return = c("object", "table", "report"),
suffixes = c(".x", ".y")) {
if (inherits(x, "FileSpecs"))
.filespec_stop_unsupported("join_lib_metadata()")
return <- match.arg(return)
is_os <- is_OpenSpecy(x)
is_specs <- is_Specs(x)
if (!(is_os || is_specs)) {
stop("'x' must be an OpenSpecy or Specs object", call. = FALSE)
}
metadata <- data.table::as.data.table(data.table::copy(x$metadata))
lookup <- .lib_read_lookup(lookup)
keys <- if (is.null(names(by)) || all(names(by) == "")) {
list(x = unname(by), y = unname(by))
} else {
list(x = names(by), y = unname(by))
}
.lib_require_cols(metadata, keys$x, "metadata")
.lib_require_cols(lookup, keys$y, "lookup")
make_key <- function(tab, cols) {
vals <- lapply(cols, function(col) as.character(tab[[col]]))
any_na <- Reduce(`|`, lapply(vals, is.na))
key <- do.call(paste, c(vals, sep = "\r"))
key[any_na] <- NA_character_
key
}
metadata_key <- make_key(metadata, keys$x)
lookup_key <- make_key(lookup, keys$y)
value_cols <- setdiff(names(lookup), keys$y)
dups <- duplicated(lookup_key) | duplicated(lookup_key, fromLast = TRUE)
dups[is.na(lookup_key)] <- FALSE
dup_report <- if (any(dups)) {
data.table::data.table(value = lookup_key[dups])[
, .(n = .N), by = "value"][
, `:=`(problem = "duplicate_lookup_key",
column = paste(keys$y, collapse = "|"))][
, .(problem, column, value, n)]
} else {
data.table::data.table(problem = character(), column = character(),
value = character(), n = integer())
}
if (nrow(dup_report) > 0) {
attr(dup_report, "data") <- lookup
stop("Lookup keys must be unique before joining. Duplicate keys: ",
paste(head(dup_report$value, 10), collapse = ", "), call. = FALSE)
}
missing_key <- is.na(metadata_key) | !metadata_key %in% lookup_key
unmatched <- data.table::data.table(value = metadata_key[missing_key])[
!is.na(value), .(n = .N), by = "value"][
, `:=`(problem = "unmatched_metadata_key",
column = paste(keys$x, collapse = "|"))][
, .(problem, column, value, n)]
missing_values <- data.table::data.table()
matched <- match(metadata_key, lookup_key)
if (length(value_cols) > 0 && any(!is.na(matched))) {
for (col in value_cols) {
vals <- lookup[[col]][matched]
miss <- !is.na(matched) & is.na(vals)
if (any(miss)) {
missing_values <- rbind(missing_values, data.table::data.table(
problem = "missing_joined_value",
column = col,
value = metadata_key[miss],
n = 1L
)[, .(n = .N), by = .(problem, column, value)], fill = TRUE)
}
}
}
report <- data.table::rbindlist(list(unmatched, missing_values), fill = TRUE)
.lib_alert_join_report(report, require_complete)
lookup_values <- lookup[, setdiff(names(lookup), keys$y), with = FALSE]
meta <- data.table::copy(metadata)
look <- data.table::copy(lookup_values)
meta$..join_key <- metadata_key
meta$..row_id <- seq_len(nrow(meta))
look$..join_key <- lookup_key
joined <- merge(meta, look, by = "..join_key", all.x = TRUE, sort = FALSE,
suffixes = suffixes)
data.table::setorder(joined, "..row_id")
joined[, c("..join_key", "..row_id") := NULL]
attr(joined, "join_report") <- report
if (return == "report") return(list(data = joined, report = report))
if (return == "table") return(joined)
x$metadata <- joined
attr(x, "join_report") <- report
x
}
#' @rdname build_lib
#' @export
join_material_hierarchy <- function(x, hierarchy, key_col = "material",
levels = c("material", "material_class",
"material_type"),
output_names = levels,
require_complete = FALSE,
return = c("object", "table", "report")) {
if (inherits(x, "FileSpecs"))
.filespec_stop_unsupported("join_material_hierarchy()")
return <- match.arg(return)
is_os <- is_OpenSpecy(x)
is_specs <- is_Specs(x)
if (!(is_os || is_specs)) {
stop("'x' must be an OpenSpecy or Specs object", call. = FALSE)
}
metadata <- data.table::as.data.table(data.table::copy(x$metadata))
hierarchy <- .lib_read_lookup(hierarchy)
if (!is.null(names(output_names)) && any(names(output_names) != "")) {
missing <- setdiff(levels, names(output_names))
if (length(missing) > 0) {
stop("'output_names' is missing hierarchy levels: ",
paste(missing, collapse = ", "), call. = FALSE)
}
output_names <- unname(output_names[levels])
}
if (length(output_names) != length(levels)) {
stop("'output_names' must have the same length as 'levels'", call. = FALSE)
}
.lib_require_cols(metadata, key_col, "metadata")
.lib_require_cols(hierarchy, levels, "hierarchy")
keys <- as.character(metadata[[key_col]])
out <- metadata
for (col in output_names) out[[col]] <- NA_character_
matched_level <- rep(NA_character_, nrow(out))
remaining <- seq_len(nrow(out))
duplicate_reports <- list()
for (i in seq_along(levels)) {
if (length(remaining) == 0L) break
level <- levels[i]
cols <- levels[i:length(levels)]
h <- unique(hierarchy[, cols, with = FALSE])
h_key <- as.character(h[[level]])
dups <- duplicated(h_key) | duplicated(h_key, fromLast = TRUE)
dups[is.na(h_key)] <- FALSE
if (any(dups)) {
duplicate_reports[[level]] <- data.table::data.table(
problem = "duplicate_hierarchy_key",
level = level,
value = unique(h_key[dups])
)
next
}
idx <- match(keys[remaining], h_key)
found <- !is.na(idx)
rows <- remaining[found]
if (length(rows) > 0) {
for (j in i:length(levels)) {
out[[output_names[j]]][rows] <- h[[levels[j]]][idx[found]]
}
matched_level[rows] <- level
remaining <- remaining[!found]
}
}
unmatched <- data.table::data.table(value = keys[is.na(matched_level)])[
!is.na(value), .(n = .N), by = "value"][
, `:=`(problem = "unmatched_hierarchy_key",
column = "hierarchy")][
, .(problem, column, value, n)]
duplicates <- data.table::rbindlist(duplicate_reports, fill = TRUE)
if (nrow(duplicates) > 0) {
duplicates[, `:=`(column = level, n = 1L)]
duplicates <- duplicates[, .(problem, column, value, n)]
}
report <- data.table::rbindlist(list(unmatched, duplicates), fill = TRUE)
.lib_alert_join_report(report, require_complete)
attr(out, "join_report") <- report
if (return == "report") return(list(data = out, report = report))
if (return == "table") return(out)
x$metadata <- out
attr(x, "join_report") <- report
x
}
#' @rdname build_lib
#' @export
dedupe_spec <- function(x, id_col = "sample_name", exclude_ids = NULL,
duplicate = c("first", "remove_all", "none"),
scale = 100, algo = "md5") {
duplicate <- match.arg(duplicate)
x <- as_OpenSpecy(x)
ids <- vapply(seq_len(ncol(x$spectra)), function(i) {
digest::digest(list(as.integer(x$wavenumber),
as.integer(x$spectra[, i] * scale)),
algo = algo)
}, FUN.VALUE = character(1))
x$metadata[[id_col]] <- ids
colnames(x$spectra) <- ids
x$metadata$col_id <- ids
keep <- rep(TRUE, length(ids))
if (!is.null(exclude_ids)) keep <- keep & !ids %in% exclude_ids
if (duplicate == "first") keep <- keep & !duplicated(ids)
if (duplicate == "remove_all") {
keep <- keep & !(duplicated(ids) | duplicated(ids, fromLast = TRUE))
}
if (!all(keep)) x <- filter_spec(x, keep)
x
}
#' @rdname build_lib
#' @export
prune_lib <- function(x, class_col = "material_class",
type_col = "spectrum_type",
material_type_col = "material_type",
id_col = "sample_name", min_n = 10,
cross_class = FALSE,
cross_class_threshold = 0.9,
exclude = c(2200, 2420),
return = c("object", "ids", "report"),
progress = TRUE) {
return <- match.arg(return)
x <- as_OpenSpecy(x)
.lib_require_cols(x$metadata, c(class_col, type_col, material_type_col),
"metadata")
if (length(min_n) != 1L || is.na(min_n) || min_n < 1 || min_n %% 1 != 0) {
stop("'min_n' must be one positive whole number", call. = FALSE)
}
if (!is.logical(cross_class) || length(cross_class) != 1L ||
is.na(cross_class)) {
stop("'cross_class' must be TRUE or FALSE", call. = FALSE)
}
if (!is.numeric(cross_class_threshold) ||
length(cross_class_threshold) != 1L ||
!is.finite(cross_class_threshold) || cross_class_threshold < 0 ||
cross_class_threshold > 1) {
stop("'cross_class_threshold' must be one finite number in [0, 1]",
call. = FALSE)
}
if (!is.numeric(exclude) || length(exclude) != 2L || anyNA(exclude)) {
stop("'exclude' must be two finite wavenumbers", call. = FALSE)
}
if (!is.logical(progress) || length(progress) != 1L || is.na(progress)) {
stop("'progress' must be TRUE or FALSE", call. = FALSE)
}
metadata <- data.table::copy(x$metadata)
ids <- .lib_ids(x, id_col)
classes <- trimws(as.character(metadata[[class_col]]))
material_types <- trimws(tolower(as.character(
metadata[[material_type_col]]
)))
spectrum_types <- trimws(tolower(as.character(metadata[[type_col]])))
pools <- .lib_prune_pools(metadata[[type_col]])
normalized <- .lib_prune_normalize(x$spectra, x$wavenumber, exclude)
before_n <- length(ids)
cross_class_removals <- .lib_prune_cross_class_schema()
cross_class_conflicts <- .lib_prune_conflict_edge_schema()
if (isTRUE(cross_class)) {
if (!"library_name" %in% names(metadata)) {
stop(
"'cross_class = TRUE' requires populated library_name metadata for ",
"every resolved class spectrum", call. = FALSE
)
}
library_names <- trimws(as.character(metadata$library_name))
class_keys <- tolower(classes)
resolved <- !is.na(classes) & nzchar(classes) &
!class_keys %in% c(
"other", "other plastic", "other material", "unclassified"
)
if (any(resolved & (is.na(library_names) | !nzchar(library_names)))) {
stop(
"'cross_class = TRUE' requires populated library_name metadata for ",
"every resolved class spectrum", call. = FALSE
)
}
cross_normalized <- .lib_prune_normalize(
x$spectra, x$wavenumber, exclude = NULL
)
conflict <- .lib_prune_cross_class_conflicts(
classes, spectrum_types, cross_normalized, ids, library_names,
threshold = cross_class_threshold, progress = progress,
block_size = 256L, support_groups = spectrum_types, min_n = min_n,
correlation_view = "prune_full"
)
cross_class_removals <- conflict$removals
cross_class_conflicts <- conflict$conflicts
if (length(conflict$removed_rows)) {
keep <- rep(TRUE, length(ids))
keep[conflict$removed_rows] <- FALSE
if (!any(keep)) {
stop("Cross-class pruning would remove every spectrum",
call. = FALSE)
}
x <- filter_spec(x, keep)
metadata <- data.table::copy(x$metadata)
ids <- ids[keep]
classes <- classes[keep]
material_types <- material_types[keep]
spectrum_types <- spectrum_types[keep]
pools <- pools[keep]
library_names <- library_names[keep]
normalized <- normalized[keep, , drop = FALSE]
}
rm(cross_normalized)
}
generic_reassigned <- .lib_reassign_other_classes(
classes, material_types, pools, normalized, ids, progress = progress
)
classes <- generic_reassigned$classes
material_types <- generic_reassigned$material_types
small_reassigned <- .lib_reassign_small_classes(
classes, material_types, spectrum_types, pools, normalized, ids,
min_n = min_n, progress = progress
)
classes <- small_reassigned$classes
material_types <- small_reassigned$material_types
reassignment_report <- data.table::rbindlist(list(
generic_reassigned$report, small_reassigned$report
), fill = TRUE)
metadata[[class_col]] <- classes
metadata[[material_type_col]] <- material_types
protected <- tolower(classes) %in% c(
"unclassified", "other", "other plastic", "other material"
)
support <- data.table::data.table(
row = seq_along(ids), spectrum_type = spectrum_types, pool = pools,
material_class = classes
)[
!is.na(spectrum_type) & nzchar(spectrum_type) &
!is.na(material_class) & nzchar(material_class),
.(observed_n = .N), by = .(spectrum_type, pool, material_class)
]
if (nrow(support)) {
data.table::setorder(
support, spectrum_type, -observed_n, material_class
)
}
excluded_classes <- small_reassigned$classes_report
if (!nrow(excluded_classes)) {
excluded_classes <- .lib_prune_excluded_class_schema()
}
threshold_rows <- small_reassigned$dropped_rows
active <- rep(TRUE, length(ids))
active[threshold_rows] <- FALSE
if (isTRUE(progress)) {
if (nrow(excluded_classes)) {
preview_n <- min(8L, nrow(excluded_classes))
preview <- excluded_classes[seq_len(preview_n), sprintf(
"%s / %s (n=%d; shortfall=%d)",
spectrum_type, material_class, observed_n, shortfall
)]
if (nrow(excluded_classes) > preview_n) {
preview <- c(
preview,
sprintf("and %d more", nrow(excluded_classes) - preview_n)
)
}
message(sprintf(
paste0("prune_lib: minimum support gate reassigned %d and removed %d ",
"spectrum/spectra across %d class/type group(s) below ",
"min_n=%d: %s"),
nrow(small_reassigned$report), length(threshold_rows),
nrow(excluded_classes), min_n,
paste(preview, collapse = "; ")
))
} else {
message(sprintf(
paste0("prune_lib: minimum support gate complete ",
"(%d class/type group(s); none below min_n=%d)"),
nrow(support), min_n
))
}
}
schedule <- data.table::data.table(
pool = pools[active],
material_class = classes[active],
is_protected = protected[active]
)[!is.na(pool) & !is.na(material_class) & nzchar(material_class),
.(initial_n = .N, is_protected = all(is_protected)),
by = .(pool, material_class)][is_protected == FALSE]
if (nrow(schedule) > 0L) {
data.table::setorder(schedule, pool, -initial_n, material_class)
schedule[, schedule_order := seq_len(.N), by = pool]
} else {
schedule[, schedule_order := integer()]
}
removal_rows <- if (nrow(cross_class_removals)) {
list(cross_class_removals)
} else {
list()
}
if (length(threshold_rows)) {
removal_rows[[length(removal_rows) + 1L]] <- data.table::data.table(
spectrum_id = ids[threshold_rows],
prior_class = classes[threshold_rows],
matched_id = NA_character_, matched_class = NA_character_,
correlation = NA_real_, pool = pools[threshold_rows],
schedule_order = NA_integer_, reason = "class_below_min_n"
)
}
removal_i <- length(removal_rows)
if (isTRUE(progress)) {
message(sprintf(
"prune_lib: starting %d scheduled class(es) across %d spectrum pool(s)",
nrow(schedule), length(unique(stats::na.omit(pools)))
))
}
if (nrow(schedule) > 0L) {
for (s in seq_len(nrow(schedule))) {
target_pool <- schedule$pool[[s]]
target_class <- schedule$material_class[[s]]
if (isTRUE(progress)) {
message(sprintf(
"prune_lib: %s / %s (%d initially)",
target_pool, target_class, schedule$initial_n[[s]]
))
}
initial_target <- which(active & pools == target_pool &
!is.na(classes) & classes == target_class)
candidates <- which(active & pools == target_pool & !protected)
candidate_order <- order(ids[candidates], candidates, na.last = TRUE)
candidates <- candidates[candidate_order]
iteration <- 0L
best_index <- rep(NA_integer_, length(active))
best_correlation <- rep(NA_real_, length(active))
query <- initial_target
repeat {
target <- which(active & pools == target_pool &
!is.na(classes) & classes == target_class)
if (length(target) <= min_n) break
if (length(query) > 0L) {
active_candidates <- candidates[active[candidates]]
correlation_started <- proc.time()[["elapsed"]]
if (isTRUE(progress)) {
message(sprintf(
paste0("prune_lib: %s / %s blockwise correlation starting ",
"(%d query; %d eligible; iteration=%d)"),
target_pool, target_class, length(query),
length(active_candidates), iteration + 1L
))
}
best <- .lib_prune_best_match(
query, active_candidates, normalized, ids,
exclude_self = TRUE, block_size = 32L
)
best_index[query] <- best$index
best_correlation[query] <- best$correlation
rm(best)
gc(verbose = FALSE, full = TRUE)
if (isTRUE(progress)) {
message(sprintf(
paste0("prune_lib: %s / %s blockwise correlation complete ",
"(%d query; %d eligible; %.1fs)"),
target_pool, target_class, length(query),
length(active_candidates),
proc.time()[["elapsed"]] - correlation_started
))
}
}
target_best <- best_index[target]
target_correlation <- best_correlation[target]
conflicts <- which(
!is.na(target_best) & is.finite(target_correlation) &
classes[target_best] != target_class
)
conflicts <- conflicts[!is.na(conflicts)]
if (length(conflicts) == 0L) break
removable_n <- min(length(conflicts), length(target) - min_n)
if (removable_n < 1L) break
conflict_rows <- data.table::data.table(
query = target[conflicts],
matched = target_best[conflicts],
correlation = target_correlation[conflicts]
)
conflict_rows[, query_id := ids[query]]
data.table::setorder(conflict_rows, -correlation, query_id)
conflict_rows <- conflict_rows[seq_len(removable_n)]
active[conflict_rows$query] <- FALSE
iteration <- iteration + 1L
if (isTRUE(progress)) {
message(sprintf(
paste0("prune_lib: %s / %s iteration %d ",
"(removed=%d; retained=%d)"),
target_pool, target_class, iteration, nrow(conflict_rows),
sum(active[initial_target])
))
}
removal_i <- removal_i + 1L
removal_rows[[removal_i]] <- conflict_rows[, .(
spectrum_id = ids[query],
prior_class = classes[query],
matched_id = ids[matched],
matched_class = classes[matched],
correlation,
pool = target_pool,
schedule_order = schedule$schedule_order[[s]],
reason = "top_match_other_class"
)]
remaining_target <- target[active[target]]
query <- remaining_target[
is.na(best_index[remaining_target]) |
!active[best_index[remaining_target]]
]
if (length(query) == 0L) {
break
}
}
if (isTRUE(progress)) {
retained <- sum(active & pools == target_pool &
!is.na(classes) & classes == target_class)
message(sprintf(
"prune_lib: %s / %s complete (retained=%d; removed=%d)",
target_pool, target_class, retained,
schedule$initial_n[[s]] - retained
))
}
}
}
if (isTRUE(cross_class) && any(active)) {
active_rows <- which(active)
closure_normalized <- .lib_prune_normalize(
x$spectra[, active_rows, drop = FALSE], x$wavenumber, exclude = NULL
)
closure <- .lib_prune_cross_class_conflicts(
classes[active_rows], spectrum_types[active_rows], closure_normalized,
ids[active_rows], library_names[active_rows],
threshold = cross_class_threshold, progress = progress,
block_size = 256L, support_groups = spectrum_types[active_rows],
min_n = min_n, correlation_view = "post_reassignment_full"
)
if (nrow(closure$removals)) {
closure$removals[, phase := "final_closure"]
cross_class_removals <- data.table::rbindlist(
list(cross_class_removals, closure$removals), fill = TRUE
)
removal_i <- removal_i + 1L
removal_rows[[removal_i]] <- closure$removals
active[active_rows[closure$removed_rows]] <- FALSE
}
if (nrow(closure$conflicts)) {
closure$conflicts[, phase := "final_closure"]
cross_class_conflicts <- data.table::rbindlist(
list(cross_class_conflicts, closure$conflicts), fill = TRUE
)
}
rm(closure_normalized, closure)
resolved_active <- active & !protected & !is.na(spectrum_types) &
nzchar(spectrum_types) & !is.na(classes) & nzchar(classes)
final_support <- data.table::data.table(
row = which(resolved_active),
spectrum_type = spectrum_types[resolved_active],
material_class = classes[resolved_active]
)[, .(rows = list(row), observed_n = .N),
by = .(spectrum_type, material_class)]
small_final <- final_support[observed_n < min_n]
if (nrow(small_final)) {
drop <- unlist(small_final$rows, use.names = FALSE)
count_lookup <- small_final$observed_n[match(
paste(spectrum_types[drop], classes[drop]),
paste(small_final$spectrum_type, small_final$material_class)
)]
floor_removals <- data.table::data.table(
phase = "final_closure", correlation_view = "global_support",
component_id = NA_character_, spectrum_id = ids[drop],
prior_class = classes[drop], library_name = library_names[drop],
matched_id = NA_character_, matched_class = NA_character_,
matched_library = NA_character_, correlation = NA_real_,
pool = spectrum_types[drop], active_degree = 0L,
matched_active_degree = 0L, evidence_libraries = 0L,
conflicting_spectra = 0L, matched_evidence_libraries = 0L,
decision_round = NA_integer_, threshold = cross_class_threshold,
class_n_before = as.integer(count_lookup), class_n_after = 0L,
schedule_order = NA_integer_, quarantine_status = "excluded",
review_status = "global_support_at_risk",
reason = "class_below_global_min_n_after_closure"
)
active[drop] <- FALSE
cross_class_removals <- data.table::rbindlist(
list(cross_class_removals, floor_removals), fill = TRUE
)
removal_i <- removal_i + 1L
removal_rows[[removal_i]] <- floor_removals
}
}
rm(normalized)
removals <- if (length(removal_rows) > 0L) {
data.table::rbindlist(removal_rows, fill = TRUE)
} else {
data.table::data.table(
spectrum_id = character(), prior_class = character(),
matched_id = character(), matched_class = character(),
correlation = numeric(), pool = character(), schedule_order = integer(),
reason = character()
)
}
retained_ids <- ids[active]
out <- x
out$metadata <- metadata
if (!all(active)) out <- filter_spec(out, active)
audit <- list(
retained_ids = retained_ids,
schedule = schedule,
excluded_classes = excluded_classes,
reassignments = reassignment_report,
cross_class_removals = cross_class_removals,
cross_class_conflicts = cross_class_conflicts,
removals = removals,
summary = data.table::data.table(
before = before_n,
after = sum(active),
reassigned = nrow(reassignment_report),
classes_excluded = sum(excluded_classes$action == "dropped"),
cross_class_removed = nrow(cross_class_removals),
threshold_removed = length(threshold_rows),
removed = before_n - sum(active)
)
)
attr(out, "prune_report") <- audit
if (isTRUE(progress)) {
message(sprintf(
paste0("prune_lib: complete (before=%d; after=%d; reassigned=%d; ",
"classes_excluded=%d; cross_class_removed=%d; ",
"threshold_removed=%d; removed=%d)"),
before_n, sum(active), nrow(reassignment_report),
sum(excluded_classes$action == "dropped"),
nrow(cross_class_removals), length(threshold_rows),
before_n - sum(active)
))
}
if (return == "ids") return(retained_ids)
if (return == "report") return(c(list(object = out), audit))
out
}
.lib_prune_excluded_class_schema <- function() {
data.table::data.table(
spectrum_type = character(), pool = character(),
material_class = character(), observed_n = integer(),
minimum_spectra = integer(), shortfall = integer(),
destination_class = character(), mean_correlation = numeric(),
spectra_removed = integer(), action = character(), reason = character()
)
}
.lib_prune_excluded_assessment_schema <- function() {
out <- .lib_prune_excluded_class_schema()
out[, artifact := character()]
data.table::setcolorder(out, c("artifact", setdiff(names(out), "artifact")))
out
}
.lib_prune_pools <- function(type) {
type <- trimws(tolower(as.character(type)))
out <- ifelse(type %in% c("ftir", "nir"), "ftir_nir",
ifelse(type == "raman", "raman", type))
out[is.na(type) | !nzchar(type)] <- NA_character_
out
}
.lib_prune_normalize <- function(spectra, wavenumber, exclude) {
use <- if (is.null(exclude)) {
rep(TRUE, length(wavenumber))
} else {
limits <- sort(exclude)
wavenumber < limits[[1L]] | wavenumber > limits[[2L]]
}
if (!any(use)) {
stop("'exclude' removes every wavenumber", call. = FALSE)
}
# Reproduce cor_spec()'s exact make_rel -> mean replacement -> correlation
# scaling sequence in bounded spectrum blocks. Preserving that operation
# order keeps deterministic tie-breaking while avoiding full correlation
# matrices during pruning.
values <- matrix(NA_real_, nrow = ncol(spectra), ncol = sum(use))
row_block <- 256L
blocks <- split(seq_len(nrow(values)),
ceiling(seq_len(nrow(values)) / row_block))
for (block_index in seq_along(blocks)) {
block <- blocks[[block_index]]
part <- spectra[use, block, drop = FALSE]
part <- make_rel(part, na.rm = TRUE)
part <- .matrix_mean_replace(part)
values[block, ] <- .scale_correlation_spectra(part)
}
values
}
.lib_prune_best_match <- function(query, candidates, normalized, ids,
exclude_self = FALSE, block_size = 32L) {
candidate_order <- order(ids[candidates], candidates, na.last = TRUE)
candidates <- candidates[candidate_order]
best_index <- rep(NA_integer_, length(query))
best_correlation <- rep(NA_real_, length(query))
blocks <- split(seq_along(query),
ceiling(seq_along(query) / as.integer(block_size)))
for (block_index in seq_along(blocks)) {
block <- blocks[[block_index]]
# Multiply against the resident normalized matrix and subset the small
# correlation block, rather than copying the full candidate library for
# every query block.
cors <- tcrossprod(normalized[query[block], , drop = FALSE], normalized)
cors <- cors[, candidates, drop = FALSE]
cors[!is.finite(cors)] <- -Inf
if (isTRUE(exclude_self)) {
self_col <- match(query[block], candidates)
has_self <- !is.na(self_col)
cors[cbind(which(has_self), self_col[has_self])] <- -Inf
}
top <- matrixStats::rowMaxs(cors)
tolerance <- sqrt(.Machine$double.eps) * pmax(1, abs(top))
near_top <- abs(cors - top) <= tolerance
near_top[is.na(near_top)] <- FALSE
local <- max.col(near_top, ties.method = "first")
scores <- cors[cbind(seq_along(block), local)]
ok <- is.finite(scores)
best_index[block[ok]] <- candidates[local[ok]]
best_correlation[block[ok]] <- scores[ok]
rm(cors, top, tolerance, near_top, local, scores)
if (block_index %% 8L == 0L) {
gc(verbose = FALSE, full = TRUE)
}
}
gc(verbose = FALSE, full = TRUE)
list(index = best_index, correlation = best_correlation)
}
.lib_prune_cross_class_schema <- function() {
data.table::data.table(
phase = character(), correlation_view = character(),
component_id = character(),
spectrum_id = character(), prior_class = character(),
library_name = character(), matched_id = character(),
matched_class = character(), matched_library = character(),
correlation = numeric(), pool = character(),
active_degree = integer(), matched_active_degree = integer(),
evidence_libraries = integer(), conflicting_spectra = integer(),
matched_evidence_libraries = integer(), decision_round = integer(),
threshold = numeric(), class_n_before = integer(),
class_n_after = integer(), schedule_order = integer(),
quarantine_status = character(), review_status = character(),
reason = character()
)
}
.lib_prune_conflict_edge_schema <- function() {
data.table::data.table(
phase = character(), correlation_view = character(),
component_id = character(), pool = character(),
spectrum_id = character(), prior_class = character(),
library_name = character(), matched_id = character(),
matched_class = character(), matched_library = character(),
correlation = numeric(), same_library = logical(),
class_pair_libraries = integer(), review_status = character(),
threshold = numeric()
)
}
.lib_prune_removal_assessment_schema <- function() {
out <- .lib_prune_cross_class_schema()
out[, artifact := character()]
data.table::setcolorder(out, c("artifact", setdiff(names(out), "artifact")))
out
}
.lib_prune_internal_class_conflicts <- function(
classes, pools, normalized, ids, library_names, threshold,
support_groups = pools, min_n = 1L, correlation_view = "prune_full",
progress = FALSE, block_size = 32L) {
n <- length(ids)
stopifnot(
length(classes) == n, length(pools) == n,
length(library_names) == n, length(support_groups) == n,
nrow(normalized) == n
)
generic <- c("other", "other plastic", "other material", "unclassified")
class_keys <- tolower(trimws(classes))
eligible <- !is.na(pools) & nzchar(pools) &
!is.na(classes) & nzchar(classes) & !class_keys %in% generic &
!is.na(library_names) & nzchar(library_names)
groups <- unique(data.table::data.table(
pool = pools[eligible], library_name = library_names[eligible]
))
data.table::setorder(groups, pool, library_name)
edge_rows <- list()
edge_i <- 0L
started <- proc.time()[["elapsed"]]
for (group_i in seq_len(nrow(groups))) {
pool <- groups$pool[[group_i]]
library_name <- groups$library_name[[group_i]]
candidates <- which(
eligible & pools == pool & library_names == library_name
)
candidates <- candidates[order(ids[candidates], candidates, na.last = TRUE)]
if (length(candidates) < 2L) next
blocks <- split(candidates, ceiling(seq_along(candidates) / block_size))
for (query in blocks) {
cors <- tcrossprod(
normalized[query, , drop = FALSE],
normalized[candidates, , drop = FALSE]
)
cors[!is.finite(cors)] <- -Inf
for (row in seq_along(query)) {
q <- query[[row]]
matched <- candidates[
candidates > q & cors[row, ] > threshold &
classes[candidates] != classes[[q]]
]
if (!length(matched)) next
edge_i <- edge_i + 1L
edge_rows[[edge_i]] <- data.table::data.table(
from = q, to = matched,
correlation = cors[row, match(matched, candidates)],
pool = pool, library_name = library_name
)
}
rm(cors)
}
}
edges <- if (length(edge_rows)) {
data.table::rbindlist(edge_rows)
} else {
data.table::data.table(
from = integer(), to = integer(), correlation = numeric(),
pool = character(), library_name = character()
)
}
if (!nrow(edges)) {
return(list(
removed_rows = integer(), removals = .lib_prune_cross_class_schema(),
conflicts = .lib_prune_conflict_edge_schema()
))
}
parent <- seq_len(n)
find_root <- function(value) {
while (parent[[value]] != value) value <- parent[[value]]
value
}
for (edge_i in seq_len(nrow(edges))) {
left <- find_root(edges$from[[edge_i]])
right <- find_root(edges$to[[edge_i]])
if (left != right) parent[[max(left, right)]] <- min(left, right)
}
vertices <- sort(unique(c(edges$from, edges$to)))
roots <- vapply(vertices, find_root, integer(1L))
root_labels <- vapply(split(vertices, roots), function(rows) {
sort(ids[rows], na.last = TRUE)[[1L]]
}, character(1L))
component <- rep(NA_character_, n)
component[vertices] <- paste0("internal:", root_labels[as.character(roots)])
pair_left <- pmin(classes[edges$from], classes[edges$to])
pair_right <- pmax(classes[edges$from], classes[edges$to])
pair_stats <- data.table::data.table(
pool = edges$pool, class_left = pair_left, class_right = pair_right,
library_name = edges$library_name
)[, .(class_pair_libraries = data.table::uniqueN(library_name)),
by = .(pool, class_left, class_right)]
pair_lookup <- pair_stats[data.table::data.table(
pool = edges$pool, class_left = pair_left, class_right = pair_right
), on = .(pool, class_left, class_right)]$class_pair_libraries
active <- eligible
decisions <- list()
decision_i <- 0L
round <- 0L
repeat {
edge_active <- active[edges$from] & active[edges$to]
current <- edges[edge_active]
if (!nrow(current)) break
degree <- tabulate(c(current$from, current$to), nbins = n)
maximum <- max(degree)
top <- which(active & degree == maximum)
tied_edges <- current[from %in% top & to %in% top]
if (nrow(tied_edges)) {
remove <- sort(unique(c(tied_edges$from, tied_edges$to)))
tied <- rep(TRUE, length(remove))
} else {
remove <- top[order(ids[top], top, na.last = TRUE)][[1L]]
tied <- FALSE
}
round <- round + 1L
for (remove_i in seq_along(remove)) {
q <- remove[[remove_i]]
incident <- current[from == q | to == q]
neighbor <- ifelse(incident$from == q, incident$to, incident$from)
selected_order <- order(
-incident$correlation, ids[neighbor], neighbor, na.last = TRUE
)
selected_row <- selected_order[[1L]]
matched <- neighbor[[selected_row]]
class_before <- sum(
active & support_groups == support_groups[[q]] &
classes == classes[[q]], na.rm = TRUE
)
class_removed <- sum(
remove %in% which(
active & support_groups == support_groups[[q]] &
classes == classes[[q]]
)
)
class_after <- class_before - class_removed
incident_rows <- which(
(edges$from == q | edges$to == q) & edge_active
)
recurrent <- any(pair_lookup[incident_rows] >= 2L)
review <- if (class_after > 0L && class_after < min_n) {
"global_support_at_risk"
} else if (recurrent) {
"recurrent_class_pair"
} else {
"automatic"
}
decision_i <- decision_i + 1L
decisions[[decision_i]] <- data.table::data.table(
phase = "internal", correlation_view = correlation_view,
component_id = component[[q]], spectrum_id = ids[[q]],
prior_class = classes[[q]], library_name = library_names[[q]],
matched_id = ids[[matched]], matched_class = classes[[matched]],
matched_library = library_names[[matched]],
correlation = incident$correlation[[selected_row]], pool = pools[[q]],
active_degree = degree[[q]],
matched_active_degree = degree[[matched]], evidence_libraries = 0L,
conflicting_spectra = degree[[q]],
matched_evidence_libraries = 0L, decision_round = round,
threshold = threshold, class_n_before = class_before,
class_n_after = class_after, schedule_order = NA_integer_,
quarantine_status = "excluded", review_status = review,
reason = if (isTRUE(tied[[remove_i]])) {
"cross_class_equal_internal_degree"
} else {
"cross_class_more_internal_conflicts"
}
)
}
active[remove] <- FALSE
}
removals <- data.table::rbindlist(decisions, fill = TRUE)
if (!nrow(removals)) removals <- .lib_prune_cross_class_schema()
data.table::setorder(removals, decision_round, -active_degree, spectrum_id)
conflicts <- data.table::data.table(
phase = "internal", correlation_view = correlation_view,
component_id = component[edges$from], pool = edges$pool,
spectrum_id = ids[edges$from], prior_class = classes[edges$from],
library_name = library_names[edges$from], matched_id = ids[edges$to],
matched_class = classes[edges$to],
matched_library = library_names[edges$to],
correlation = edges$correlation, same_library = TRUE,
class_pair_libraries = pair_lookup,
review_status = ifelse(
pair_lookup >= 2L, "recurrent_class_pair", "automatic"
), threshold = threshold
)
if (isTRUE(progress)) message(sprintf(
paste0("prune_lib: internal cross-class pass complete ",
"(edges=%d; removed=%d; elapsed=%.1fs)"),
nrow(edges), nrow(removals), proc.time()[["elapsed"]] - started
))
list(
removed_rows = which(!active & eligible), removals = removals,
conflicts = conflicts
)
}
.lib_prune_between_library_conflicts <- function(classes, pools, normalized, ids,
library_names, threshold,
correlation_view = "prune_full",
progress = FALSE,
block_size = 32L) {
n <- length(ids)
started <- proc.time()[["elapsed"]]
stopifnot(
length(classes) == n, length(pools) == n,
length(library_names) == n, nrow(normalized) == n
)
generic <- c("other", "other plastic", "other material", "unclassified")
class_keys <- tolower(trimws(classes))
eligible <- !is.na(pools) & nzchar(pools) &
!is.na(classes) & nzchar(classes) & !class_keys %in% generic
evidence_libraries <- integer(n)
conflicting_spectra <- integer(n)
pool_values <- sort(unique(pools[eligible]))
pool_values <- pool_values[!is.na(pool_values) & nzchar(pool_values)]
for (pool in pool_values) {
candidates <- which(eligible & pools == pool)
candidates <- candidates[order(ids[candidates], candidates, na.last = TRUE)]
blocks <- split(candidates, ceiling(seq_along(candidates) / block_size))
if (isTRUE(progress)) {
message(sprintf(
paste0("prune_lib: cross-class evidence scan %s starting ",
"(%d spectra; %d features; block=%d; threshold > %.4f)"),
pool, length(candidates), ncol(normalized), block_size, threshold
))
}
for (query in blocks) {
cors <- tcrossprod(
normalized[query, , drop = FALSE],
normalized[candidates, , drop = FALSE]
)
cors[!is.finite(cors)] <- -Inf
for (row in seq_along(query)) {
q <- query[[row]]
matched <- candidates[
cors[row, ] > threshold & candidates != q &
library_names[candidates] != library_names[[q]] &
classes[candidates] != classes[[q]]
]
conflicting_spectra[[q]] <- length(matched)
if (length(matched)) {
evidence_libraries[[q]] <- length(unique(library_names[matched]))
}
}
rm(cors)
}
if (isTRUE(progress)) {
message(sprintf(
paste0("prune_lib: cross-class evidence scan %s complete ",
"(flagged=%d; max_libraries=%d; elapsed=%.1fs)"),
pool, sum(conflicting_spectra[candidates] > 0L),
max(evidence_libraries[candidates], 0L),
proc.time()[["elapsed"]] - started
))
}
}
scores <- sort(unique(evidence_libraries[conflicting_spectra > 0L]),
decreasing = TRUE)
scores <- scores[scores > 0L]
if (!length(scores)) {
return(list(removed_rows = integer(), removals =
.lib_prune_cross_class_schema()))
}
active <- eligible
decision_match <- rep(NA_integer_, n)
decision_correlation <- rep(NA_real_, n)
decision_round <- integer(n)
decision_reason <- rep(NA_character_, n)
for (round in seq_along(scores)) {
score <- scores[[round]]
remove_round <- integer()
for (pool in pool_values) {
query_rows <- which(
active & pools == pool & evidence_libraries == score
)
if (!length(query_rows)) next
candidates <- which(active & pools == pool)
candidates <- candidates[
order(ids[candidates], candidates, na.last = TRUE)
]
blocks <- split(
query_rows, ceiling(seq_along(query_rows) / block_size)
)
for (query in blocks) {
cors <- tcrossprod(
normalized[query, , drop = FALSE],
normalized[candidates, , drop = FALSE]
)
cors[!is.finite(cors)] <- -Inf
for (row in seq_along(query)) {
q <- query[[row]]
matched <- candidates[
cors[row, ] > threshold & candidates != q &
library_names[candidates] != library_names[[q]] &
classes[candidates] != classes[[q]]
]
if (!length(matched)) next
if (any(evidence_libraries[matched] > score)) {
stop("Internal cross-class pruning order invariant failed",
call. = FALSE)
}
tied <- matched[evidence_libraries[matched] == score]
decision_candidates <- if (length(tied)) tied else matched
candidate_columns <- match(decision_candidates, candidates)
candidate_correlations <- cors[row, candidate_columns]
best_order <- order(
-candidate_correlations, ids[decision_candidates],
decision_candidates, na.last = TRUE
)
selected <- decision_candidates[best_order[[1L]]]
decision_match[[q]] <- selected
decision_correlation[[q]] <- cors[
row, match(selected, candidates)
]
decision_round[[q]] <- round
decision_reason[[q]] <- if (length(tied)) {
"cross_class_equal_library_evidence"
} else {
"cross_class_more_library_evidence"
}
remove_round <- c(remove_round, q)
}
rm(cors)
}
}
remove_round <- sort(unique(remove_round))
active[remove_round] <- FALSE
if (isTRUE(progress)) {
message(sprintf(
paste0("prune_lib: cross-class decision round %d/%d ",
"(evidence_libraries=%d; removed=%d; active=%d; elapsed=%.1fs)"),
round, length(scores), score, length(remove_round), sum(active),
proc.time()[["elapsed"]] - started
))
}
}
retained <- which(active)
for (pool in pool_values) {
candidates <- retained[pools[retained] == pool]
if (length(candidates) < 2L) next
candidates <- candidates[order(ids[candidates], candidates, na.last = TRUE)]
blocks <- split(candidates, ceiling(seq_along(candidates) / block_size))
for (query in blocks) {
cors <- tcrossprod(
normalized[query, , drop = FALSE],
normalized[candidates, , drop = FALSE]
)
cors[!is.finite(cors)] <- -Inf
for (row in seq_along(query)) {
q <- query[[row]]
conflict <- candidates[
cors[row, ] > threshold & candidates != q &
library_names[candidates] != library_names[[q]] &
classes[candidates] != classes[[q]]
]
if (length(conflict)) {
stop(
"Cross-class pruning postcondition failed for retained spectra: ",
ids[[q]], " and ", ids[[conflict[[1L]]]], call. = FALSE
)
}
}
rm(cors)
}
}
removed_rows <- which(decision_round > 0L)
matched_rows <- decision_match[removed_rows]
removals <- data.table::data.table(
phase = "independent", correlation_view = correlation_view,
component_id = NA_character_,
spectrum_id = ids[removed_rows], prior_class = classes[removed_rows],
library_name = library_names[removed_rows],
matched_id = ids[matched_rows], matched_class = classes[matched_rows],
matched_library = library_names[matched_rows],
correlation = decision_correlation[removed_rows],
pool = pools[removed_rows],
evidence_libraries = evidence_libraries[removed_rows],
conflicting_spectra = conflicting_spectra[removed_rows],
active_degree = conflicting_spectra[removed_rows],
matched_active_degree = conflicting_spectra[matched_rows],
matched_evidence_libraries = evidence_libraries[matched_rows],
decision_round = decision_round[removed_rows], threshold = threshold,
class_n_before = NA_integer_, class_n_after = NA_integer_,
schedule_order = NA_integer_, quarantine_status = "excluded",
review_status = "automatic", reason = decision_reason[removed_rows]
)
data.table::setorder(
removals, decision_round, -evidence_libraries, spectrum_id
)
if (isTRUE(progress)) {
message(sprintf(
"prune_lib: cross-class conflict pass complete (removed=%d; elapsed=%.1fs)",
length(removed_rows), proc.time()[["elapsed"]] - started
))
}
list(removed_rows = removed_rows, removals = removals)
}
.lib_prune_cross_class_conflicts <- function(
classes, pools, normalized, ids, library_names, threshold,
support_groups = pools, min_n = 1L, correlation_view = "prune_full",
progress = FALSE, block_size = 32L) {
internal <- .lib_prune_internal_class_conflicts(
classes, pools, normalized, ids, library_names, threshold,
support_groups = support_groups, min_n = min_n,
correlation_view = correlation_view, progress = progress,
block_size = block_size
)
keep <- setdiff(seq_along(ids), internal$removed_rows)
independent <- .lib_prune_between_library_conflicts(
classes[keep], pools[keep], normalized[keep, , drop = FALSE], ids[keep],
library_names[keep], threshold, correlation_view = correlation_view,
progress = progress, block_size = block_size
)
independent_rows <- keep[independent$removed_rows]
conflicts <- internal$conflicts
if (nrow(independent$removals)) {
independent_edges <- independent$removals[, .(
phase, correlation_view, component_id, pool,
spectrum_id, prior_class, library_name, matched_id, matched_class,
matched_library, correlation, same_library = FALSE,
class_pair_libraries = pmax(
evidence_libraries, matched_evidence_libraries, na.rm = TRUE
), review_status, threshold
)]
conflicts <- data.table::rbindlist(
list(conflicts, independent_edges), fill = TRUE
)
}
list(
removed_rows = sort(unique(c(internal$removed_rows, independent_rows))),
removals = data.table::rbindlist(
list(internal$removals, independent$removals), fill = TRUE
),
conflicts = conflicts
)
}
.lib_prune_correlations <- function(x, query, candidates, exclude, ids) {
limits <- sort(exclude)
use <- x$wavenumber < limits[[1L]] | x$wavenumber > limits[[2L]]
query_spec <- x
query_spec$wavenumber <- x$wavenumber[use]
query_spec$spectra <- x$spectra[use, query, drop = FALSE]
query_spec$metadata <- x$metadata[query]
colnames(query_spec$spectra) <- ids[query]
candidate_spec <- x
candidate_spec$wavenumber <- x$wavenumber[use]
candidate_spec$spectra <- x$spectra[use, candidates, drop = FALSE]
candidate_spec$metadata <- x$metadata[candidates]
colnames(candidate_spec$spectra) <- ids[candidates]
# cor_spec() returns library spectra by query spectra. Passing the target
# class as the library gives the row-by-candidate layout needed by max.col()
# without transposing or copying the full correlation matrix.
correlations <- cor_spec(
candidate_spec, library = query_spec, compute = "optimized"
)
correlations[!is.finite(correlations)] <- -Inf
self_column <- match(query, candidates)
has_self <- !is.na(self_column)
correlations[cbind(which(has_self), self_column[has_self])] <- -Inf
correlations
}
.lib_reassign_other_classes <- function(classes, material_types, pools,
normalized, ids, progress = FALSE) {
class_keys <- tolower(classes)
generic_labels <- c("other", "other plastic", "other material")
generic <- which(class_keys %in% generic_labels)
rows <- list()
if (isTRUE(progress) && length(generic)) {
message(sprintf(
"prune_lib: reassigning %d generic-class spectrum/spectra",
length(generic)
))
}
groups <- split(generic, paste(pools[generic], class_keys[generic], sep = "\r"))
for (query in groups) {
generic_class <- class_keys[[query[[1L]]]]
target_pool <- pools[[query[[1L]]]]
eligible <- !is.na(pools) & pools == target_pool &
!class_keys %in% c(generic_labels, "unclassified") &
!is.na(classes) & nzchar(classes)
if (identical(generic_class, "other plastic")) {
eligible <- eligible & material_types == "plastic"
} else if (identical(generic_class, "other material")) {
eligible <- eligible & class_keys %in% c("organic matter", "mineral")
}
candidates <- which(eligible)
if (isTRUE(progress)) {
message(sprintf(
"prune_lib: %s / %s reassignment (%d query; %d eligible)",
target_pool, generic_class, length(query), length(candidates)
))
}
if (length(candidates) == 0L) next
best <- .lib_prune_best_match(query, candidates, normalized, ids)
matched <- which(!is.na(best$index))
if (!length(matched)) next
query_rows <- query[matched]
candidate_rows <- best$index[matched]
prior_class <- classes[query_rows]
prior_type <- material_types[query_rows]
classes[query_rows] <- classes[candidate_rows]
material_types[query_rows] <- material_types[candidate_rows]
rows[[length(rows) + 1L]] <- data.table::data.table(
spectrum_id = ids[query_rows], prior_class,
material_class = classes[query_rows], prior_material_type = prior_type,
material_type = material_types[query_rows],
matched_id = ids[candidate_rows],
correlation = best$correlation[matched], pool = pools[query_rows],
reason = "nearest_eligible_class"
)
}
report <- if (length(rows) > 0L) {
data.table::rbindlist(rows, fill = TRUE)
} else {
data.table::data.table(
spectrum_id = character(), prior_class = character(),
material_class = character(), prior_material_type = character(),
material_type = character(), matched_id = character(),
correlation = numeric(), pool = character(), reason = character()
)
}
if (isTRUE(progress) && length(generic)) {
message(sprintf(
"prune_lib: generic-class reassignment complete (reassigned=%d; retained=%d)",
nrow(report), length(generic) - nrow(report)
))
}
list(classes = classes, material_types = material_types, report = report)
}
.lib_reassign_small_classes <- function(classes, material_types,
spectrum_types, pools, normalized,
ids, min_n, progress = FALSE) {
generic <- c("other", "other plastic", "other material", "unclassified")
support <- data.table::data.table(
row = seq_along(ids), spectrum_type = spectrum_types, pool = pools,
material_class = classes, material_type = material_types
)[
!is.na(spectrum_type) & nzchar(spectrum_type) &
!is.na(material_class) & nzchar(material_class),
.(
observed_n = .N,
material_type = {
values <- unique(material_type[!is.na(material_type) &
nzchar(material_type)])
if (length(values) == 1L) values else NA_character_
}
),
by = .(spectrum_type, pool, material_class)
]
small <- support[observed_n < min_n]
if (nrow(small)) {
data.table::setorder(small, spectrum_type, material_class)
}
established <- support[
observed_n >= min_n & !tolower(material_class) %in% generic
]
report_rows <- list()
class_rows <- list()
dropped_rows <- integer()
for (i in seq_len(nrow(small))) {
item <- small[i]
query <- which(
spectrum_types == item$spectrum_type & pools == item$pool &
!is.na(classes) & classes == item$material_class
)
eligible_classes <- established[
spectrum_type == item$spectrum_type & pool == item$pool,
material_class
]
candidates <- which(
spectrum_types == item$spectrum_type & pools == item$pool &
classes %in% eligible_classes
)
if (!is.na(item$material_type)) {
candidates <- candidates[
material_types[candidates] == item$material_type
]
}
candidates <- candidates[!is.na(candidates)]
match <- .lib_prune_best_class(
query, candidates, classes, normalized, ids
)
if (is.null(match) || tolower(item$material_class) == "unclassified") {
dropped_rows <- c(dropped_rows, query)
class_rows[[length(class_rows) + 1L]] <- data.table::data.table(
spectrum_type = item$spectrum_type, pool = item$pool,
material_class = item$material_class,
observed_n = as.integer(item$observed_n),
minimum_spectra = as.integer(min_n),
shortfall = as.integer(min_n - item$observed_n),
destination_class = NA_character_, mean_correlation = NA_real_,
spectra_removed = as.integer(item$observed_n), action = "dropped",
reason = "class_below_min_n_no_eligible_destination"
)
next
}
prior_class <- classes[query]
prior_type <- material_types[query]
classes[query] <- match$material_class
material_types[query] <- material_types[match$index]
report_rows[[length(report_rows) + 1L]] <- data.table::data.table(
spectrum_id = ids[query], prior_class,
material_class = classes[query], prior_material_type = prior_type,
material_type = material_types[query],
matched_id = ids[match$index], correlation = match$correlation,
pool = pools[query], reason = "class_below_min_n_reassigned"
)
class_rows[[length(class_rows) + 1L]] <- data.table::data.table(
spectrum_type = item$spectrum_type, pool = item$pool,
material_class = item$material_class,
observed_n = as.integer(item$observed_n),
minimum_spectra = as.integer(min_n),
shortfall = as.integer(min_n - item$observed_n),
destination_class = match$material_class,
mean_correlation = match$mean_correlation,
spectra_removed = 0L, action = "reassigned",
reason = "class_below_min_n_reassigned"
)
}
report <- if (length(report_rows)) {
data.table::rbindlist(report_rows, fill = TRUE)
} else {
.lib_prune_reassignment_schema()
}
classes_report <- if (length(class_rows)) {
data.table::rbindlist(class_rows, fill = TRUE)
} else {
.lib_prune_excluded_class_schema()
}
if (isTRUE(progress) && nrow(small)) {
message(sprintf(
paste0("prune_lib: small-class reassignment complete ",
"(classes=%d; reassigned=%d; dropped=%d)"),
nrow(small), sum(classes_report$action == "reassigned"),
sum(classes_report$action == "dropped")
))
}
list(
classes = classes, material_types = material_types, report = report,
classes_report = classes_report,
dropped_rows = sort(unique(as.integer(dropped_rows)))
)
}
.lib_prune_best_class <- function(query, candidates, classes, normalized, ids) {
if (!length(query) || !length(candidates)) return(NULL)
candidates <- candidates[order(ids[candidates], candidates, na.last = TRUE)]
destination <- sort(unique(classes[candidates]))
destination <- destination[!is.na(destination) & nzchar(destination)]
if (!length(destination)) return(NULL)
correlations <- tcrossprod(
normalized[query, , drop = FALSE], normalized
)[, candidates, drop = FALSE]
correlations[!is.finite(correlations)] <- -Inf
scores <- vapply(destination, function(class) {
columns <- which(classes[candidates] == class)
best <- matrixStats::rowMaxs(correlations[, columns, drop = FALSE])
if (any(!is.finite(best))) -Inf else mean(best)
}, numeric(1L))
if (!any(is.finite(scores))) return(NULL)
best_score <- max(scores)
tolerance <- sqrt(.Machine$double.eps) * max(1, abs(best_score))
selected <- destination[which(abs(scores - best_score) <= tolerance)[[1L]]]
selected_candidates <- candidates[classes[candidates] == selected]
selected_columns <- match(selected_candidates, candidates)
selected_correlations <- correlations[, selected_columns, drop = FALSE]
top <- matrixStats::rowMaxs(selected_correlations)
near_top <- abs(selected_correlations - top) <=
sqrt(.Machine$double.eps) * pmax(1, abs(top))
local <- max.col(near_top, ties.method = "first")
list(
material_class = selected,
mean_correlation = unname(best_score),
index = selected_candidates[local],
correlation = selected_correlations[cbind(seq_along(query), local)]
)
}
#' @rdname build_lib
#' @export
reduce_lib <- function(x, group_cols = "material_class", id_col = "sample_name",
k = 50, min_n = k, return = c("object", "ids"),
progress = FALSE, ...) {
return <- match.arg(return)
if (!is.logical(progress) || length(progress) != 1L || is.na(progress)) {
stop("'progress' must be TRUE or FALSE", call. = FALSE)
}
x <- as_OpenSpecy(x)
.lib_require_cols(x$metadata, group_cols, "metadata")
ids <- .lib_ids(x, id_col)
groups <- do.call(paste, c(x$metadata[, group_cols, with = FALSE], sep = "_"))
group_index <- split(seq_along(groups), groups)
reduce_group <- lengths(group_index) > min_n & lengths(group_index) > k
reduce_number <- cumsum(reduce_group)
reduce_total <- sum(reduce_group)
reduction_started <- proc.time()[["elapsed"]]
if (isTRUE(progress)) {
message(sprintf(
"reduce_lib: %d spectra in %d groups; %d group(s) require PAM (k=%d)",
length(ids), length(group_index), reduce_total, k
))
}
keep_ids <- unlist(lapply(seq_along(group_index), function(group_i) {
idx <- group_index[[group_i]]
if (!reduce_group[[group_i]]) return(ids[idx])
reduce_i <- reduce_number[[group_i]]
values <- vapply(group_cols, function(column) {
value <- as.character(x$metadata[[column]][idx[[1L]]])
if (is.na(value) || !nzchar(value)) value <- "<missing>"
paste0(column, "=", value)
}, character(1))
label <- paste(values, collapse = ", ")
group_started <- proc.time()[["elapsed"]]
if (isTRUE(progress)) {
message(sprintf(
"reduce_lib: PAM group %d/%d starting (%s; n=%d; k=%d)",
reduce_i, reduce_total, label, length(idx), k
))
}
reduction_group <- filter_spec(x, idx)
fill_started <- proc.time()[["elapsed"]]
missing_values <- sum(!is.finite(reduction_group$spectra))
if (isTRUE(progress)) {
message(sprintf(
paste0("reduce_lib: spectrum-mean fill starting ",
"(%s; n=%d; missing=%d)"),
label, length(idx), missing_values
))
}
reduction_group$spectra <- make_rel(
.lib_spectrum_mean_replace(reduction_group$spectra), na.rm = TRUE
)
# A constant spectrum has no relative range. Keep it as a flat zero
# candidate so the support rule, rather than complete-case behavior,
# controls eligibility; cor_spec() will assign it no positive similarity.
reduction_group$spectra[!is.finite(reduction_group$spectra)] <- 0
if (isTRUE(progress)) {
message(sprintf(
paste0("reduce_lib: spectrum-mean fill complete ",
"(%s; replaced=%d; %.1fs)"),
label, missing_values,
proc.time()[["elapsed"]] - fill_started
))
}
selected <- .pam_group_ids(
reduction_group, id_col = id_col, k = k,
progress = progress, group_label = label, ...
)
if (isTRUE(progress)) {
message(sprintf(
"reduce_lib: PAM group %d/%d complete (kept=%d/%d; %.1fs)",
reduce_i, reduce_total, length(selected), length(idx),
proc.time()[["elapsed"]] - group_started
))
}
selected
}), use.names = FALSE)
if (isTRUE(progress)) {
message(sprintf(
"reduce_lib: complete (kept=%d/%d; %.1fs)",
length(keep_ids), length(ids),
proc.time()[["elapsed"]] - reduction_started
))
}
if (return == "ids") return(keep_ids)
filter_spec(x, keep_ids)
}
#' @rdname build_lib
#' @export
build_model_lib <- function(x, class_col = "material_class",
type_col = "spectrum_type", min_n = 10,
alpha = 0.1, seed = 123,
grouped = TRUE, weights = TRUE,
make_relative = TRUE,
method = c("logistic_regression", "random_forest"),
...) {
train_spec_model(
x = x, class_col = class_col, type_col = type_col, min_n = min_n,
alpha = alpha, seed = seed, grouped = grouped, weights = weights,
make_relative = make_relative, method = method, ...
)
}
.lib_build_workers <- function() {
workers <- suppressWarnings(as.integer(
getOption("OpenSpecy.build_workers", 1L)
))
if (length(workers) != 1L || is.na(workers) || workers < 1L) return(1L)
workers
}
.lib_cv_glmnet <- function(arguments, workers, folds, seed) {
workers <- min(as.integer(workers), as.integer(folds))
can_parallelize <- workers > 1L &&
requireNamespace("doFuture", quietly = TRUE) &&
requireNamespace("doRNG", quietly = TRUE) &&
requireNamespace("future", quietly = TRUE) &&
requireNamespace("foreach", quietly = TRUE)
if (!can_parallelize) return(do.call(glmnet::cv.glmnet, arguments))
# A maintainer workflow may register one persistent doFuture backend so
# successive model fits do not repeatedly start and stop worker processes.
if (identical(foreach::getDoParName(), "doFuture")) {
doRNG::registerDoRNG(seed)
on.exit(doFuture::registerDoFuture(), add = TRUE)
arguments$parallel <- TRUE
return(do.call(glmnet::cv.glmnet, arguments))
}
previous_plan <- future::plan()
on.exit({
future::plan(previous_plan)
foreach::registerDoSEQ()
}, add = TRUE)
doFuture::registerDoFuture()
future::plan(future::multisession, workers = workers)
doRNG::registerDoRNG(seed)
arguments$parallel <- TRUE
do.call(glmnet::cv.glmnet, arguments)
}
#' @rdname build_lib
#' @export
train_spec_model <- function(x, class_col = "material_class",
type_col = "spectrum_type", min_n = 10,
alpha = 0.1, seed = 123,
grouped = TRUE, weights = TRUE,
make_relative = TRUE,
method = c("logistic_regression", "random_forest"),
...) {
method <- match.arg(method)
x <- as_OpenSpecy(x)
.lib_require_cols(x$metadata, class_col, "metadata")
supported <- .lib_filter_spectral_support(x, min_fraction = 0.1)
x <- supported$object
support <- supported$audit
wavenumbers <- x$wavenumber
spectra <- x$spectra
if (make_relative) spectra <- make_rel(spectra, na.rm = TRUE)
metadata <- data.table::copy(x$metadata)
labels <- as.character(metadata[[class_col]])
if (!is.null(type_col) && type_col %in% names(metadata)) {
types <- as.character(metadata[[type_col]])
labels <- ifelse(is.na(types), labels, paste(types, labels, sep = "_"))
}
keep <- !is.na(labels)
tab <- table(labels[keep])
class_support <- data.table::data.table(
class = names(tab), spectra = as.integer(tab),
minimum_spectra = rep.int(as.integer(min_n), length(tab)),
retained = as.integer(tab) >= min_n
)
keep <- keep & labels %in% names(tab)[tab >= min_n]
spectra <- spectra[, keep, drop = FALSE]
labels <- labels[keep]
metadata <- metadata[keep, ]
if (ncol(spectra) == 0 || length(unique(labels)) < 2) {
stop("At least two classes with 'min_n' spectra are required to train a model",
call. = FALSE)
}
grouping_object <- x
grouping_object$spectra <- spectra
grouping_object$metadata <- metadata
group_ids <- .lib_stable_group_info(grouping_object)$group_id
filled <- .lib_wavenumber_mean_replace(spectra)
spectra <- filled$spectra
train <- t(spectra)
colnames(train) <- as.character(wavenumbers)
outcome <- as.integer(factor(labels))
weight_vec <- NULL
if (weights) weight_vec <- 1 / (table(outcome)[as.character(outcome)] / length(outcome))
dimension_conversion <- unique(data.table::data.table(
factor_num = outcome,
name = labels
))
fill_values <- filled$means
fill <- as_OpenSpecy(
wavenumbers,
spectra = matrix(
fill_values, ncol = 1L,
dimnames = list(NULL, "model_training_mean")
),
metadata = data.table::data.table(sample_name = "model_training_mean")
)
if (identical(method, "random_forest")) {
return(.lib_build_random_forest(
train = train, outcome = outcome, labels = labels, metadata = metadata,
type_col = type_col, weights = weights, seed = seed,
dimension_conversion = dimension_conversion, fill = fill,
support = support, class_support = class_support,
fill_replaced = filled$replaced, ...
))
}
set.seed(seed)
glmnet_args <- list(
x = train,
y = outcome,
alpha = alpha,
family = "multinomial",
intercept = FALSE,
type.multinomial = if (grouped) "grouped" else "ungrouped",
maxit = 1000000L
)
if (!is.null(weight_vec)) glmnet_args$weights <- as.numeric(weight_vec)
user_args <- list(...)
glmnet_args[names(user_args)] <- user_args
group_labels <- unique(data.table::data.table(
group_id = group_ids, outcome = outcome
))
mixed_groups <- group_labels[, data.table::uniqueN(outcome), by = group_id][
V1 > 1L, group_id
]
if (length(mixed_groups)) {
stop("Stable spectral groups span multiple model classes", call. = FALSE)
}
class_groups <- split(group_labels$group_id, group_labels$outcome)
nfolds <- min(5L, min(lengths(class_groups)))
if (nfolds >= 3L) {
group_fold <- character()
fold_value <- integer()
for (groups in class_groups) {
assignments <- sample(rep(seq_len(nfolds), length.out = length(groups)))
group_fold <- c(group_fold, groups)
fold_value <- c(fold_value, assignments)
}
foldid <- fold_value[match(group_ids, group_fold)]
cv_args <- glmnet_args
cv_args$foldid <- foldid
cv_args$nfolds <- nfolds
cv_args$type.measure <- "class"
cv_args$keep <- TRUE
fit <- .lib_cv_glmnet(
cv_args, workers = .lib_build_workers(), folds = nfolds, seed = seed
)
model <- fit$glmnet.fit
lambda_metrics <- .lib_macro_lambda_metrics(
fit$fit.preval, outcome = outcome, lambda = fit$lambda
)
selected_index <- which(lambda_metrics$selected)[[1L]]
lambda <- lambda_metrics$lambda[selected_index]
} else {
model <- do.call(glmnet::glmnet, glmnet_args)
lambda <- min(model$lambda)
selected_index <- length(model$lambda)
lambda_metrics <- data.table::data.table(
lambda = as.numeric(lambda), macro_class_accuracy = NA_real_,
overall_accuracy = NA_real_, selected = TRUE,
selection_scope = "minimum_lambda_insufficient_folds",
selection_rule = "minimum_lambda_without_three_folds"
)
}
convergence_error <- if (is.null(model$jerr)) 0L else as.integer(model$jerr)
selected_lambda_converged <- convergence_error == 0L ||
selected_index < length(model$lambda)
# Calls produced through do.call() can capture the complete training matrix
# and outcome. They are not used by coef() or predict(), and retaining them
# needlessly adds many megabytes to checkpoints and release artifacts.
model$call <- NULL
coefficients <- stats::coef(model, s = lambda)
coef_list <- if (is.list(coefficients)) coefficients else list(coefficients)
rows <- lapply(seq_along(coef_list), function(item) {
data.table::data.table(
dimensions_used = coef_list[[item]]@i,
dimension_units = coef_list[[item]]@x,
variable = item
)
})
coefficient_values <- data.table::rbindlist(rows)
wave <- data.table::data.table(
names = coef_list[[1]]@Dimnames[[1]],
id = seq_along(coef_list[[1]]@Dimnames[[1]]) - 1L
)
coefficients_join <- merge(coefficient_values, dimension_conversion,
by.x = "variable", by.y = "factor_num",
all.x = TRUE)
coefficients_join <- merge(coefficients_join, wave,
by.x = "dimensions_used", by.y = "id",
all.x = FALSE)
coefficients_join$names <- suppressWarnings(as.numeric(ifelse(
coefficients_join$names == "(Intercept)", "0", coefficients_join$names
)))
predictions <- predict(model, newx = train, s = lambda, type = "response")
pred <- .ai_prediction_table(predictions, n = nrow(train))
actual <- data.table::data.table(row_id = seq_along(outcome),
actual_label = outcome,
actual_name = labels)
tests <- merge(pred, actual, by.x = "x", by.y = "row_id", all.x = TRUE)
tests <- merge(tests, dimension_conversion, by.x = "y",
by.y = "factor_num", all.x = TRUE)
names(tests)[names(tests) == "name"] <- "predicted_class"
ids <- if ("sample_name" %in% names(metadata)) {
as.character(metadata$sample_name)
} else {
rownames(train)
}
if (is.null(ids)) ids <- paste0("spectrum_", seq_len(nrow(train)))
technique <- if (!is.null(type_col) && type_col %in% names(metadata)) {
as.character(metadata[[type_col]])
} else {
NA_character_
}
tests[, `:=`(
spectrum_id = ids[x],
technique = technique[x],
expected_class = actual_name,
correct = actual_name == predicted_class,
score = value,
split = "training",
provenance = "model_fit"
)]
tests <- tests[, .(
spectrum_id, technique, expected_class, predicted_class, correct, score,
split, provenance
)]
list(
model = model,
model_type = "logistic_regression",
lambda_selected = lambda,
lambda_path_complete = convergence_error == 0L,
selected_lambda_converged = selected_lambda_converged,
convergence_error = convergence_error,
training_groups = data.table::uniqueN(group_ids),
selection_metric = "macro_class_accuracy",
lambda_metrics = lambda_metrics,
dimension_conversion = dimension_conversion,
tests = tests,
coefficients = coefficients_join,
class_names = unique(labels),
class_num = length(unique(outcome)),
observation_count = length(labels),
fill = fill,
support = support,
class_support = class_support,
fill_method = "wavenumber_mean",
fill_replaced = as.integer(filled$replaced),
variable_num = nrow(coefficients_join),
all_variables = as.numeric(colnames(train)),
variables_in = coefficients_join$names
)
}
.lib_build_random_forest <- function(train, outcome, labels, metadata,
type_col, weights, seed,
dimension_conversion, fill, support,
class_support, fill_replaced, ...) {
if (!requireNamespace("ranger", quietly = TRUE)) {
stop(
"Training a random-forest model requires the suggested 'ranger' package",
call. = FALSE
)
}
p <- ncol(train)
outcome_factor <- factor(outcome, levels = sort(unique(outcome)))
class_counts <- table(outcome_factor)
class_weights <- length(outcome) /
(length(class_counts) * as.numeric(class_counts))
names(class_weights) <- names(class_counts)
class_weight_table <- merge(
data.table::data.table(
factor_num = as.integer(names(class_counts)),
spectra = as.integer(class_counts),
weight = if (isTRUE(weights)) as.numeric(class_weights) else 1,
application = if (isTRUE(weights)) "case_sampling" else "none"
),
dimension_conversion,
by = "factor_num", all.x = TRUE, sort = FALSE
)
ranger_args <- list(
x = train,
y = outcome_factor,
probability = TRUE,
num.trees = 500L,
mtry = max(1L, min(p, max(floor(sqrt(p)), floor(p / 20)))),
min.node.size = 10L,
importance = "permutation",
write.forest = TRUE,
oob.error = TRUE,
# Package and CI runs remain single-threaded by default. Official builders
# can opt into the host's cores without expanding the public API.
num.threads = .lib_build_workers(),
seed = as.integer(seed),
verbose = FALSE
)
if (isTRUE(weights)) {
ranger_args$case.weights <- as.numeric(
class_weights[as.character(outcome_factor)]
)
}
user_args <- list(...)
protected <- intersect(
names(user_args), c("x", "y", "probability", "write.forest", "oob.error")
)
if (length(protected)) {
stop(
"Random-forest arguments managed by train_spec_model() cannot be ",
"overridden: ", paste(protected, collapse = ", "), call. = FALSE
)
}
ranger_args[names(user_args)] <- user_args
set.seed(seed)
model <- do.call(ranger::ranger, ranger_args)
predictions <- model$predictions
pred <- .ai_prediction_table(predictions, n = nrow(train))
actual <- data.table::data.table(
row_id = seq_along(outcome),
actual_label = outcome,
actual_name = labels
)
tests <- merge(pred, actual, by.x = "x", by.y = "row_id", all.x = TRUE)
tests <- merge(
tests, dimension_conversion, by.x = "y", by.y = "factor_num",
all.x = TRUE
)
names(tests)[names(tests) == "name"] <- "predicted_class"
ids <- if ("sample_name" %in% names(metadata)) {
as.character(metadata$sample_name)
} else {
rownames(train)
}
if (is.null(ids)) ids <- paste0("spectrum_", seq_len(nrow(train)))
technique <- if (!is.null(type_col) && type_col %in% names(metadata)) {
as.character(metadata[[type_col]])
} else {
NA_character_
}
tests[, `:=`(
spectrum_id = ids[x],
technique = technique[x],
expected_class = actual_name,
correct = actual_name == predicted_class,
score = value,
split = "oob",
provenance = "model_oob"
)]
tests <- tests[, .(
spectrum_id, technique, expected_class, predicted_class, correct, score,
split, provenance
)]
class_accuracy <- tests[, .(
spectra = .N,
accuracy = mean(correct, na.rm = TRUE),
mean_score = mean(score, na.rm = TRUE)
), by = expected_class]
valid_accuracy <- class_accuracy$accuracy[is.finite(class_accuracy$accuracy)]
oob_metrics <- data.table::data.table(
spectra = nrow(tests),
classes = nrow(class_accuracy),
accuracy = mean(tests$correct, na.rm = TRUE),
macro_class_accuracy = if (length(valid_accuracy)) {
mean(valid_accuracy)
} else {
NA_real_
},
mean_score = mean(tests$score, na.rm = TRUE),
brier_score = as.numeric(model$prediction.error)
)
feature_importance <- data.table::data.table(
wavenumber = suppressWarnings(as.numeric(names(model$variable.importance))),
importance = as.numeric(model$variable.importance)
)
parameters <- data.table::data.table(
num_trees = as.integer(model$num.trees),
mtry = as.integer(model$mtry),
min_node_size = as.integer(ranger_args$min.node.size),
num_threads = as.integer(ranger_args$num.threads),
case_weighted = isTRUE(weights),
class_weighted = FALSE,
balance_method = if (isTRUE(weights)) {
if ("case.weights" %in% names(user_args)) {
"user_case_sampling"
} else {
"inverse_frequency_case_sampling"
}
} else {
"none"
},
importance = as.character(ranger_args$importance)
)
list(
model = model,
model_type = "random_forest",
lambda_selected = NA_real_,
selection_metric = "oob_macro_class_accuracy",
lambda_metrics = data.table::data.table(
lambda = numeric(), macro_class_accuracy = numeric(),
overall_accuracy = numeric(), selected = logical(),
selection_scope = character(), selection_rule = character()
),
dimension_conversion = dimension_conversion,
tests = tests,
coefficients = data.table::data.table(),
feature_importance = feature_importance,
oob_metrics = oob_metrics,
oob_class_accuracy = class_accuracy,
training_parameters = parameters,
class_weights = class_weight_table,
class_names = unique(labels),
class_num = length(unique(outcome)),
observation_count = length(labels),
fill = fill,
support = support,
class_support = class_support,
fill_method = "wavenumber_mean",
fill_replaced = as.integer(fill_replaced),
variable_num = p,
all_variables = as.numeric(colnames(train)),
variables_in = as.numeric(colnames(train))
)
}
.lib_spectrum_mean_replace <- function(spectra) {
spectra[!is.finite(spectra)] <- NA_real_
out <- mean_replace(spectra, na.rm = TRUE)
if (any(!is.finite(out))) {
stop("Spectrum-wise mean replacement left non-finite values", call. = FALSE)
}
out
}
.lib_wavenumber_mean_replace <- function(spectra) {
spectra[!is.finite(spectra)] <- NA_real_
means <- rowMeans(spectra, na.rm = TRUE)
missing_means <- which(!is.finite(means))
if (length(missing_means)) {
stop(
"Cannot fit a model because ", length(missing_means),
" wavenumber(s) have no finite training values",
call. = FALSE
)
}
missing <- is.na(spectra)
if (any(missing)) {
index <- which(missing, arr.ind = TRUE)
spectra[index] <- means[index[, "row"]]
}
list(spectra = spectra, means = means, replaced = sum(missing))
}
.lib_macro_lambda_metrics <- function(predictions, outcome, lambda) {
dimensions <- dim(predictions)
if (length(dimensions) != 3L || dimensions[1L] != length(outcome) ||
dimensions[3L] != length(lambda)) {
stop("Unexpected multinomial cross-validation prediction shape",
call. = FALSE)
}
predicted <- apply(predictions, c(1L, 3L), which.max)
if (is.null(dim(predicted))) {
predicted <- matrix(predicted, nrow = length(outcome))
}
macro <- vapply(seq_along(lambda), function(i) {
class_accuracy <- vapply(split(seq_along(outcome), outcome), function(rows) {
mean(predicted[rows, i] == outcome[rows], na.rm = TRUE)
}, numeric(1))
mean(class_accuracy, na.rm = TRUE)
}, numeric(1))
overall <- vapply(seq_along(lambda), function(i) {
mean(predicted[, i] == outcome, na.rm = TRUE)
}, numeric(1))
best <- which.max(macro)
data.table::data.table(
lambda = as.numeric(lambda), macro_class_accuracy = macro,
overall_accuracy = overall, selected = seq_along(lambda) == best,
selection_scope = "out_of_fold",
selection_rule = "max_macro_accuracy_then_largest_lambda"
)
}
.lib_support_schema <- function() {
data.table::data.table(
spectrum_id = character(), observed = integer(), available = integer(),
observed_fraction = numeric(), minimum_observed = integer(),
retained = logical()
)
}
.lib_filter_spectral_support <- function(x, min_fraction = 0.1,
id_col = "sample_name") {
x <- as_OpenSpecy(x)
if (!is.numeric(min_fraction) || length(min_fraction) != 1L ||
is.na(min_fraction) || min_fraction <= 0 || min_fraction > 1) {
stop("'min_fraction' must be in (0, 1]", call. = FALSE)
}
finite <- is.finite(x$spectra)
observed <- colSums(finite)
available <- nrow(finite)
minimum <- as.integer(ceiling(min_fraction * available))
retained <- observed >= minimum
audit <- data.table::data.table(
spectrum_id = as.character(.lib_ids(x, id_col)),
observed = as.integer(observed), available = as.integer(available),
observed_fraction = as.numeric(observed / available),
minimum_observed = rep.int(minimum, length(observed)),
retained = retained
)
rm(finite)
if (!any(retained)) {
stop(
"No spectra meet the minimum observed support of ",
format(min_fraction * 100, trim = TRUE), "% (", minimum, " of ",
available, " wavenumbers)", call. = FALSE
)
}
out <- if (all(retained)) x else filter_spec(x, retained)
attr(out, "identification_support") <- audit
attr(out, "identification_dropped_ids") <- audit[!retained, spectrum_id]
attr(out, "identification_minimum_observed") <- minimum
attr(out, "identification_finite_coverage") <- mean(observed / available)
list(object = out, audit = audit)
}
#' @rdname build_lib
#' @export
assess_lib <- function(x, class_col = NULL, id_col = "sample_name",
nearest = !is.null(class_col)) {
x <- as_OpenSpecy(x)
valid <- suppressWarnings(check_OpenSpecy(x))
out <- data.table::data.table(
metric = c("valid_OpenSpecy", "spectra", "wavenumbers"),
value = c(as.character(valid), ncol(x$spectra), length(x$wavenumber))
)
if (!is.null(class_col) && class_col %in% names(x$metadata)) {
counts <- data.table::data.table(class = x$metadata[[class_col]])[
, .N, by = "class"]
out <- rbind(out, data.table::data.table(
metric = c("classes", "smallest_class"),
value = c(length(unique(counts$class)), min(counts$N))
), fill = TRUE)
}
if (nearest && ncol(x$spectra) > 1 && class_col %in% names(x$metadata)) {
cors <- cor_spec(x, x)
diag(cors) <- NA
top <- max_cor_named(cors)
ids <- .lib_ids(x, id_col)
matched <- x$metadata[[class_col]][match(names(top), ids)]
accuracy <- mean(matched == x$metadata[[class_col]], na.rm = TRUE)
out <- rbind(out, data.table::data.table(
metric = "nearest_class_accuracy",
value = accuracy
), fill = TRUE)
}
out
}
.default_lib_recipes <- function() {
list(
raw = list(),
derivative = list(
conform_spec = FALSE,
smooth_intens = TRUE,
smooth_intens_args = list(
polynomial = 3,
window = 15,
derivative = 1,
abs = TRUE
),
subtr_baseline = FALSE,
make_rel = TRUE
),
nobaseline = list(
conform_spec = FALSE,
smooth_intens = FALSE,
subtr_baseline = TRUE,
make_rel = TRUE
)
)
}
.lib_reference_sources <- function(source_file, processed_dir,
progress = TRUE) {
processed <- character()
if (!is.null(processed_dir) && dir.exists(processed_dir)) {
processed <- list.files(
processed_dir, pattern = "[.]rds$", recursive = TRUE,
full.names = TRUE, ignore.case = TRUE
)
processed <- processed[grepl(
"[/\\\\]Processed[/\\\\]", processed, ignore.case = TRUE
)]
}
source <- if (!is.null(source_file) && file.exists(source_file)) {
source_file
} else {
character()
}
files <- unique(c(processed, source))
if (length(files) == 0L) {
stop(
"No official reference-library sources were found. Set ",
"OPENSPECY_SOURCE_FILE and/or OPENSPECY_PROCESSED_DIR, or supply 'x'",
call. = FALSE
)
}
if (isTRUE(progress)) {
message(sprintf(
"build_lib: discovered %d processed source(s) plus %d raw source(s)",
length(processed), length(source)
))
}
files
}
.lib_build_reference <- function(x, recipes, range, res, id_col, exclude_ids,
dedupe, metadata_lookups,
material_hierarchy, metadata_name_lookup,
clean_metadata_values, convert_intensity,
restrict_range_args, signal_noise, assess,
prune, progress, workflow_data, output_dir,
previous_library_dir, reuse, remove_other,
seed, holdout,
...) {
if (!is.character(output_dir) || length(output_dir) != 1L ||
is.na(output_dir) || !nzchar(output_dir)) {
stop("'output_dir' must be one nonempty path in end-to-end mode",
call. = FALSE)
}
if (!is.numeric(seed) || length(seed) != 1L || is.na(seed)) {
stop("'seed' must be one finite number", call. = FALSE)
}
if (!is.numeric(holdout) || length(holdout) != 1L || is.na(holdout) ||
holdout <= 0 || holdout >= 1) {
stop("'holdout' must be between zero and one", call. = FALSE)
}
started <- proc.time()[["elapsed"]]
report <- function(stage) {
if (isTRUE(progress)) {
message(sprintf(
"build_lib [%.1fs]: %s",
proc.time()[["elapsed"]] - started, stage
))
}
}
report("resolving official lookup and exclusion tables")
tables <- .lib_reference_tables(workflow_data)
.lib_validate_reference_tables(tables)
classes_exact <- tables$classes_reference[
!is.na(material) & nzchar(material), .(spectrum_identity, material)
]
type_lookup <- data.table::copy(tables$library_types)
if ("spectrum_type" %in% names(type_lookup)) {
type_lookup[grepl(";", spectrum_type, fixed = TRUE),
spectrum_type := NA_character_]
}
if (is.null(metadata_lookups)) {
metadata_lookups <- list(
list(lookup = classes_exact, by = "spectrum_identity"),
list(lookup = type_lookup, by = "organization", fill_only = TRUE)
)
}
if (is.null(material_hierarchy)) {
material_hierarchy <- tables$material_hierarchy
}
if (is.null(exclude_ids)) exclude_ids <- tables$known_bad_ids$sample_name
signature <- .lib_build_signature(
x,
workflow_paths = unlist(tables$paths, use.names = FALSE),
arguments = list(
recipes = recipes, range = range, res = res, id_col = id_col,
exclude_ids = exclude_ids, dedupe = dedupe,
clean_metadata_values = clean_metadata_values,
convert_intensity = convert_intensity,
restrict_range_args = restrict_range_args,
signal_noise = signal_noise, assess = assess, prune = prune,
remove_other = remove_other, seed = seed, holdout = holdout
)
)
# Keep completed libraries, medoids, and production models reusable when
# only assessment, reporting, or export code changes. Bump this version
# whenever those scientific artifacts intentionally change.
artifact_signature <- .lib_component_signature(
x,
workflow_paths = unlist(tables$paths, use.names = FALSE),
arguments = list(
recipes = recipes, range = range, res = res, id_col = id_col,
exclude_ids = exclude_ids, dedupe = dedupe,
metadata_lookups = metadata_lookups,
material_hierarchy = material_hierarchy,
metadata_name_lookup = metadata_name_lookup,
clean_metadata_values = clean_metadata_values,
convert_intensity = convert_intensity,
restrict_range_args = restrict_range_args,
signal_noise = signal_noise, assess = assess, prune = prune,
remove_other = remove_other
),
component_version = "reference-artifacts-v12-correlation-closure-quarantine"
)
# Keep expensive spectral preprocessing reusable when only downstream class,
# pruning, assessment, or export code changes. Bump component_version only
# when a core transformation intentionally changes its scientific output.
core_signature <- .lib_component_signature(
x,
workflow_paths = unlist(
tables$paths[c(
"classes_reference", "library_types", "material_hierarchy",
"known_bad_ids"
)],
use.names = FALSE
),
arguments = list(
recipes = recipes, range = range, res = res, id_col = id_col,
exclude_ids = exclude_ids, dedupe = dedupe,
metadata_lookups = metadata_lookups,
material_hierarchy = material_hierarchy,
metadata_name_lookup = metadata_name_lookup,
clean_metadata_values = clean_metadata_values,
convert_intensity = convert_intensity,
restrict_range_args = restrict_range_args,
signal_noise = signal_noise, assess = assess
),
component_version = "core-libraries-v2-library-name"
)
checkpoints <- .lib_checkpoint_manager(
output_dir, signature = artifact_signature, reuse = reuse, report = report
)
libraries <- checkpoints$get("libraries")
local_assessments <- checkpoints$get("library_assessments")
quarantine <- checkpoints$get("quarantined_spectra")
if (is.null(libraries)) {
core_path <- file.path(output_dir, "checkpoints", "core_libraries.rds")
core <- checkpoints$get("core_libraries", key = core_signature)
if (is.null(core)) {
report("building raw, derivative, and nobaseline libraries")
core <- .lib_build_core(
x = x, recipes = recipes, range = range, res = res, id_col = id_col,
exclude_ids = exclude_ids, dedupe = dedupe,
metadata_lookups = metadata_lookups,
material_hierarchy = material_hierarchy,
metadata_name_lookup = metadata_name_lookup,
clean_metadata_values = clean_metadata_values,
convert_intensity = convert_intensity,
restrict_range_args = restrict_range_args,
signal_noise = signal_noise, assess = assess, prune = NULL,
progress = progress, ...
)
checkpoints$put("core_libraries", core, key = core_signature)
for (name in names(core)) {
checkpoints$put(
paste0("core_library_", name), core[[name]], key = core_signature
)
}
}
rm(core)
gc(verbose = FALSE)
completed <- .lib_complete_reference_build(
readRDS(core_path), tables = tables, prune = prune,
remove_other = remove_other,
progress = progress,
report = report
)
libraries <- completed$libraries
local_assessments <- completed$assessments
quarantine <- completed$quarantine
quarantine$manifest$build_signature <- artifact_signature
quarantine$manifest$source_hashes <- artifact_signature
.lib_write_quarantine_review(quarantine, output_dir)
checkpoints$put("libraries", libraries)
checkpoints$put("library_assessments", local_assessments)
checkpoints$put("quarantined_spectra", quarantine)
for (name in names(libraries)) {
checkpoints$put(paste0("library_", name), libraries[[name]])
}
} else if (is.null(local_assessments)) {
report("reconstructing completed-library assessments from attributes")
local_assessments <- .lib_recover_library_assessments(libraries, tables)
checkpoints$put("library_assessments", local_assessments)
}
if (is.null(quarantine)) {
quarantine <- .lib_quarantine_bundle(build_signature = artifact_signature)
.lib_write_quarantine_review(quarantine, output_dir)
checkpoints$put("quarantined_spectra", quarantine)
}
medoids <- checkpoints$get("medoids")
if (is.null(medoids)) {
medoids <- .lib_build_medoids(
libraries, report = report, checkpoints = checkpoints,
progress = progress
)
checkpoints$put("medoids", medoids)
}
correlation_closure <- .lib_validate_medoid_parent_closure(
libraries, medoids, prune_spec = prune, report = report
)
local_assessments$correlation_closure <- correlation_closure
report("finalizing library metadata by missing-value count")
finalized <- .lib_finalize_reference_metadata(libraries, medoids)
libraries <- finalized$libraries
medoids <- finalized$medoids
local_assessments$metadata_finalization <- finalized$assessment
models <- checkpoints$get("models_logistic_regression_parallel_rng_v2")
model_warnings <- .lib_warning_schema()
if (is.null(models)) {
model_result <- .lib_build_models(
libraries, medoids, report = report, checkpoints = checkpoints
)
models <- model_result$models
model_warnings <- model_result$warnings
checkpoints$put("models_logistic_regression_parallel_rng_v2", models)
}
build <- list(
libraries = libraries,
medoids = medoids,
models = models,
assessments = .lib_local_build_assessments(
libraries, medoids, models, local_assessments, model_warnings
)
)
prior_signature <- .lib_previous_signature(previous_library_dir)
assessment_key <- digest::digest(
list(
artifact_signature, prior_signature, seed = seed, holdout = holdout,
assessment_version = "typed-legacy-bundles-v18-accuracy-comparison"
),
algo = "sha256"
)
fallback_assessment_key <- digest::digest(
list(
artifact_signature, prior_signature, seed = seed, holdout = holdout,
assessment_version = "selected-lambda-logistic-v14"
),
algo = "sha256"
)
cached_assessments <- checkpoints$get(
"assessment_components_parallel_rng_v1", key = assessment_key
)
if (!is.null(cached_assessments)) {
build$assessments <- cached_assessments
validated_models <- checkpoints$get(
"validated_models_parallel_rng_v1", key = assessment_key
)
if (!is.null(validated_models)) build$models <- validated_models
} else if (!is.null(previous_library_dir)) {
report("assessing complete candidate and legacy artifacts")
comparison <- .lib_compare_reference_build(
build, previous_library_dir = previous_library_dir,
seed = seed, holdout = holdout, progress = progress,
checkpoints = checkpoints, checkpoint_key = assessment_key,
checkpoint_fallback_key = fallback_assessment_key
)
if (!is.null(comparison$models)) {
build$models <- comparison$models
checkpoints$put(
"validated_models_parallel_rng_v1", build$models, key = assessment_key
)
comparison$models <- NULL
}
build$assessments[names(comparison)] <- comparison
}
build$assessments$output_manifest <- checkpoints$manifest()
assessment_components <- build$assessments
checkpoints$put(
"assessment_components_parallel_rng_v1", assessment_components,
key = assessment_key
)
build$assessments <- .lib_assessment_review(assessment_components)
.lib_validate_reference_build(build)
checkpoints$put("assessments", build$assessments, key = assessment_key)
checkpoints$put("reference_library_build", build, key = assessment_key)
release_signature <- .lib_release_signature(assessment_key)
report("promoting validated artifacts to a versioned release directory")
promotion <- .lib_promote_reference_build(
build, quarantine = quarantine, output_dir = output_dir,
signature = release_signature, reuse = reuse, progress = report
)
build <- .lib_finalize_reference_release(
build, assessment_components, promotion, release_signature, report
)
report("complete")
build
}
.lib_rebuild_artifacts <- function(x, output_dir, previous_library_dir,
reuse, seed, holdout, progress) {
if (missing(x)) {
stop("'x' must specify a completed build or checkpoint", call. = FALSE)
}
if (!is.character(output_dir) || length(output_dir) != 1L ||
is.na(output_dir) || !nzchar(output_dir)) {
stop("'output_dir' must be one nonempty path", call. = FALSE)
}
if (!is.logical(reuse) || length(reuse) != 1L || is.na(reuse)) {
stop("'reuse' must be TRUE or FALSE", call. = FALSE)
}
if (!is.logical(progress) || length(progress) != 1L || is.na(progress)) {
stop("'progress' must be TRUE or FALSE", call. = FALSE)
}
if (!is.numeric(seed) || length(seed) != 1L || !is.finite(seed)) {
stop("'seed' must be one finite number", call. = FALSE)
}
if (!is.numeric(holdout) || length(holdout) != 1L || is.na(holdout) ||
holdout <= 0 || holdout >= 1) {
stop("'holdout' must be between zero and one", call. = FALSE)
}
started <- proc.time()[["elapsed"]]
report <- function(stage) {
if (isTRUE(progress)) message(sprintf(
"rebuild_lib_artifacts [%.1fs]: %s",
proc.time()[["elapsed"]] - started, stage
))
}
report("resolving completed libraries and upstream assessments")
input <- .lib_resolve_rebuild_input(x)
libraries <- .lib_drop_typed_range_flats(input$libraries, report = report)
completed <- input$assessments
quarantine <- input$quarantine
signature <- digest::digest(list(
input = input$signature,
downstream_version = "full-library-random-forest-model-v5-range-flat-filter"
), algo = "sha256")
checkpoints <- .lib_checkpoint_manager(
output_dir, signature = signature, reuse = reuse, report = report
)
medoids <- checkpoints$get("medoids")
if (is.null(medoids) && !is.null(input$medoids)) {
report("reusing compatible medoids supplied by the completed build")
medoids <- input$medoids
checkpoints$put("medoids", medoids)
}
if (is.null(medoids)) {
medoids <- .lib_build_medoids(
libraries, report = report, checkpoints = checkpoints,
progress = progress
)
checkpoints$put("medoids", medoids)
}
report("finalizing library metadata by missing-value count")
finalized <- .lib_finalize_reference_metadata(libraries, medoids)
libraries <- finalized$libraries
medoids <- finalized$medoids
if (is.null(completed)) completed <- list()
completed$metadata_finalization <- finalized$assessment
models <- checkpoints$get("models_logistic_regression_parallel_rng_v2")
model_warnings <- .lib_warning_schema()
if (is.null(models)) {
supplied_models <- input$models
if (!is.null(supplied_models$logistic_regression)) {
for (recipe in names(supplied_models$logistic_regression)) {
for (type in names(supplied_models$logistic_regression[[recipe]])) {
stage <- paste(
"model", "logistic_regression", recipe, type, sep = "_"
)
stage <- paste0(stage, "_parallel_rng_v1")
if (is.null(checkpoints$get(stage))) {
model <- supplied_models$logistic_regression[[recipe]][[type]]
if (is.null(model$model_type)) model$model_type <- "logistic_regression"
checkpoints$put(stage, model)
}
}
}
report("reusing compatible logistic models supplied by the completed build")
}
result <- .lib_build_models(
libraries, medoids, report = report, checkpoints = checkpoints
)
models <- result$models
model_warnings <- result$warnings
checkpoints$put("models_logistic_regression_parallel_rng_v2", models)
}
build <- list(
libraries = libraries, medoids = medoids, models = models,
assessments = .lib_local_build_assessments(
libraries, medoids, models, completed, model_warnings
)
)
prior_signature <- .lib_previous_signature(previous_library_dir)
assessment_key <- digest::digest(list(
signature, prior_signature, seed = seed, holdout = holdout,
assessment_version = "typed-legacy-bundles-v18-accuracy-comparison"
), algo = "sha256")
fallback_assessment_key <- digest::digest(list(
signature, prior_signature, seed = seed, holdout = holdout,
assessment_version = "selected-lambda-logistic-v14"
), algo = "sha256")
cached <- checkpoints$get(
"assessment_components_parallel_rng_v1", key = assessment_key
)
if (!is.null(cached)) {
build$assessments <- cached
validated <- checkpoints$get(
"validated_models_parallel_rng_v1", key = assessment_key
)
if (!is.null(validated)) build$models <- validated
} else if (!is.null(previous_library_dir)) {
report("assessing candidate and legacy artifacts on source-local cohorts")
comparison <- .lib_compare_reference_build(
build, previous_library_dir = previous_library_dir,
seed = seed, holdout = holdout, progress = progress,
checkpoints = checkpoints, checkpoint_key = assessment_key,
checkpoint_fallback_key = fallback_assessment_key
)
if (!is.null(comparison$models)) {
build$models <- comparison$models
checkpoints$put(
"validated_models_parallel_rng_v1", build$models, key = assessment_key
)
comparison$models <- NULL
}
build$assessments[names(comparison)] <- comparison
}
build$assessments$output_manifest <- checkpoints$manifest()
assessment_components <- build$assessments
checkpoints$put(
"assessment_components_parallel_rng_v1", assessment_components,
key = assessment_key
)
build$assessments <- .lib_assessment_review(assessment_components)
.lib_validate_reference_build(build)
checkpoints$put("assessments", build$assessments, key = assessment_key)
checkpoints$put("reference_library_build", build, key = assessment_key)
release_signature <- .lib_release_signature(assessment_key)
report("promoting downstream artifacts to a versioned release directory")
promotion <- .lib_promote_reference_build(
build, quarantine = quarantine, output_dir = output_dir,
signature = release_signature, reuse = reuse, progress = report
)
build <- .lib_finalize_reference_release(
build, assessment_components, promotion, release_signature, report
)
report("complete")
build
}
.lib_resolve_rebuild_input <- function(x) {
source_path <- NULL
source_signature <- NULL
sibling_assessments <- NULL
if (is.character(x)) {
if (length(x) != 1L || is.na(x) || !nzchar(x)) {
stop("'x' must be one completed-build or checkpoint path", call. = FALSE)
}
if (dir.exists(x)) {
candidates <- c(
file.path(x, "checkpoints", "libraries.rds"),
file.path(x, "libraries.rds"),
file.path(x, "reference_library_build.rds"),
file.path(x, "checkpoints", "reference_library_build.rds")
)
releases <- list.files(
file.path(x, "releases"), pattern = "reference_library_build[.]rds$",
recursive = TRUE, full.names = TRUE
)
candidates <- c(candidates, releases)
candidates <- candidates[file.exists(candidates)]
if (!length(candidates)) {
stop("No completed libraries checkpoint was found under 'x'",
call. = FALSE)
}
source_path <- candidates[[1L]]
} else if (file.exists(x)) {
source_path <- x
} else {
stop("'x' does not exist: ", x, call. = FALSE)
}
manifest_path <- sub("[.]rds$", ".manifest.rds", source_path)
manifest <- if (file.exists(manifest_path)) {
tryCatch(readRDS(manifest_path), error = function(error) NULL)
} else NULL
source_signature <- if (is.list(manifest) &&
!is.null(manifest$signature)) {
manifest$signature
} else {
digest::digest(.lib_file_signatures(source_path), algo = "sha256")
}
if (identical(basename(source_path), "libraries.rds")) {
sibling <- file.path(dirname(source_path), "library_assessments.rds")
if (file.exists(sibling)) sibling_assessments <- readRDS(sibling)
}
x <- readRDS(source_path)
if (.lib_is_release_index(x)) {
source_signature <- x$build_signature
x <- .lib_load_release_index(x, dirname(source_path))
}
}
if (!is.list(x)) {
stop("'x' must resolve to a completed build or nested libraries list",
call. = FALSE)
}
medoids <- NULL
models <- NULL
quarantine <- NULL
if (!is.null(x$libraries)) {
libraries <- x$libraries
assessments <- .lib_upstream_assessments(x$assessments)
medoids <- x$medoids
models <- x$models
quarantine <- x$quarantine
if (!is.null(models) &&
!any(c("logistic_regression", "random_forest") %in% names(models))) {
models <- list(logistic_regression = models)
}
if (is.null(source_signature)) {
source_signature <- attr(x, "build_signature", exact = TRUE)
}
} else {
libraries <- x
assessments <- .lib_upstream_assessments(sibling_assessments)
}
required <- c("derivative", "nobaseline")
if (!all(required %in% names(libraries))) {
stop("Completed libraries must contain derivative and nobaseline recipes",
call. = FALSE)
}
valid_recipe <- vapply(libraries[required], function(recipe) {
is.list(recipe) && length(recipe) > 0L &&
all(vapply(recipe, is_OpenSpecy, logical(1)))
}, logical(1))
if (!all(valid_recipe)) {
stop("Derivative and nobaseline libraries must be spectrum-type lists of OpenSpecy objects",
call. = FALSE)
}
if (is.null(source_signature) || !length(source_signature) ||
is.na(source_signature[[1L]])) {
source_signature <- digest::digest(libraries, algo = "sha256")
}
list(
libraries = libraries, medoids = medoids, models = models,
assessments = assessments, quarantine = quarantine,
signature = as.character(source_signature[[1L]]), path = source_path
)
}
.lib_upstream_assessments <- function(assessments) {
if (is.null(assessments) || !is.list(assessments)) return(NULL)
nested <- attr(assessments, "upstream_assessments", exact = TRUE)
if (!is.null(nested)) return(nested)
keep <- c(
"lookup_coverage", "identity_cleanup", "class_prediction",
"class_coverage", "type_coverage", "material_form_enrichment",
"material_form_matches", "material_form_clashes",
"common_use_enrichment", "common_use_coverage",
"exclusions_deduplication",
"other_review", "other_filter", "filters", "metadata_drop",
"metadata_finalization", "pruning", "pruning_excluded_classes",
"pruning_reassignments", "pruning_removals", "pruning_conflicts",
"correlation_closure",
"quality_control", "automated_test_flags", "library_retention",
"dropped_spectrum_identities",
"model_assessment_correlations"
)
assessments[intersect(keep, names(assessments))]
}
.lib_find_workflow_data <- function(workflow_data = NULL,
script_dirs = NULL) {
if (!is.null(workflow_data)) return(workflow_data)
if (is.null(script_dirs)) {
script_paths <- unlist(lapply(sys.frames(), function(frame) {
path <- frame$ofile
if (is.null(path)) character() else as.character(path)
}), use.names = FALSE)
script_dirs <- dirname(normalizePath(
script_paths[file.exists(script_paths)], mustWork = FALSE
))
}
candidates <- unique(c(
file.path(rev(script_dirs), "data"),
file.path(getwd(), "data"),
file.path(getwd(), "workflows", "data")
))
required <- c(
"classes_reference.csv", "classes_regex.csv", "library_types.csv",
"material_hierarchy.csv", "known_bad_ids.csv",
"metadata_drop_columns.csv", "material_form_regex.csv",
"common_use_reference.csv"
)
complete <- vapply(candidates, function(path) {
dir.exists(path) && all(file.exists(file.path(path, required)))
}, logical(1))
if (!any(complete)) {
stop(
"Could not locate the reference helper tables. Put them in data/ ",
"beside the calling script or supply 'workflow_data'. Searched: ",
paste(candidates, collapse = ", "),
call. = FALSE
)
}
normalizePath(candidates[which(complete)[1L]], mustWork = TRUE)
}
.lib_reference_tables <- function(workflow_data) {
if (!is.character(workflow_data) || length(workflow_data) != 1L ||
is.na(workflow_data) || !nzchar(workflow_data)) {
stop("'workflow_data' must be one nonempty directory", call. = FALSE)
}
files <- c(
classes_reference = "classes_reference.csv",
classes_regex = "classes_regex.csv",
library_types = "library_types.csv",
material_hierarchy = "material_hierarchy.csv",
known_bad_ids = "known_bad_ids.csv",
metadata_drop = "metadata_drop_columns.csv",
material_form_regex = "material_form_regex.csv",
common_use_reference = "common_use_reference.csv"
)
paths <- stats::setNames(file.path(workflow_data, unname(files)), names(files))
missing <- !file.exists(paths)
if (any(missing)) {
stop("Missing workflow data file(s): ",
paste(paths[missing], collapse = ", "), call. = FALSE)
}
out <- lapply(paths, data.table::fread)
out$paths <- as.list(paths)
out
}
.lib_validate_reference_regex <- function(regex_reference) {
.lib_require_cols(regex_reference, c("pattern", "material"),
"classes_regex")
literal_only <- vapply(
regex_reference$pattern, .lib_regex_is_exact_literal, logical(1)
)
if (any(literal_only)) {
stop(
"Literal-only anchored class patterns belong in classes_reference.csv: ",
paste(regex_reference$pattern[literal_only], collapse = ", "),
call. = FALSE
)
}
invisible(TRUE)
}
.lib_validate_reference_tables <- function(tables) {
.lib_require_cols(
tables$classes_reference, c("spectrum_identity", "material"),
"classes_reference"
)
identity <- trimws(as.character(tables$classes_reference$spectrum_identity))
if (any(is.na(identity) | !nzchar(identity)) || anyDuplicated(identity)) {
stop("classes_reference keys must be nonblank and unique", call. = FALSE)
}
spreadsheet_date <- grepl(
"^([0-9]{1,2}-[A-Za-z]{3}|[A-Za-z]{3}-[0-9]{1,2})$", identity
)
if (any(spreadsheet_date)) {
stop(
"classes_reference contains spreadsheet-style date coercions: ",
paste(identity[spreadsheet_date], collapse = ", "), call. = FALSE
)
}
.lib_validate_reference_regex(tables$classes_regex)
.lib_require_cols(tables$known_bad_ids, "sample_name", "known_bad_ids")
known_bad <- trimws(as.character(tables$known_bad_ids$sample_name))
if (any(is.na(known_bad) | !nzchar(known_bad)) || anyDuplicated(known_bad) ||
any(!grepl("^[[:xdigit:]]{32}$", known_bad))) {
stop("known_bad_ids must contain unique exact 32-character hexadecimal IDs",
call. = FALSE)
}
.lib_require_cols(
tables$material_hierarchy,
c("material", "material_class", "material_type"), "material_hierarchy"
)
material <- trimws(as.character(tables$material_hierarchy$material))
if (any(is.na(material) | !nzchar(material)) || anyDuplicated(material)) {
stop("material_hierarchy material keys must be nonblank and unique",
call. = FALSE)
}
material_class <- trimws(as.character(
tables$material_hierarchy$material_class
))
material_type <- trimws(tolower(as.character(
tables$material_hierarchy$material_type
)))
paint_classes <- c("paint", "acrylic paint", "alkyd paint", "urethane paint")
if (any(tolower(material_class) %in% paint_classes)) {
stop("material_hierarchy material_class must describe chemistry, not paint form",
call. = FALSE)
}
invalid_plastic_class <- material_type == "plastic" &
!is.na(material_class) & nzchar(material_class) &
material_class != "other plastic" & !startsWith(tolower(material_class),
"poly")
if (any(invalid_plastic_class)) {
stop(
"Standard plastic material classes must start with 'poly': ",
paste(sort(unique(material_class[invalid_plastic_class])),
collapse = ", "), call. = FALSE
)
}
.lib_validate_material_form_regex(tables$material_form_regex)
.lib_validate_common_use_reference(
tables$common_use_reference, tables$material_hierarchy
)
invisible(TRUE)
}
.lib_validate_material_form_regex <- function(regex_reference) {
.lib_require_cols(
regex_reference, c("pattern", "material_form"), "material_form_regex"
)
pattern <- trimws(as.character(regex_reference$pattern))
material_form <- trimws(as.character(regex_reference$material_form))
if (any(is.na(pattern) | !nzchar(pattern)) || anyDuplicated(pattern)) {
stop("material_form_regex patterns must be nonblank and unique",
call. = FALSE)
}
allowed <- c(
"paint", "rubber", "hard plastic", "fiber", "pellet",
"film plastic", "fragment", "foam", "sphere/bead"
)
invalid_form <- is.na(material_form) | !material_form %in% allowed
if (any(invalid_form)) {
stop(
"material_form_regex has unsupported material_form values: ",
paste(unique(material_form[invalid_form]), collapse = ", "),
call. = FALSE
)
}
invalid_pattern <- vapply(pattern, function(value) {
inherits(try(grepl(value, "", perl = TRUE), silent = TRUE), "try-error")
}, logical(1L))
if (any(invalid_pattern)) {
stop("material_form_regex contains invalid regular expressions: ",
paste(pattern[invalid_pattern], collapse = ", "), call. = FALSE)
}
empty_match <- vapply(pattern, grepl, logical(1L), x = "", perl = TRUE)
if (any(empty_match)) {
stop("material_form_regex patterns may not match empty text: ",
paste(pattern[empty_match], collapse = ", "), call. = FALSE)
}
invisible(TRUE)
}
.lib_validate_common_use_reference <- function(reference, hierarchy) {
required <- c(
"material_class", "common_use", "consumer_mass_share",
"industrial_mass_share", "unclassified_mass_share", "basis_year",
"geography", "source_url", "retrieved_date", "evidence_notes"
)
.lib_require_cols(reference, required, "common_use_reference")
material_class <- trimws(as.character(reference$material_class))
if (any(is.na(material_class) | !nzchar(material_class)) ||
anyDuplicated(material_class)) {
stop("common_use_reference material_class keys must be nonblank and unique",
call. = FALSE)
}
hierarchy_classes <- sort(unique(trimws(
as.character(hierarchy$material_class)
)))
if (!setequal(material_class, hierarchy_classes)) {
stop(
"common_use_reference must contain every hierarchy class exactly once; ",
"missing: ", paste(setdiff(hierarchy_classes, material_class),
collapse = ", "),
"; stale: ", paste(setdiff(material_class, hierarchy_classes),
collapse = ", "),
call. = FALSE
)
}
common_use <- trimws(as.character(reference$common_use))
common_use[is.na(common_use) | !nzchar(common_use)] <- NA_character_
invalid_use <- !is.na(common_use) &
!common_use %in% c("consumer", "industrial", "mixed")
if (any(invalid_use)) {
stop("common_use_reference has unsupported common_use values: ",
paste(unique(common_use[invalid_use]), collapse = ", "),
call. = FALSE)
}
shares <- data.frame(
consumer = suppressWarnings(as.numeric(reference$consumer_mass_share)),
industrial = suppressWarnings(as.numeric(reference$industrial_mass_share)),
unclassified = suppressWarnings(as.numeric(
reference$unclassified_mass_share
))
)
invalid_share <- vapply(shares, function(value) {
any(!is.na(value) & (!is.finite(value) | value < 0 | value > 1))
}, logical(1L))
if (any(invalid_share)) {
stop("common_use_reference shares must be proportions in [0, 1]",
call. = FALSE)
}
share_sum <- rowSums(shares, na.rm = TRUE)
if (any(share_sum > 1 + 1e-8)) {
stop("common_use_reference shares may not sum above one", call. = FALSE)
}
labelled <- !is.na(common_use)
share_present <- rowSums(!is.na(shares)) > 0L
complete_share <- stats::complete.cases(shares)
if (any(share_present & !complete_share)) {
stop(
"common_use_reference shares must be either all missing or all present",
call. = FALSE
)
}
if (any(!labelled & share_present)) {
stop("common_use_reference shares require a common_use label",
call. = FALSE)
}
valid_label <- rep(TRUE, nrow(reference))
consumer <- common_use == "consumer" & labelled & complete_share
industrial <- common_use == "industrial" & labelled & complete_share
mixed <- common_use == "mixed" & labelled & complete_share
valid_label[consumer] <- shares$consumer[consumer] > 0.5
valid_label[industrial] <- shares$industrial[industrial] > 0.5
valid_label[mixed] <-
shares$consumer[mixed] >= 0.25 & shares$industrial[mixed] >= 0.25 &
shares$consumer[mixed] <= 0.5 & shares$industrial[mixed] <= 0.5 &
shares$consumer[mixed] + shares$industrial[mixed] >= 0.75
source_provenance <- c("source_url", "retrieved_date", "evidence_notes")
source_ok <- lapply(source_provenance, function(column) {
value <- trimws(as.character(reference[[column]]))
!is.na(value) & nzchar(value)
})
source_complete <- Reduce(`&`, source_ok)
quantitative_provenance <- lapply(c("basis_year", "geography"),
function(column) {
value <- trimws(as.character(reference[[column]]))
!is.na(value) & nzchar(value)
})
quantitative_complete <- Reduce(`&`, quantitative_provenance)
invalid <- labelled & (
!valid_label | !source_complete |
(complete_share & !quantitative_complete)
)
if (any(invalid)) {
stop(
"common_use_reference labels require source provenance and, when ",
"quantitative shares are supplied, valid threshold shares and complete ",
"quantitative provenance: ",
paste(material_class[invalid], collapse = ", "),
call. = FALSE
)
}
invisible(TRUE)
}
.lib_normalize_enrichment_text <- function(value) {
value <- suppressWarnings(as.character(value))
value[is.na(value)] <- ""
ascii <- iconv(value, from = "", to = "ASCII//TRANSLIT")
ascii[is.na(ascii)] <- value[is.na(ascii)]
trimws(gsub("[[:space:]]+", " ", tolower(ascii), perl = TRUE))
}
.lib_regex_row_hits <- function(text, patterns) {
hits <- vector("list", length(text))
for (pattern_index in seq_along(patterns)) {
matched <- which(grepl(patterns[[pattern_index]], text, perl = TRUE))
for (row_index in matched) {
hits[[row_index]] <- c(hits[[row_index]], pattern_index)
}
}
hits
}
.lib_enrich_material_form <- function(metadata, regex_reference) {
out <- data.table::copy(data.table::as.data.table(metadata))
row_count <- nrow(out)
atomic <- vapply(out, function(value) {
is.atomic(value) && length(value) == row_count
}, logical(1L))
searchable <- out[, names(out)[atomic], with = FALSE]
search_values <- lapply(searchable, function(value) {
value <- suppressWarnings(as.character(value))
value[is.na(value)] <- ""
value
})
if (length(search_values)) {
corpus <- .lib_normalize_enrichment_text(do.call(
paste, c(search_values, list(sep = "\u001f"))
))
} else {
corpus <- rep("", row_count)
}
direct <- if ("material_form" %in% names(out)) {
.lib_normalize_enrichment_text(out[["material_form"]])
} else {
rep("", row_count)
}
patterns <- as.character(regex_reference$pattern)
forms <- as.character(regex_reference$material_form)
direct_hits <- .lib_regex_row_hits(direct, patterns)
direct_forms <- lapply(direct_hits, function(index) unique(forms[index]))
use_corpus <- lengths(direct_forms) == 0L
selected_hits <- direct_hits
evidence_source <- rep("material_form", row_count)
if (any(use_corpus)) {
corpus_hits <- .lib_regex_row_hits(corpus[use_corpus], patterns)
selected_hits[use_corpus] <- corpus_hits
evidence_source[use_corpus] <- "all_metadata"
}
matched_forms <- lapply(selected_hits, function(index) unique(forms[index]))
form_count <- lengths(matched_forms)
result <- rep(NA_character_, row_count)
unique_match <- form_count == 1L
result[unique_match] <- vapply(
matched_forms[unique_match], `[[`, character(1L), 1L
)
conflict <- form_count > 1L
out[, material_form := result]
matched_rows <- which(form_count > 0L)
normalized_matches <- lapply(search_values, function(value) {
.lib_normalize_enrichment_text(value[matched_rows])
})
evidence <- vapply(seq_along(matched_rows), function(match_position) {
row_index <- matched_rows[[match_position]]
if (identical(evidence_source[[row_index]], "material_form")) {
return(paste0("material_form=", direct[[row_index]]))
}
values <- character()
for (pattern_index in selected_hits[[row_index]]) {
for (column_name in names(normalized_matches)) {
value <- normalized_matches[[column_name]][[match_position]]
if (nzchar(value) && grepl(patterns[[pattern_index]], value,
perl = TRUE)) {
values <- c(values, paste0(
column_name, "=", substr(value, 1L, 160L)
))
break
}
}
}
paste(unique(values), collapse = " | ")
}, character(1L))
matches <- data.table::data.table(
row_id = as.integer(matched_rows),
sample_name = if ("sample_name" %in% names(out)) {
as.character(out[["sample_name"]][matched_rows])
} else {
rep(NA_character_, length(matched_rows))
},
material_form = result[matched_rows],
evidence_source = evidence_source[matched_rows],
matched_forms = vapply(
matched_forms[matched_rows], paste, character(1L), collapse = " | "
),
matched_patterns = vapply(selected_hits[matched_rows], function(index) {
paste(patterns[index], collapse = " | ")
}, character(1L)),
evidence = evidence,
conflict = conflict[matched_rows]
)
category <- sort(unique(stats::na.omit(result)))
category_metric <- if (length(category)) {
paste0("material_form:", category)
} else {
character()
}
summary <- data.table::data.table(
metric = c(
"total", "populated", "unmatched", "conflicting",
category_metric
),
value = as.integer(c(
row_count, sum(!is.na(result)), sum(form_count == 0L), sum(conflict),
vapply(
category, function(value) sum(result == value, na.rm = TRUE),
integer(1L)
)
))
)
list(
data = out, summary = summary, matches = matches,
clashes = matches[matches[["conflict"]]]
)
}
.lib_enrich_common_use <- function(metadata, reference) {
out <- data.table::copy(data.table::as.data.table(metadata))
common_use <- trimws(as.character(reference$common_use))
common_use[is.na(common_use) | !nzchar(common_use)] <- NA_character_
material_class <- if ("material_class" %in% names(out)) {
as.character(out[["material_class"]])
} else {
rep(NA_character_, nrow(out))
}
reference_index <- match(material_class, as.character(reference$material_class))
result <- common_use[reference_index]
out[, common_use := result]
category <- sort(unique(stats::na.omit(result)))
category_metric <- if (length(category)) {
paste0("common_use:", category)
} else {
character()
}
summary <- data.table::data.table(
metric = c(
"total", "populated", "unmatched", category_metric
),
value = as.integer(c(
nrow(out), sum(!is.na(result)), sum(is.na(result)),
vapply(
category, function(value) sum(result == value, na.rm = TRUE),
integer(1L)
)
))
)
classes <- sort(unique(stats::na.omit(material_class)))
class_index <- match(classes, as.character(reference$material_class))
coverage <- data.table::data.table(
material_class = classes,
spectra = vapply(
classes, function(value) {
sum(!is.na(material_class) & material_class == value)
}, integer(1L)
),
common_use = common_use[class_index],
consumer_mass_share = as.numeric(reference$consumer_mass_share[class_index]),
industrial_mass_share = as.numeric(reference$industrial_mass_share[class_index]),
unclassified_mass_share = as.numeric(
reference$unclassified_mass_share[class_index]
),
basis_year = as.character(reference$basis_year[class_index]),
geography = as.character(reference$geography[class_index]),
source_url = as.character(reference$source_url[class_index]),
retrieved_date = as.character(reference$retrieved_date[class_index]),
evidence_notes = as.character(reference$evidence_notes[class_index])
)
list(data = out, summary = summary, coverage = coverage)
}
.lib_regex_is_exact_literal <- function(pattern) {
if (is.na(pattern) || !startsWith(pattern, "^") ||
!endsWith(pattern, "$")) return(FALSE)
body <- substr(pattern, 2L, nchar(pattern) - 1L)
chars <- strsplit(body, "", fixed = TRUE)[[1L]]
if (length(chars) == 0L) return(TRUE)
metacharacters <- c(".", "(", ")", "+", "*", "?", "{", "}",
"|", "[", "]", "^", "$")
variable_escapes <- c(
"A", "B", "b", "D", "d", "G", "K", "N", "P", "p", "Q",
"R", "S", "s", "W", "w", "X", "x", "Z", "z", "0", "1",
"2", "3", "4", "5", "6", "7", "8", "9"
)
escaped <- FALSE
for (char in chars) {
if (escaped) {
if (char %in% variable_escapes) return(FALSE)
escaped <- FALSE
} else if (identical(char, "\\")) {
escaped <- TRUE
} else if (char %in% metacharacters) {
return(FALSE)
}
}
!escaped
}
.lib_build_signature <- function(x, workflow_paths, arguments) {
sources <- if (is.character(x)) {
.lib_file_signatures(x)
} else {
data.table::data.table(
path = "<in-memory>", size = as.numeric(utils::object.size(x)),
modified = NA_character_, checksum = digest::digest(x, algo = "sha256")
)
}
workflow <- .lib_file_signatures(workflow_paths, checksum_limit = Inf)
description <- if (file.exists("DESCRIPTION")) {
read.dcf("DESCRIPTION", fields = "Version")[[1L]]
} else {
NA_character_
}
code_checksum <- if (file.exists(file.path("R", "build_lib.R"))) {
.lib_sha256_file(file.path("R", "build_lib.R"))
} else {
NA_character_
}
digest::digest(
list(sources = sources, workflow = workflow, arguments = arguments,
version = description, code = code_checksum,
git = .lib_git_state(), runtime = .lib_runtime_provenance()),
algo = "sha256"
)
}
.lib_release_signature <- function(assessment_signature) {
package_source <- .lib_file_signatures(
c("DESCRIPTION", file.path("R", "build_lib.R")), checksum_limit = Inf
)
digest::digest(
list(
assessment = assessment_signature, package_source = package_source,
git = .lib_git_state(), runtime = .lib_runtime_provenance()
),
algo = "sha256"
)
}
.lib_component_signature <- function(x, workflow_paths, arguments,
component_version) {
sources <- if (is.character(x)) {
.lib_file_signatures(x)
} else {
data.table::data.table(
path = "<in-memory>", size = as.numeric(utils::object.size(x)),
modified = NA_character_, checksum = digest::digest(x, algo = "sha256")
)
}
workflow <- .lib_file_signatures(workflow_paths, checksum_limit = Inf)
digest::digest(
list(
sources = sources, workflow = workflow, arguments = arguments,
component_version = component_version,
runtime = .lib_runtime_provenance()
),
algo = "sha256"
)
}
.lib_file_signature_cache <- new.env(parent = emptyenv())
.lib_file_signatures <- function(paths, checksum_limit = 50 * 1024^2) {
paths <- unique(as.character(paths))
info <- file.info(paths)
normalized <- normalizePath(paths, mustWork = FALSE)
checksum <- rep(NA_character_, length(paths))
use <- !is.na(info$size) & !is.na(info$isdir) & !info$isdir
for (i in which(use)) {
cache_key <- digest::digest(
list(path = normalized[[i]], size = as.numeric(info$size[[i]]),
modified = as.character(info$mtime[[i]])),
algo = "sha256"
)
if (exists(cache_key, envir = .lib_file_signature_cache,
inherits = FALSE)) {
checksum[[i]] <- get(
cache_key, envir = .lib_file_signature_cache, inherits = FALSE
)
} else {
checksum[[i]] <- .lib_sha256_file(paths[[i]])
assign(cache_key, checksum[[i]], envir = .lib_file_signature_cache)
}
}
data.table::data.table(
path = normalized,
size = as.numeric(info$size), checksum_algorithm = "sha256",
modified = as.character(info$mtime), checksum = checksum
)
}
.lib_sha256_file <- function(path) {
digest::digest(file = path, algo = "sha256", serialize = FALSE)
}
.lib_git_state <- function() {
run <- function(args) {
value <- tryCatch(
suppressWarnings(system2("git", args, stdout = TRUE, stderr = FALSE)),
error = function(error) character()
)
paste(value, collapse = "\n")
}
list(
commit = run(c("rev-parse", "HEAD")),
tracked_status_sha256 = digest::digest(
run(c("status", "--porcelain", "--untracked-files=no")), algo = "sha256"
)
)
}
.lib_runtime_provenance <- function() {
packages <- c("OpenSpecy", "data.table", "digest", "glmnet", "ranger")
versions <- vapply(packages, function(package) {
if (identical(package, "OpenSpecy") && file.exists("DESCRIPTION")) {
return(as.character(read.dcf("DESCRIPTION", fields = "Version")[[1L]]))
}
if (requireNamespace(package, quietly = TRUE)) {
as.character(utils::packageVersion(package))
} else {
NA_character_
}
}, character(1))
list(R = R.version.string, platform = R.version$platform, packages = versions)
}
.lib_checkpoint_manager <- function(output_dir, signature, reuse, report) {
checkpoint_dir <- file.path(output_dir, "checkpoints")
dir.create(checkpoint_dir, recursive = TRUE, showWarnings = FALSE)
events <- list()
event_i <- 0L
record <- function(component, status, path, key, size = NA_real_,
checksum = NA_character_) {
event_i <<- event_i + 1L
events[[event_i]] <<- data.table::data.table(
component = component, status = status,
path = normalizePath(path, mustWork = FALSE), signature = key,
size = as.numeric(size), checksum_algorithm = "sha256",
checksum = as.character(checksum)
)
}
paths <- function(stage) {
safe <- gsub("[^A-Za-z0-9_.-]+", "_", stage)
list(
object = file.path(checkpoint_dir, paste0(safe, ".rds")),
manifest = file.path(checkpoint_dir, paste0(safe, ".manifest.rds"))
)
}
get <- function(stage, key = signature) {
target <- paths(stage)
if (!isTRUE(reuse) || !file.exists(target$object) ||
!file.exists(target$manifest)) return(NULL)
manifest <- tryCatch(readRDS(target$manifest), error = function(e) NULL)
info <- file.info(target$object)
checksum <- if (!is.na(info$size)) .lib_sha256_file(target$object) else NA_character_
if (!is.list(manifest) || !identical(manifest$signature, key) ||
!isTRUE(manifest$complete) || !identical(manifest$checksum, checksum) ||
!identical(as.numeric(manifest$size), as.numeric(info$size))) {
record(stage, "invalidated", target$object, key)
return(NULL)
}
object <- tryCatch(readRDS(target$object), error = function(e) NULL)
if (is.null(object)) {
record(stage, "unreadable", target$object, key)
return(NULL)
}
report(paste0("reusing checkpoint: ", stage))
record(stage, "reused", target$object, key, info$size, checksum)
object
}
put <- function(stage, object, key = signature) {
target <- paths(stage)
.lib_atomic_saveRDS(object, target$object)
info <- file.info(target$object)
checksum <- .lib_sha256_file(target$object)
.lib_atomic_saveRDS(
list(signature = key, complete = TRUE, size = as.numeric(info$size),
checksum_algorithm = "sha256", checksum = checksum),
target$manifest
)
record(stage, "built", target$object, key, info$size, checksum)
invisible(object)
}
manifest <- function() {
if (length(events) == 0L) {
return(data.table::data.table(
component = character(), status = character(), path = character(),
signature = character(), size = numeric(),
checksum_algorithm = character(), checksum = character()
))
}
data.table::rbindlist(events, fill = TRUE)
}
list(get = get, put = put, manifest = manifest)
}
.lib_atomic_saveRDS <- function(object, path) {
dir.create(dirname(path), recursive = TRUE, showWarnings = FALSE)
temporary <- tempfile(pattern = paste0(basename(path), "."),
tmpdir = dirname(path))
on.exit(if (file.exists(temporary)) unlink(temporary), add = TRUE)
saveRDS(object, temporary)
if (file.exists(path)) unlink(path)
if (!file.rename(temporary, path)) {
stop("Could not promote completed component to ", path, call. = FALSE)
}
invisible(path)
}
.lib_atomic_fwrite <- function(object, path) {
dir.create(dirname(path), recursive = TRUE, showWarnings = FALSE)
temporary <- tempfile(pattern = paste0(basename(path), "."),
tmpdir = dirname(path), fileext = ".csv")
on.exit(if (file.exists(temporary)) unlink(temporary), add = TRUE)
data.table::fwrite(object, temporary)
if (file.exists(path)) unlink(path)
if (!file.rename(temporary, path)) {
stop("Could not promote review table to ", path, call. = FALSE)
}
invisible(path)
}
.lib_write_quarantine_review <- function(quarantine, output_dir) {
review_dir <- file.path(output_dir, "review")
.lib_atomic_saveRDS(
quarantine, file.path(review_dir, "quarantined_spectra.rds")
)
metadata <- data.table::rbindlist(lapply(names(quarantine$spectra), function(name) {
out <- data.table::copy(quarantine$spectra[[name]]$metadata)
out[, quarantine_object := name]
out
}), fill = TRUE)
if (!nrow(metadata)) {
metadata <- data.table::data.table(
quarantine_object = character(), quarantine_key = character(),
sample_name = character(), material_class = character(),
quarantine_status = character(), quarantine_phase = character(),
quarantine_reason = character(), review_status = character()
)
}
.lib_atomic_fwrite(
metadata, file.path(review_dir, "quarantined_spectra_metadata.csv")
)
.lib_atomic_fwrite(
quarantine$conflicts,
file.path(review_dir, "quarantined_spectra_conflicts.csv")
)
invisible(review_dir)
}
.lib_promote_rds <- function(object, path) {
dir.create(dirname(path), recursive = TRUE, showWarnings = FALSE)
temporary <- tempfile(pattern = paste0(basename(path), "."),
tmpdir = dirname(path))
on.exit(if (file.exists(temporary)) unlink(temporary), add = TRUE)
saveRDS(object, temporary)
checksum <- .lib_sha256_file(temporary)
size <- as.numeric(file.info(temporary)$size)
status <- "promoted"
if (file.exists(path)) {
existing_checksum <- .lib_sha256_file(path)
if (!identical(existing_checksum, checksum)) {
# Some model backends serialize semantically identical internal state
# differently after a save/load cycle (observed for ranger on R-devel).
# Preserve the already-promoted immutable bytes only when both payloads
# deserialize to identical R objects; genuine content drift still fails.
existing <- tryCatch(readRDS(path), error = function(error) NULL)
candidate <- tryCatch(readRDS(temporary), error = function(error) NULL)
if (is.null(existing) || is.null(candidate) ||
!identical(existing, candidate)) {
stop("Immutable release artifact differs from existing payload: ", path,
call. = FALSE)
}
checksum <- existing_checksum
size <- as.numeric(file.info(path)$size)
}
status <- "verified_existing"
unlink(temporary)
} else if (!file.rename(temporary, path)) {
stop("Could not promote completed component to ", path, call. = FALSE)
}
data.table::data.table(
component = tools::file_path_sans_ext(basename(path)), status = status,
path = normalizePath(path, mustWork = FALSE), size = size,
checksum_algorithm = "sha256", checksum = checksum
)
}
.lib_promote_build_index <- function(signature, artifact_manifest, path) {
index <- .lib_release_index(signature, artifact_manifest)
.lib_promote_rds(index, path)
}
.lib_finalize_reference_release <- function(build, assessment_components,
promotion, signature, report) {
release_dir <- promotion$directory
assessment_components$output_manifest <- data.table::copy(promotion$manifest)
assessment_components$output_manifest[, status := "available"]
build$assessments <- .lib_assessment_review(assessment_components)
.lib_validate_reference_build(build)
attr(build, "output_dir") <- normalizePath(release_dir, mustWork = FALSE)
attr(build, "build_signature") <- signature
report("serializing standalone reference-library assessments")
assessments_manifest <- .lib_promote_rds(
build$assessments, file.path(release_dir, "assessments.rds")
)
artifact_manifest <- data.table::rbindlist(
list(promotion$manifest, assessments_manifest), fill = TRUE
)
report("serializing the lightweight reference-library release index")
index_manifest <- .lib_promote_build_index(
signature, artifact_manifest,
file.path(release_dir, "reference_library_build.rds")
)
release_manifest <- data.table::rbindlist(
list(artifact_manifest, index_manifest), fill = TRUE
)
release_manifest[, status := "available"]
attr(release_manifest, "build_signature") <- signature
.lib_promote_rds(
release_manifest, file.path(release_dir, "release_manifest.rds")
)
build
}
.lib_complete_reference_build <- function(libraries, tables, prune,
remove_other, progress, report) {
retention_stages <- data.table::rbindlist(lapply(
libraries,
function(object) attr(object, "library_retention_stages", exact = TRUE)
), fill = TRUE)
identity_index <- data.table::rbindlist(lapply(names(libraries), function(name) {
metadata <- libraries[[name]]$metadata
data.table::data.table(
artifact = name,
sample_name = as.character(.lib_ids(libraries[[name]], "sample_name")),
spectrum_identity = if ("spectrum_identity" %in% names(metadata)) {
as.character(metadata$spectrum_identity)
} else {
NA_character_
}
)
}), fill = TRUE)
core_dropped <- data.table::rbindlist(lapply(libraries, function(object) {
attr(object, "dropped_spectrum_identities", exact = TRUE)
}), fill = TRUE)
prediction_rows <- list()
coverage_rows <- list()
for (name in names(libraries)) {
report(paste0("completing class metadata (", name, ")"))
prediction <- predict_class_reference(
libraries[[name]]$metadata, tables$classes_regex, return = "report"
)
if (nrow(prediction$clashes) > 0L) {
stop("Class regex clashes require exact material entries: ",
paste(head(prediction$clashes$spectrum_identity, 20L),
collapse = ", "), call. = FALSE)
}
libraries[[name]]$metadata <- prediction$data
libraries[[name]] <- join_material_hierarchy(
libraries[[name]], tables$material_hierarchy
)
attr(libraries[[name]], "class_prediction_report") <- prediction[
c("summary", "predictions", "clashes", "overlaps")
]
libraries[[name]] <- .lib_complete_reference_classes(
libraries[[name]], classes = tables$classes_reference,
hierarchy = tables$material_hierarchy,
enforce_other_limit = !isTRUE(remove_other)
)
prediction_rows[[name]] <- data.table::data.table(
artifact = name, metric = names(prediction$summary),
value = as.numeric(unlist(prediction$summary, use.names = FALSE))
)
coverage <- attr(libraries[[name]], "class_coverage_report")
coverage_rows[[name]] <- data.table::data.table(
artifact = name, stage = coverage$stage,
populated_class = coverage$populated_class,
reviewed_source_key = coverage$reviewed_source_key,
reviewed_class_key = coverage$reviewed_class_key,
unclassified = coverage$unclassified,
unresolved_other = coverage$unresolved_other,
other = coverage$other,
other_fraction = coverage$other_fraction
)
}
other_policy <- .lib_apply_other_policy(
libraries, remove_other = remove_other, report = report
)
libraries <- other_policy$libraries
other_policy$libraries <- NULL
retention_stages <- data.table::rbindlist(list(
retention_stages,
.lib_library_stage_counts(libraries, stage = "post_other")
), fill = TRUE)
type_coverage <- data.table::rbindlist(lapply(names(libraries), function(name) {
metadata <- libraries[[name]]$metadata
data.table::data.table(
artifact = name, field = c("library_type", "spectrum_type"),
populated = c(
sum(!is.na(metadata$library_type) & nzchar(metadata$library_type)),
sum(!is.na(metadata$spectrum_type) & nzchar(metadata$spectrum_type))
), total = nrow(metadata)
)
}))
if (any(type_coverage$populated != type_coverage$total)) {
stop("Blank library_type or spectrum_type values remain after source lookup",
call. = FALSE)
}
quality_store <- new.env(parent = emptyenv())
quality_store$libraries <- libraries
rm(libraries)
gc(verbose = FALSE)
quality <- .lib_quality_control_processed(
quality_store, report = report,
artifact_ratio = 2, tail_n = 5L, max_crop = 0.2,
snr_threshold = 2
)
libraries <- quality$libraries
quality$libraries <- NULL
rm(quality_store)
gc(verbose = FALSE)
retention_stages <- data.table::rbindlist(list(
retention_stages,
.lib_library_stage_counts(libraries, stage = "post_quality")
), fill = TRUE)
prune_spec <- prune
if (is.null(prune_spec)) {
prune_spec <- list(
derivative = list(cross_class = TRUE),
nobaseline = list(cross_class = TRUE)
)
}
prune_rows <- list()
prune_excluded_rows <- list()
prune_reassignment_rows <- list()
prune_removal_rows <- list()
prune_conflict_rows <- list()
quarantine_parts <- list()
prune_targets <- intersect(names(prune_spec), names(libraries))
library_order <- names(libraries)
prune_paths <- character()
if (length(prune_targets) > 0L) {
report("staging libraries for bounded pruning")
prune_paths <- stats::setNames(vapply(library_order, function(name) {
tempfile(paste0("openspecy-prune-", name, "-"), fileext = ".rds")
}, character(1L)), library_order)
on.exit(unlink(prune_paths[file.exists(prune_paths)]), add = TRUE)
for (name in library_order) {
saveRDS(libraries[[name]], prune_paths[[name]], compress = FALSE)
libraries[[name]] <- NULL
gc(verbose = FALSE, full = TRUE)
}
}
for (name in prune_targets) {
report(paste0("pruning ", name))
args <- prune_spec[[name]]
if (is.null(args)) args <- list()
if (is.null(args$progress)) args$progress <- progress
args$return <- "report"
library <- readRDS(prune_paths[[name]])
pruned <- do.call(prune_lib, c(list(library), args))
if (nrow(pruned$cross_class_conflicts)) {
prune_conflict_rows[[paste0(name, "_prune")]] <- data.table::copy(
pruned$cross_class_conflicts
)[, artifact := name][]
}
if (nrow(pruned$cross_class_removals)) {
min_n <- if (is.null(args$min_n)) 10L else as.integer(args$min_n)
quarantine_parts <- c(
quarantine_parts,
.lib_typed_quarantine_parts(
library, pruned$cross_class_removals, name, min_n
)
)
}
rm(library)
prune_rows[[name]] <- data.table::copy(pruned$summary)[
, artifact := name][]
if (nrow(pruned$excluded_classes)) {
prune_excluded_rows[[name]] <- data.table::copy(
pruned$excluded_classes
)[, artifact := name][]
data.table::setcolorder(
prune_excluded_rows[[name]],
c("artifact", setdiff(names(prune_excluded_rows[[name]]), "artifact"))
)
}
if (nrow(pruned$reassignments)) {
prune_reassignment_rows[[name]] <- data.table::copy(
pruned$reassignments
)[, artifact := name][]
}
if (nrow(pruned$removals)) {
prune_removal_rows[[name]] <- data.table::copy(
pruned$removals
)[, artifact := name][]
data.table::setcolorder(
prune_removal_rows[[name]],
c("artifact", setdiff(names(prune_removal_rows[[name]]), "artifact"))
)
}
saveRDS(pruned$object, prune_paths[[name]], compress = FALSE)
pruned$object <- NULL
rm(pruned)
gc(verbose = FALSE, full = TRUE)
}
if (length(prune_targets) > 0L) {
report("restoring pruned libraries")
libraries <- stats::setNames(lapply(prune_paths, readRDS), library_order)
unlink(prune_paths[file.exists(prune_paths)])
prune_paths <- character()
gc(verbose = FALSE, full = TRUE)
}
retention_stages <- data.table::rbindlist(list(
retention_stages,
.lib_library_stage_counts(libraries, stage = "post_prune")
), fill = TRUE)
if (!isTRUE(remove_other) && nrow(other_policy$review)) {
other_policy$review[
action == "retained_for_semisupervised_reassignment",
action := "retained_unresolved"
]
if (length(prune_reassignment_rows)) {
reassigned_ids <- data.table::rbindlist(
prune_reassignment_rows, fill = TRUE
)[, .(artifact, spectrum_id)]
other_policy$review[
reassigned_ids,
on = .(artifact, spectrum_id),
action := "reassigned"
]
}
}
material_form_rows <- list()
material_form_match_rows <- list()
material_form_clash_rows <- list()
common_use_rows <- list()
common_use_coverage_rows <- list()
for (name in names(libraries)) {
report(paste0("enriching material form and common use (", name, ")"))
form <- .lib_enrich_material_form(
libraries[[name]]$metadata, tables$material_form_regex
)
libraries[[name]]$metadata <- form$data
attr(libraries[[name]], "material_form_enrichment_report") <- form[
c("summary", "matches", "clashes")
]
material_form_rows[[name]] <- data.table::copy(form$summary)
material_form_rows[[name]][, artifact := name]
if (nrow(form$matches)) {
material_form_match_rows[[name]] <- data.table::copy(form$matches)
material_form_match_rows[[name]][, artifact := name]
}
if (nrow(form$clashes)) {
material_form_clash_rows[[name]] <- data.table::copy(form$clashes)
material_form_clash_rows[[name]][, artifact := name]
}
use <- .lib_enrich_common_use(
libraries[[name]]$metadata, tables$common_use_reference
)
libraries[[name]]$metadata <- use$data
attr(libraries[[name]], "common_use_enrichment_report") <- use[
c("summary", "coverage")
]
common_use_rows[[name]] <- data.table::copy(use$summary)
common_use_rows[[name]][, artifact := name]
common_use_coverage_rows[[name]] <- data.table::copy(use$coverage)
common_use_coverage_rows[[name]][, artifact := name]
}
superseded_drop <- c(
"x", "y", "xunits", "interpretation", "form_factor", "shape",
"x_unit", "spectrumid", "locationdescription", "datatype"
)
optional_drop <- grepl("^assessment_", tables$metadata_drop$metadata_column)
drop_status <- data.table::data.table(
metadata_column = tables$metadata_drop$metadata_column,
status = ifelse(
tables$metadata_drop$metadata_column %in% names(libraries$raw$metadata),
"present",
ifelse(
tables$metadata_drop$metadata_column %in% superseded_drop,
"superseded_absent",
ifelse(optional_drop, "optional_absent", "stale_absent")
)
)
)
report(paste0(
"metadata drop QA: ",
paste(drop_status[, paste0(status, "=", .N), by = status]$V1,
collapse = "; ")
))
automated_test_flags <- .lib_collect_automated_test_flags(libraries)
before_filter <- ncol(libraries$raw$spectra)
material_type <- as.character(libraries$raw$metadata$material_type)
if (any(is.na(material_type) | !nzchar(trimws(material_type)))) {
stop("Blank material_type values remain after reviewed class cleanup",
call. = FALSE)
}
libraries <- lapply(libraries, function(object) {
drop_cols <- intersect(
tables$metadata_drop$metadata_column, names(object$metadata)
)
if (length(drop_cols) > 0L) object$metadata[, (drop_cols) := NULL]
object
})
for (name in intersect(c("derivative", "nobaseline"), names(libraries))) {
libraries[[name]]$spectra <- round(libraries[[name]]$spectra, 3)
finite_range <- apply(libraries[[name]]$spectra, 2L, function(values) {
values <- values[is.finite(values)]
if (length(values)) diff(range(values)) else 0
})
flat <- !is.finite(finite_range) | finite_range <= 0
if (any(flat)) {
flat_rows <- data.table::data.table(
artifact = name,
spectrum_id = as.character(.lib_ids(libraries[[name]], "sample_name"))[flat],
spectrum_type = as.character(libraries[[name]]$metadata$spectrum_type)[flat],
check = "flat_spectrum", action = "drop",
before_value = finite_range[flat], after_value = NA_real_,
threshold = 0, removed = TRUE, reason = "post_transform_flat_spectrum"
)
quality$assessment <- data.table::rbindlist(
list(quality$assessment, flat_rows), fill = TRUE
)
libraries[[name]] <- filter_spec(libraries[[name]], !flat)
}
}
retention_stages <- data.table::rbindlist(list(
retention_stages,
.lib_library_stage_counts(libraries, stage = "post_transform")
), fill = TRUE)
report(sprintf("reviewed exclusions and flat-spectrum checks complete (removed=%d; retained=%d)",
before_filter - ncol(libraries$raw$spectra),
ncol(libraries$raw$spectra)))
retained_index <- data.table::rbindlist(lapply(names(libraries), function(name) {
data.table::data.table(
artifact = name,
sample_name = as.character(.lib_ids(libraries[[name]], "sample_name"))
)
}))
post_dropped <- identity_index[
!retained_index, on = .(artifact, sample_name),
.(spectrum_identity)
]
dropped_spectrum_identities <- data.table::rbindlist(
list(core_dropped, post_dropped), fill = TRUE
)[
!is.na(spectrum_identity) & nzchar(trimws(spectrum_identity)),
.(spectrum_identity = sort(unique(spectrum_identity)))
]
partition_store <- new.env(parent = emptyenv())
partition_store$libraries <- libraries
rm(libraries)
gc(verbose = FALSE, full = TRUE)
libraries <- .lib_partition_reference_libraries(partition_store, report)
rm(partition_store)
gc(verbose = FALSE, full = TRUE)
closure <- .lib_close_typed_reference_libraries(
libraries, prune_spec = prune_spec, report = report, progress = progress
)
libraries <- closure$libraries
closure$libraries <- NULL
if (nrow(closure$removals)) {
prune_removal_rows$final_closure <- closure$removals
}
if (nrow(closure$conflicts)) {
prune_conflict_rows$final_closure <- closure$conflicts
}
quarantine_parts <- c(quarantine_parts, closure$quarantine_parts)
if (nrow(closure$support_drops)) {
quality$assessment <- data.table::rbindlist(
list(quality$assessment, closure$support_drops), fill = TRUE
)
}
quarantine <- .lib_quarantine_bundle(
quarantine_parts,
conflicts = if (length(prune_conflict_rows)) {
data.table::rbindlist(prune_conflict_rows, fill = TRUE)
} else .lib_prune_conflict_edge_schema(),
thresholds = unlist(lapply(prune_targets, function(name) {
args <- prune_spec[[name]]
if (is.null(args$cross_class_threshold)) 0.9 else
args$cross_class_threshold
}), use.names = FALSE)
)
range_flat_rows <- data.table::rbindlist(lapply(names(libraries), function(recipe) {
data.table::rbindlist(lapply(libraries[[recipe]], function(object) {
attr(object, "range_flat_drops", exact = TRUE)
}), fill = TRUE)
}), fill = TRUE)
if (nrow(range_flat_rows)) {
quality$assessment <- data.table::rbindlist(
list(quality$assessment, range_flat_rows), fill = TRUE
)
dropped_spectrum_identities <- data.table::rbindlist(
list(
dropped_spectrum_identities,
range_flat_rows[
!is.na(spectrum_identity) & nzchar(trimws(spectrum_identity)),
.(spectrum_identity)
]
),
fill = TRUE
)[, .(spectrum_identity = sort(unique(spectrum_identity)))]
}
retention_stages <- data.table::rbindlist(list(
retention_stages,
.lib_library_stage_counts(libraries, stage = "final")
), fill = TRUE)
library_retention <- .lib_library_retention_report(retention_stages)
list(
libraries = libraries,
assessments = list(
class_prediction = data.table::rbindlist(prediction_rows, fill = TRUE),
class_coverage = data.table::rbindlist(coverage_rows, fill = TRUE),
type_coverage = type_coverage,
material_form_enrichment = data.table::rbindlist(
material_form_rows, fill = TRUE
),
material_form_matches = data.table::rbindlist(
material_form_match_rows, fill = TRUE
),
material_form_clashes = data.table::rbindlist(
material_form_clash_rows, fill = TRUE
),
common_use_enrichment = data.table::rbindlist(
common_use_rows, fill = TRUE
),
common_use_coverage = data.table::rbindlist(
common_use_coverage_rows, fill = TRUE
),
other_review = other_policy$review,
other_filter = other_policy$summary,
pruning = data.table::rbindlist(prune_rows, fill = TRUE),
pruning_excluded_classes = if (length(prune_excluded_rows)) {
data.table::rbindlist(prune_excluded_rows, fill = TRUE)
} else {
.lib_prune_excluded_assessment_schema()
},
pruning_reassignments = if (length(prune_reassignment_rows)) {
data.table::rbindlist(prune_reassignment_rows, fill = TRUE)
} else {
.lib_prune_reassignment_schema()
},
pruning_removals = if (length(prune_removal_rows)) {
data.table::rbindlist(prune_removal_rows, fill = TRUE)
} else {
.lib_prune_removal_assessment_schema()
},
pruning_conflicts = if (length(prune_conflict_rows)) {
data.table::rbindlist(prune_conflict_rows, fill = TRUE)
} else {
out <- .lib_prune_conflict_edge_schema()
out[, artifact := character()]
out
},
automated_test_flags = automated_test_flags,
quality_control = quality$assessment,
library_retention = library_retention,
dropped_spectrum_identities = dropped_spectrum_identities,
filters = data.table::data.table(
stage = "special_filter", before = before_filter,
after = ncol(libraries$raw$spectra),
removed = before_filter - ncol(libraries$raw$spectra)
),
metadata_drop = drop_status
),
quarantine = quarantine
)
}
.lib_quality_schema <- function() {
data.table::data.table(
artifact = character(), spectrum_id = character(),
spectrum_type = character(), check = character(), action = character(),
before_value = numeric(), after_value = numeric(), threshold = numeric(),
removed = logical(), reason = character()
)
}
.lib_warning_schema <- function() {
data.table::data.table(
algorithm = character(), artifact = character(), model = character(),
warning = character()
)
}
.lib_prune_reassignment_schema <- function() {
data.table::data.table(
artifact = character(), spectrum_id = character(),
prior_class = character(), material_class = character(),
prior_material_type = character(), material_type = character(),
matched_id = character(), correlation = numeric(), pool = character(),
reason = character()
)
}
.lib_quality_control_processed <- function(libraries, report,
artifact_ratio = 2,
tail_n = 5L,
max_crop = 0.2,
snr_threshold = 2) {
assessment <- list()
detached <- is.environment(libraries)
store <- if (detached) libraries else NULL
recipe_names <- if (detached) names(store$libraries) else names(libraries)
for (recipe in intersect(c("derivative", "nobaseline"), recipe_names)) {
x <- if (detached) store$libraries[[recipe]] else libraries[[recipe]]
if (detached) {
store$libraries[[recipe]] <- NULL
gc(verbose = FALSE)
}
started <- proc.time()[["elapsed"]]
ids <- .lib_ids(x, "sample_name")
type <- tolower(as.character(x$metadata$spectrum_type))
report(sprintf(
"quality gate %s: starting (spectra=%d; wavenumbers=%d)",
recipe, ncol(x$spectra), nrow(x$spectra)
))
metrics <- .artifact_ratio_metrics(
x, tail_n = tail_n, co2_region = c(2200, 2420),
silent_region = c(2420, 2550)
)
co2 <- type == "ftir" & is.finite(metrics$co2_ratio) &
metrics$co2_ratio > artifact_ratio
co2[is.na(co2)] <- FALSE
if (any(co2)) {
corrected <- flatten_range(
filter_spec(x, co2), min = 2200, max = 2420, make_rel = FALSE
)
x$spectra[, co2] <- corrected$spectra
}
co2_after <- .artifact_ratio_metrics(
x, tail_n = tail_n, co2_region = c(2200, 2420),
silent_region = c(2420, 2550)
)$co2_ratio
failed_co2 <- co2 & (!is.finite(co2_after) |
co2_after > artifact_ratio)
failed_co2[is.na(failed_co2)] <- TRUE
assessment[[paste0(recipe, "_co2")]] <- data.table::data.table(
artifact = recipe, spectrum_id = ids[co2], spectrum_type = type[co2],
check = "co2_region", action = ifelse(failed_co2[co2], "drop", "flatten"),
before_value = metrics$co2_ratio[co2], after_value = co2_after[co2],
threshold = artifact_ratio, removed = failed_co2[co2],
reason = ifelse(
failed_co2[co2], "co2_postcondition_failed",
"co2_to_silent_ratio_corrected"
)
)
report(sprintf(
"quality gate %s: CO2 flatten complete (flattened=%d; failed=%d)",
recipe, sum(co2), sum(failed_co2)
))
tail_before <- .artifact_ratio_metrics(
x, tail_n = tail_n, co2_region = c(2200, 2420),
silent_region = c(2420, 2550)
)$tail_ratio
tail_flag <- is.finite(tail_before) & tail_before > artifact_ratio
tail_flag[is.na(tail_flag)] <- FALSE
correction <- .lib_trim_high_tail_matrix(
x$wavenumber, x$spectra[, tail_flag, drop = FALSE],
artifact_ratio = artifact_ratio, tail_n = tail_n,
max_crop = max_crop, co2_region = c(2200, 2420)
)
if (any(tail_flag)) x$spectra[, tail_flag] <- correction$spectra
tail_after <- .artifact_ratio_metrics(
x, tail_n = tail_n, co2_region = c(2200, 2420),
silent_region = c(2420, 2550)
)$tail_ratio
failed_tail <- rep(FALSE, ncol(x$spectra))
failed_tail[tail_flag] <- !correction$passed
assessment[[paste0(recipe, "_tail")]] <- data.table::data.table(
artifact = recipe, spectrum_id = ids[tail_flag],
spectrum_type = type[tail_flag], check = "high_tail",
action = ifelse(correction$passed, "trim", "drop"),
before_value = tail_before[tail_flag], after_value = tail_after[tail_flag],
threshold = artifact_ratio, removed = !correction$passed,
reason = correction$reason
)
report(sprintf(
"quality gate %s: high-tail correction complete (flagged=%d; corrected=%d; failed=%d)",
recipe, sum(tail_flag), sum(correction$passed), sum(!correction$passed)
))
snr <- sig_noise(x, metric = "run_sig_over_noise", step = 10,
na.rm = TRUE)
low_snr <- !is.finite(snr) | snr < snr_threshold
assessment[[paste0(recipe, "_snr")]] <- data.table::data.table(
artifact = recipe, spectrum_id = ids[low_snr],
spectrum_type = type[low_snr], check = "low_snr", action = "drop",
before_value = as.numeric(snr[low_snr]), after_value = NA_real_,
threshold = snr_threshold, removed = TRUE,
reason = ifelse(is.finite(snr[low_snr]),
"running_snr_below_threshold", "running_snr_unavailable")
)
remove <- failed_co2 | failed_tail | low_snr
if (all(remove)) {
stop("Quality control removed every spectrum from ", recipe,
call. = FALSE)
}
x <- filter_spec(x, !remove)
x$metadata$sn <- snr[!remove]
attr(x, "quality_control_report") <- data.table::rbindlist(
assessment[grepl(paste0("^", recipe, "_"), names(assessment))],
fill = TRUE
)
if (detached) store$libraries[[recipe]] <- x else libraries[[recipe]] <- x
report(sprintf(
"quality gate %s: complete (removed=%d; retained=%d; %.1fs)",
recipe, sum(remove), ncol(x$spectra),
proc.time()[["elapsed"]] - started
))
}
rows <- data.table::rbindlist(assessment, fill = TRUE)
if (!nrow(rows)) rows <- .lib_quality_schema()
if (detached) libraries <- store$libraries
list(libraries = libraries, assessment = rows)
}
.lib_trim_high_tail_matrix <- function(wavenumber, spectra,
artifact_ratio = 2, tail_n = 5L,
max_crop = 0.2,
co2_region = c(2200, 2420)) {
if (!ncol(spectra)) {
return(list(spectra = spectra, passed = logical(), reason = character()))
}
passed <- logical(ncol(spectra))
reason <- rep("corrected", ncol(spectra))
for (column in seq_len(ncol(spectra))) {
values <- spectra[, column]
original <- which(is.finite(values))
if (length(original) <= 2L * tail_n) {
reason[column] <- "insufficient_finite_range"
next
}
original_span <- abs(wavenumber[max(original)] - wavenumber[min(original)])
repeat {
finite <- which(is.finite(values))
if (length(finite) <= 2L * tail_n) {
reason[column] <- "insufficient_finite_range"
break
}
normalized <- values
lo <- min(normalized[finite])
span <- max(normalized[finite]) - lo
if (!is.finite(span) || span <= 0) {
reason[column] <- "flat_or_unavailable"
break
}
normalized[finite] <- (normalized[finite] - lo) / span
left <- head(finite, tail_n)
right <- utils::tail(finite, tail_n)
control <- setdiff(
finite,
c(left, right, which(wavenumber >= co2_region[1L] &
wavenumber <= co2_region[2L]))
)
control_max <- if (length(control)) max(normalized[control]) else NA_real_
if (!is.finite(control_max)) {
reason[column] <- "control_unavailable"
break
}
left_ratio <- max(normalized[left]) / control_max
right_ratio <- max(normalized[right]) / control_max
trim_left <- is.finite(left_ratio) && left_ratio > artifact_ratio
trim_right <- is.finite(right_ratio) && right_ratio > artifact_ratio
if (!trim_left && !trim_right) {
spectra[, column] <- values
passed[column] <- TRUE
break
}
if (trim_left) values[min(finite)] <- NA_real_
if (trim_right) values[max(finite)] <- NA_real_
remaining <- which(is.finite(values))
cropped <- 1 - abs(wavenumber[max(remaining)] -
wavenumber[min(remaining)]) / original_span
if (!is.finite(cropped) || cropped > max_crop) {
reason[column] <- "max_crop_exceeded"
break
}
}
}
list(spectra = spectra, passed = passed, reason = reason)
}
.lib_type_ranges <- function() {
list(raman = c(200, 4000), ftir = c(400, 4000), nir = c(4000, 12000))
}
.lib_drop_blank_metadata <- function(x) {
blank <- vapply(x$metadata, function(column) {
if (is.character(column)) {
all(is.na(column) | !nzchar(trimws(column)))
} else {
all(is.na(column))
}
}, logical(1))
blank[names(blank) %in% c("material_form", "common_use")] <- FALSE
if (any(blank)) x$metadata[, (names(blank)[blank]) := NULL]
x
}
.lib_flat_spectrum_mask <- function(x) {
if (!is_OpenSpecy(x) || !ncol(x$spectra)) return(logical())
vapply(seq_len(ncol(x$spectra)), function(column) {
values <- x$spectra[, column]
values <- values[is.finite(values)]
length(values) == 0L || diff(range(values)) <= 0
}, logical(1))
}
.lib_drop_range_flat_spectra <- function(x, artifact, spectrum_type,
reason, report = NULL) {
flat <- .lib_flat_spectrum_mask(x)
if (!any(flat)) return(x)
support <- attr(x, "identification_support", exact = TRUE)
metadata <- x$metadata[flat, , drop = FALSE]
rows <- data.table::data.table(
artifact = artifact,
spectrum_id = as.character(.lib_ids(x, "sample_name"))[flat],
spectrum_identity = if ("spectrum_identity" %in% names(metadata)) {
as.character(metadata$spectrum_identity)
} else {
NA_character_
},
spectrum_type = spectrum_type,
check = "flat_spectrum", action = "drop",
before_value = 0, after_value = NA_real_, threshold = 0,
removed = TRUE, reason = reason
)
x <- filter_spec(x, !flat)
if (!is.null(support) && nrow(support)) {
support <- data.table::copy(support)
support[spectrum_id %in% rows$spectrum_id, retained := FALSE]
attr(x, "identification_support") <- support
attr(x, "identification_dropped_ids") <- unique(c(
attr(x, "identification_dropped_ids", exact = TRUE),
rows$spectrum_id
))
}
attr(x, "range_flat_drops") <- rows
if (!is.null(report)) report(sprintf(
"discarded flat spectra after range restriction (%s/%s; removed=%d)",
artifact, spectrum_type, nrow(rows)
))
x
}
.lib_drop_typed_range_flats <- function(libraries, report = NULL) {
for (recipe in names(libraries)) {
for (type in names(libraries[[recipe]])) {
libraries[[recipe]][[type]] <- .lib_drop_range_flat_spectra(
libraries[[recipe]][[type]], artifact = recipe,
spectrum_type = type, reason = "post_partition_flat_spectrum",
report = report
)
}
}
libraries
}
.lib_partition_reference_libraries <- function(libraries, report = NULL) {
ranges <- .lib_type_ranges()
stored <- is.environment(libraries)
source <- if (stored) NULL else libraries
recipes <- if (stored) names(libraries$libraries) else names(source)
out_all <- stats::setNames(vector("list", length(recipes)), recipes)
for (recipe in recipes) {
x <- if (stored) libraries$libraries[[recipe]] else source[[recipe]]
if (stored) {
libraries$libraries[[recipe]] <- NULL
gc(verbose = FALSE, full = TRUE)
}
out <- lapply(names(ranges), function(type) {
subset <- .lib_filter_optional_type(x, type)
if (is.null(subset)) return(NULL)
limits <- ranges[[type]]
inside <- subset$wavenumber >= limits[1L] &
subset$wavenumber <= limits[2L]
if (sum(inside) < 2L) return(NULL)
subset <- restrict_range(
subset, min = limits[1L], max = limits[2L], make_rel = FALSE
)
subset <- .lib_drop_blank_metadata(subset)
subset <- .lib_drop_range_flat_spectra(
subset, artifact = recipe, spectrum_type = type,
reason = "post_partition_flat_spectrum", report = report
)
if (!is.null(report)) report(sprintf(
"partitioned %s/%s (spectra=%d; range=%g-%g; wavenumbers=%d)",
recipe, type, ncol(subset$spectra), limits[1L], limits[2L],
nrow(subset$spectra)
))
subset
})
names(out) <- names(ranges)
out_all[[recipe]] <- Filter(Negate(is.null), out)
rm(x, out)
gc(verbose = FALSE, full = TRUE)
}
out_all
}
.lib_quarantine_part <- function(x, removals, artifact, spectrum_type, min_n) {
if (!is_OpenSpecy(x) || !nrow(removals)) return(NULL)
ids <- as.character(.lib_ids(x, "sample_name"))
removal_ids <- unique(as.character(removals$spectrum_id))
keep <- ids %in% removal_ids
keep[is.na(keep)] <- FALSE
if (!any(keep)) return(NULL)
out <- filter_spec(x, keep)
max_or_na <- function(value) {
value <- value[is.finite(value)]
if (length(value)) max(value) else NA_real_
}
min_or_na <- function(value) {
value <- value[is.finite(value)]
if (length(value)) min(value) else NA_integer_
}
collapse_values <- function(value) {
value <- sort(unique(as.character(value[!is.na(value) & nzchar(value)])))
if (length(value)) paste(value, collapse = "; ") else NA_character_
}
summary <- removals[spectrum_id %in% ids, .(
quarantine_status = collapse_values(quarantine_status),
quarantine_phase = collapse_values(phase),
quarantine_reason = collapse_values(reason),
component_id = collapse_values(component_id),
correlation_view = collapse_values(correlation_view),
threshold = max_or_na(threshold),
decision_round = min_or_na(decision_round),
active_degree = as.integer(max_or_na(active_degree)),
max_wrong_class_correlation = max_or_na(correlation),
opposing_classes = collapse_values(matched_class),
opposing_libraries = collapse_values(matched_library),
class_n_before = as.integer(max_or_na(class_n_before)),
review_status = collapse_values(review_status)
), by = spectrum_id]
out_ids <- as.character(.lib_ids(out, "sample_name"))
summary <- summary[match(out_ids, spectrum_id)]
added <- setdiff(names(summary), "spectrum_id")
added_values <- summary[, added, with = FALSE]
out$metadata[, (added) := added_values]
out$metadata[, `:=`(
quarantine_recipe = artifact,
quarantine_spectrum_type = spectrum_type,
quarantine_key = paste(artifact, spectrum_type, out_ids, sep = "/"),
min_n = as.integer(min_n)
)]
out
}
.lib_typed_quarantine_parts <- function(x, removals, artifact, min_n) {
if (!is_OpenSpecy(x) || !nrow(removals)) return(list())
types <- sort(unique(tolower(as.character(x$metadata$spectrum_type))))
types <- intersect(types[!is.na(types) & nzchar(types)],
names(.lib_type_ranges()))
out <- lapply(types, function(type) {
object <- .lib_filter_optional_type(x, type)
if (is.null(object)) return(NULL)
ids <- as.character(.lib_ids(object, "sample_name"))
rows <- removals[spectrum_id %in% ids]
if (!nrow(rows)) return(NULL)
limits <- .lib_type_ranges()[[type]]
object <- restrict_range(
object, min = limits[[1L]], max = limits[[2L]], make_rel = FALSE
)
.lib_quarantine_part(object, rows, artifact, type, min_n)
})
names(out) <- paste(artifact, types, sep = "/")
Filter(Negate(is.null), out)
}
.lib_quarantine_bundle <- function(parts = list(), conflicts = NULL,
build_signature = NA_character_,
thresholds = numeric()) {
parts <- Filter(Negate(is.null), parts)
spectra <- list()
if (length(parts)) {
keys <- vapply(parts, function(object) {
paste(
unique(as.character(object$metadata$quarantine_recipe))[[1L]],
unique(as.character(object$metadata$quarantine_spectrum_type))[[1L]],
sep = "/"
)
}, character(1L))
grouped <- split(parts, keys)
spectra <- lapply(names(grouped), function(key) {
out <- .lib_bind_same_axis(grouped[[key]], paste("quarantine", key))
ids <- as.character(.lib_ids(out, "sample_name"))
if (anyDuplicated(ids)) {
stop("Quarantine bundle contains duplicate composite IDs for ", key,
call. = FALSE)
}
out
})
names(spectra) <- names(grouped)
}
if (is.null(conflicts) || !nrow(conflicts)) {
conflicts <- .lib_prune_conflict_edge_schema()
} else {
conflicts <- data.table::as.data.table(data.table::copy(conflicts))
}
structure(list(
spectra = spectra,
conflicts = conflicts,
manifest = list(
schema = "OpenSpecy_quarantined_spectra_v1",
build_signature = as.character(build_signature),
thresholds = sort(unique(as.numeric(thresholds))),
source_hashes = as.character(build_signature)
)
), class = c("OpenSpecyQuarantine", "list"))
}
.lib_close_typed_reference_libraries <- function(libraries, prune_spec,
report = NULL,
progress = FALSE) {
removal_rows <- list()
conflict_rows <- list()
quarantine_parts <- list()
support_rows <- list()
row_i <- 0L
conflict_i <- 0L
part_i <- 0L
support_i <- 0L
generic <- c("other", "other plastic", "other material", "unclassified")
for (artifact in intersect(c("derivative", "nobaseline"), names(libraries))) {
args <- if (is.null(prune_spec)) list(cross_class = TRUE) else prune_spec[[artifact]]
if (is.null(args)) args <- list()
if (!isTRUE(args$cross_class)) next
threshold <- if (is.null(args$cross_class_threshold)) 0.9 else
as.numeric(args$cross_class_threshold)
min_n <- if (is.null(args$min_n)) 10L else as.integer(args$min_n)
for (type in names(libraries[[artifact]])) {
parent <- libraries[[artifact]][[type]]
model_supported <- .lib_restrict_model_range(parent, type)
model_supported <- .lib_drop_range_flat_spectra(
model_supported, artifact = artifact, spectrum_type = type,
reason = "pre_medoid_model_range_flat_spectrum", report = report
)
parent_ids <- as.character(.lib_ids(parent, "sample_name"))
supported_ids <- as.character(.lib_ids(model_supported, "sample_name"))
unsupported <- !parent_ids %in% supported_ids
if (any(unsupported)) {
support_i <- support_i + 1L
support_rows[[support_i]] <- data.table::data.table(
artifact = artifact, spectrum_id = parent_ids[unsupported],
spectrum_type = type, check = "identification_support",
action = "drop", before_value = NA_real_, after_value = NA_real_,
threshold = 0.1, removed = TRUE,
reason = "below_model_range_observed_support"
)
parent <- filter_spec(parent, !unsupported)
}
close_view <- function(object, view) {
ids <- as.character(.lib_ids(object, "sample_name"))
metadata <- object$metadata
result <- .lib_prune_cross_class_conflicts(
classes = as.character(metadata$material_class),
pools = rep(type, length(ids)),
normalized = .lib_prune_normalize(
object$spectra, object$wavenumber, exclude = NULL
),
ids = ids,
library_names = as.character(metadata$library_name),
threshold = threshold, support_groups = rep(type, length(ids)),
min_n = min_n, correlation_view = view,
progress = progress, block_size = 128L
)
if (nrow(result$removals)) result$removals[, phase := "final_closure"]
if (nrow(result$conflicts)) result$conflicts[, phase := "final_closure"]
result
}
full_result <- close_view(parent, "typed_full")
if (nrow(full_result$removals)) {
row_i <- row_i + 1L
removal_rows[[row_i]] <- full_result$removals[, artifact := artifact][]
part_i <- part_i + 1L
quarantine_parts[[part_i]] <- .lib_quarantine_part(
parent, full_result$removals, artifact, type, min_n
)
parent <- filter_spec(parent, -full_result$removed_rows)
}
if (nrow(full_result$conflicts)) {
conflict_i <- conflict_i + 1L
conflict_rows[[conflict_i]] <- full_result$conflicts[, artifact := artifact][]
}
model_view <- .lib_restrict_model_range(parent, type)
model_result <- close_view(model_view, "model_range")
if (nrow(model_result$removals)) {
row_i <- row_i + 1L
removal_rows[[row_i]] <- model_result$removals[, artifact := artifact][]
part_i <- part_i + 1L
quarantine_parts[[part_i]] <- .lib_quarantine_part(
parent, model_result$removals, artifact, type, min_n
)
remove_ids <- model_result$removals$spectrum_id
parent <- filter_spec(
parent, !as.character(.lib_ids(parent, "sample_name")) %in% remove_ids
)
}
if (nrow(model_result$conflicts)) {
conflict_i <- conflict_i + 1L
conflict_rows[[conflict_i]] <- model_result$conflicts[, artifact := artifact][]
}
classes <- as.character(parent$metadata$material_class)
class_keys <- tolower(trimws(classes))
support <- data.table::data.table(
material_class = classes,
resolved = !is.na(classes) & nzchar(classes) &
!class_keys %in% generic
)[resolved == TRUE, .N, by = material_class]
small <- support[N < min_n, material_class]
if (length(small)) {
drop <- which(classes %in% small)
ids <- as.character(.lib_ids(parent, "sample_name"))
counts <- support$N[match(classes[drop], support$material_class)]
floor_removals <- data.table::data.table(
phase = "final_closure", correlation_view = "global_support",
component_id = NA_character_, spectrum_id = ids[drop],
prior_class = classes[drop],
library_name = as.character(parent$metadata$library_name)[drop],
matched_id = NA_character_, matched_class = NA_character_,
matched_library = NA_character_, correlation = NA_real_, pool = type,
active_degree = 0L, matched_active_degree = 0L,
evidence_libraries = 0L, conflicting_spectra = 0L,
matched_evidence_libraries = 0L, decision_round = NA_integer_,
threshold = threshold, class_n_before = counts, class_n_after = 0L,
schedule_order = NA_integer_, quarantine_status = "excluded",
review_status = "global_support_at_risk",
reason = "class_below_global_min_n_after_closure"
)
row_i <- row_i + 1L
removal_rows[[row_i]] <- floor_removals[, artifact := artifact][]
part_i <- part_i + 1L
quarantine_parts[[part_i]] <- .lib_quarantine_part(
parent, floor_removals, artifact, type, min_n
)
parent <- filter_spec(parent, -drop)
}
libraries[[artifact]][[type]] <- parent
if (!is.null(report)) report(sprintf(
"final correlation closure %s/%s complete (retained=%d)",
artifact, type, ncol(parent$spectra)
))
}
}
list(
libraries = libraries,
removals = if (length(removal_rows)) {
data.table::rbindlist(removal_rows, fill = TRUE)
} else .lib_prune_removal_assessment_schema(),
conflicts = if (length(conflict_rows)) {
data.table::rbindlist(conflict_rows, fill = TRUE)
} else {
out <- .lib_prune_conflict_edge_schema()
out[, artifact := character()]
out
},
quarantine_parts = Filter(Negate(is.null), quarantine_parts),
support_drops = if (length(support_rows)) {
data.table::rbindlist(support_rows, fill = TRUE)
} else .lib_quality_schema()
)
}
.lib_build_medoids <- function(libraries, report, checkpoints = NULL,
progress = TRUE) {
processed <- intersect(c("derivative", "nobaseline"), names(libraries))
out <- lapply(processed, function(name) {
types <- libraries[[name]]
stats::setNames(lapply(names(types), function(type) {
stage <- paste0("medoid_", name, "_", type)
cached <- if (is.null(checkpoints)) NULL else checkpoints$get(stage)
if (!is.null(cached)) return(cached)
x <- .lib_restrict_model_range(types[[type]], type)
dropped <- attr(x, "identification_dropped_ids", exact = TRUE)
if (length(dropped)) report(sprintf(
paste0("discarded spectra below 10%% observed support ",
"(%s/%s; removed=%d; minimum=%d/%d wavenumbers)"),
name, type, length(dropped)
, attr(x, "identification_minimum_observed", exact = TRUE),
nrow(x$spectra)
))
x <- .lib_drop_range_flat_spectra(
x, artifact = paste0("medoid_", name), spectrum_type = type,
reason = "post_model_range_flat_spectrum", report = report
)
flat_drops <- attr(x, "range_flat_drops", exact = TRUE)
if (!is.null(flat_drops) && nrow(flat_drops)) {
dropped <- unique(c(dropped, flat_drops$spectrum_id))
}
report(sprintf(
"selecting medoids (%s/%s; spectra=%d; wavenumbers=%d; range=%g-%g)",
name, type, ncol(x$spectra), nrow(x$spectra),
min(x$wavenumber), max(x$wavenumber)
))
ids <- reduce_lib(
x,
group_cols = intersect(c("organization", "material_class"),
names(x$metadata)),
k = 50, min_n = 50, return = "ids",
progress = progress
)
result <- filter_spec(x, ids)
attr(result, "identification_range") <- range(x$wavenumber)
attr(result, "identification_support") <-
attr(x, "identification_support", exact = TRUE)
attr(result, "identification_dropped_ids") <- dropped
attr(result, "identification_minimum_observed") <-
attr(x, "identification_minimum_observed", exact = TRUE)
attr(result, "range_flat_drops") <- flat_drops
if (!is.null(checkpoints)) checkpoints$put(stage, result)
result
}), names(types))
})
names(out) <- processed
out
}
.lib_validate_medoid_parent_closure <- function(libraries, medoids, prune_spec,
report = NULL,
block_size = 128L) {
rows <- list()
row_i <- 0L
generic <- c("other", "other plastic", "other material", "unclassified")
for (artifact in intersect(names(medoids), names(libraries))) {
args <- if (is.null(prune_spec)) list(cross_class = TRUE) else prune_spec[[artifact]]
if (is.null(args)) args <- list()
if (!isTRUE(args$cross_class)) next
threshold <- if (is.null(args$cross_class_threshold)) 0.9 else
as.numeric(args$cross_class_threshold)
for (type in intersect(names(medoids[[artifact]]), names(libraries[[artifact]]))) {
parent <- .lib_restrict_model_range(libraries[[artifact]][[type]], type)
medoid <- medoids[[artifact]][[type]]
parent_ids <- as.character(.lib_ids(parent, "sample_name"))
medoid_ids <- as.character(.lib_ids(medoid, "sample_name"))
index <- match(medoid_ids, parent_ids)
if (anyNA(index)) {
stop("Medoid IDs are absent from the closed parent: ", artifact,
"/", type, call. = FALSE)
}
parent_classes <- as.character(parent$metadata$material_class)
medoid_classes <- as.character(medoid$metadata$material_class)
if (!identical(parent_classes[index], medoid_classes) ||
!identical(parent$wavenumber, medoid$wavenumber) ||
!isTRUE(all.equal(
parent$spectra[, index, drop = FALSE], medoid$spectra,
tolerance = 0, check.attributes = FALSE
))) {
stop("Medoids are not unchanged spectra from the closed parent: ",
artifact, "/", type, call. = FALSE)
}
parent_normalized <- .lib_prune_normalize(
parent$spectra, parent$wavenumber, exclude = NULL
)
medoid_normalized <- parent_normalized[index, , drop = FALSE]
eligible_parent <- !tolower(trimws(parent_classes)) %in% generic
eligible_medoid <- !tolower(trimws(medoid_classes)) %in% generic
eligible_parent[is.na(eligible_parent)] <- FALSE
eligible_medoid[is.na(eligible_medoid)] <- FALSE
maximum <- -Inf
wrong_pairs <- 0L
blocks <- split(
seq_along(parent_ids),
ceiling(seq_along(parent_ids) / as.integer(block_size))
)
for (block in blocks) {
cors <- tcrossprod(
parent_normalized[block, , drop = FALSE], medoid_normalized
)
different <- outer(
parent_classes[block], medoid_classes, FUN = "!="
)
different[is.na(different)] <- FALSE
valid <- outer(eligible_parent[block], eligible_medoid, FUN = "&") &
different
scores <- cors[valid & is.finite(cors)]
if (length(scores)) {
maximum <- max(maximum, max(scores))
wrong_pairs <- wrong_pairs + sum(scores > threshold)
}
rm(cors, different, valid, scores)
}
if (wrong_pairs > 0L) {
stop(
"Closed parent-to-medoid correlation invariant failed for ",
artifact, "/", type, " (", wrong_pairs, " pair(s) > ",
threshold, ")", call. = FALSE
)
}
row_i <- row_i + 1L
rows[[row_i]] <- data.table::data.table(
artifact = artifact, spectrum_type = type,
parent_spectra = length(parent_ids), medoid_spectra = length(medoid_ids),
threshold = threshold,
max_wrong_class_correlation = if (is.finite(maximum)) maximum else NA_real_,
wrong_class_pairs_above_threshold = wrong_pairs,
ids_unchanged = TRUE, classes_unchanged = TRUE,
spectra_unchanged = TRUE
)
if (!is.null(report)) report(sprintf(
"validated parent-to-medoid closure %s/%s (pairs>%g: %d)",
artifact, type, threshold, wrong_pairs
))
}
}
data.table::rbindlist(rows, fill = TRUE)
}
.lib_build_models <- function(libraries, medoids, report, checkpoints = NULL,
methods = "logistic_regression",
random_forest_args = list()) {
methods <- match.arg(
methods, c("logistic_regression", "random_forest"), several.ok = TRUE
)
if (!is.list(random_forest_args) ||
(length(random_forest_args) &&
(is.null(names(random_forest_args)) || any(!nzchar(names(random_forest_args)))))) {
stop("'random_forest_args' must be a named list", call. = FALSE)
}
models <- list()
warnings <- list()
configurations <- list(
logistic_regression = list(
source = medoids,
recipes = intersect(c("derivative", "nobaseline"), names(medoids)),
args = list()
),
random_forest = list(
source = libraries,
recipes = intersect(c("raw", "derivative", "nobaseline"),
names(libraries)),
args = random_forest_args
)
)
configurations <- configurations[methods]
for (algorithm in names(configurations)) {
configuration <- configurations[[algorithm]]
models[[algorithm]] <- list()
for (recipe in configuration$recipes) {
sources <- lapply(names(configuration$source[[recipe]]), function(type) {
.lib_restrict_model_range(configuration$source[[recipe]][[type]], type)
}) |> stats::setNames(names(configuration$source[[recipe]]))
# The historical logistic artifact retains its combined FTIR/Raman
# member. Probability forests are deployed as typed models, so fitting a
# second forest over the already-covered FTIR and Raman rows would only
# duplicate the two largest training jobs.
if (identical(algorithm, "logistic_regression") &&
all(c("ftir", "raman") %in% names(sources))) {
sources$both <- .lib_bind_same_axis(
list(sources$ftir, sources$raman),
label = paste0(recipe, " FTIR/Raman model input")
)
}
models[[algorithm]][[recipe]] <- list()
for (type in names(sources)) {
stage <- paste0(
paste("model", algorithm, recipe, type, sep = "_"),
if (identical(algorithm, "logistic_regression")) {
"_parallel_rng_v1"
} else {
""
}
)
cached <- if (is.null(checkpoints)) NULL else checkpoints$get(stage)
if (!is.null(cached)) {
models[[algorithm]][[recipe]][[type]] <- cached
cached_warnings <- attr(cached, "training_warnings", exact = TRUE)
if(length(cached_warnings)) warnings[[length(warnings) + 1L]] <-
data.table::data.table(
algorithm = algorithm, artifact = recipe, model = type,
warning = as.character(cached_warnings)
)
next
}
if (is.null(sources[[type]])) {
warnings[[length(warnings) + 1L]] <- data.table::data.table(
algorithm = algorithm, artifact = recipe, model = type,
warning = "No spectra were available for this technique"
)
models[[algorithm]][[recipe]][[type]] <- NULL
next
}
model_started <- proc.time()[["elapsed"]]
model_classes <- if("material_class" %in% names(sources[[type]]$metadata)) {
data.table::uniqueN(sources[[type]]$metadata$material_class,
na.rm = TRUE)
} else NA_integer_
report(sprintf(
paste0("training model (%s/%s/%s; spectra=%d; wavenumbers=%d; ",
"classes=%s)"),
algorithm, recipe, type, ncol(sources[[type]]$spectra),
nrow(sources[[type]]$spectra),
if(is.na(model_classes)) "unknown" else model_classes
))
training_warnings <- character()
models[[algorithm]][[recipe]][[type]] <- tryCatch(
withCallingHandlers(
do.call(
train_spec_model,
c(
list(sources[[type]], method = algorithm),
configuration$args
)
),
warning = function(condition) {
message <- conditionMessage(condition)
training_warnings <<- unique(c(training_warnings, message))
report(sprintf("model warning (%s/%s/%s): %s",
algorithm, recipe, type, message))
invokeRestart("muffleWarning")
}
),
error = function(error) {
warnings[[length(warnings) + 1L]] <<- data.table::data.table(
algorithm = algorithm, artifact = recipe, model = type,
warning = conditionMessage(error)
)
NULL
}
)
if(length(training_warnings)) {
warnings[[length(warnings) + 1L]] <- data.table::data.table(
algorithm = algorithm, artifact = recipe, model = type,
warning = training_warnings
)
}
if(!is.null(models[[algorithm]][[recipe]][[type]])) {
attr(models[[algorithm]][[recipe]][[type]], "training_warnings") <-
training_warnings
}
if (!is.null(checkpoints) &&
!is.null(models[[algorithm]][[recipe]][[type]])) {
checkpoints$put(stage, models[[algorithm]][[recipe]][[type]])
}
if (!is.null(models[[algorithm]][[recipe]][[type]])) {
fitted <- models[[algorithm]][[recipe]][[type]]
fill_replaced <- fitted$fill_replaced
if (is.null(fill_replaced)) fill_replaced <- 0L
if (identical(algorithm, "random_forest")) {
metrics <- fitted$oob_metrics
parameters <- fitted$training_parameters
report(sprintf(
paste0("model complete (%s/%s/%s; observations=%d; classes=%d; ",
"filled=%d; trees=%d; mtry=%d; OOB macro accuracy=%.4f; %.1fs)"),
algorithm, recipe, type, fitted$observation_count,
fitted$class_num, fill_replaced, parameters$num_trees,
parameters$mtry, metrics$macro_class_accuracy,
proc.time()[["elapsed"]] - model_started
))
} else {
report(sprintf(
paste0("model complete (%s/%s/%s; observations=%d; classes=%d; ",
"filled=%d; lambda=%.6g by macro class accuracy; %.1fs)"),
algorithm, recipe, type, fitted$observation_count,
fitted$class_num, fill_replaced, fitted$lambda_selected,
proc.time()[["elapsed"]] - model_started
))
}
}
}
}
}
list(
models = models,
warnings = if (length(warnings)) {
data.table::rbindlist(warnings, fill = TRUE)
} else {
.lib_warning_schema()
}
)
}
.lib_restrict_model_range <- function(x, type) {
type <- tolower(type)
if (type %in% c("ftir", "raman")) {
limits <- c(800, 3200)
} else if (identical(type, "nir")) {
finite_fraction <- rowMeans(is.finite(x$spectra))
usable <- x$wavenumber >= 4000 & x$wavenumber <= 12000 &
finite_fraction >= 0.9
if (!any(usable)) {
usable <- x$wavenumber >= 4000 & x$wavenumber <= 12000 &
finite_fraction > 0
}
runs <- rle(usable)
candidates <- which(runs$values)
if (!length(candidates)) {
stop("NIR spectra have no finite values in 4000-12000", call. = FALSE)
}
run <- candidates[which.max(runs$lengths[candidates])]
ends <- cumsum(runs$lengths)
starts <- ends - runs$lengths + 1L
indices <- seq.int(starts[run], ends[run])
limits <- range(x$wavenumber[indices])
} else {
stop("Unsupported spectrum type: ", type, call. = FALSE)
}
out <- restrict_range(
x, min = limits[1L], max = limits[2L], make_rel = FALSE
)
supported <- .lib_filter_spectral_support(out, min_fraction = 0.1)
out <- supported$object
attr(out, "identification_range") <- limits
out
}
.lib_bind_same_axis <- function(objects, label = "objects") {
objects <- Filter(Negate(is.null), objects)
if (!length(objects)) return(NULL)
axes <- lapply(objects, `[[`, "wavenumber")
if (!all(vapply(axes[-1L], identical, logical(1), axes[[1L]]))) {
stop(label, " do not share an identical wavenumber axis", call. = FALSE)
}
out <- objects[[1L]]
out$spectra <- do.call(cbind, lapply(objects, `[[`, "spectra"))
out$metadata <- data.table::rbindlist(
lapply(objects, `[[`, "metadata"), fill = TRUE
)
out <- as_OpenSpecy(out)
out
}
.lib_filter_optional_type <- function(x, type) {
if (!is_OpenSpecy(x) && is.list(x) && length(x) && !is.null(names(x))) {
index <- match(tolower(type), tolower(names(x)))
if (is.na(index)) return(NULL)
candidate <- x[[index]]
if (!is_OpenSpecy(candidate)) {
stop("Typed reference-library component '", names(x)[[index]],
"' is not an OpenSpecy object", call. = FALSE)
}
return(candidate)
}
if (!is_OpenSpecy(x)) return(NULL)
keep <- !is.na(x$metadata$spectrum_type) &
tolower(x$metadata$spectrum_type) == type
if (!any(keep)) return(NULL)
filter_spec(x, keep)
}
.lib_recover_library_assessments <- function(libraries, tables) {
artifacts <- .lib_named_typed_objects(libraries)
collect_enrichment <- function(attribute, field) {
data.table::rbindlist(lapply(names(artifacts), function(name) {
report <- attr(artifacts[[name]], attribute, exact = TRUE)
if (is.null(report) || is.null(report[[field]]) ||
!nrow(report[[field]])) return(NULL)
out <- data.table::copy(report[[field]])
out[, artifact := name]
out
}), fill = TRUE)
}
material_form_enrichment <- collect_enrichment(
"material_form_enrichment_report", "summary"
)
material_form_matches <- collect_enrichment(
"material_form_enrichment_report", "matches"
)
material_form_clashes <- collect_enrichment(
"material_form_enrichment_report", "clashes"
)
common_use_enrichment <- collect_enrichment(
"common_use_enrichment_report", "summary"
)
common_use_coverage <- collect_enrichment(
"common_use_enrichment_report", "coverage"
)
prediction <- data.table::rbindlist(lapply(names(libraries), function(name) {
report <- attr(libraries[[name]], "class_prediction_report")
if (is.null(report) || is.null(report$summary)) return(NULL)
data.table::data.table(
artifact = name, metric = names(report$summary),
value = as.numeric(unlist(report$summary, use.names = FALSE))
)
}), fill = TRUE)
coverage <- data.table::rbindlist(lapply(names(libraries), function(name) {
report <- attr(libraries[[name]], "class_coverage_report")
if (is.null(report)) return(NULL)
out <- data.table::copy(data.table::as.data.table(report))
out[, artifact := name]
out
}), fill = TRUE)
type_coverage <- data.table::rbindlist(lapply(names(libraries), function(name) {
metadata <- libraries[[name]]$metadata
data.table::data.table(
artifact = name, field = c("library_type", "spectrum_type"),
populated = c(
sum(!is.na(metadata$library_type) & nzchar(metadata$library_type)),
sum(!is.na(metadata$spectrum_type) & nzchar(metadata$spectrum_type))
), total = nrow(metadata)
)
}))
pruning <- data.table::rbindlist(lapply(names(libraries), function(name) {
report <- attr(libraries[[name]], "prune_report")
if (is.null(report) || is.null(report$summary)) return(NULL)
out <- data.table::copy(report$summary)
out[, artifact := name]
out
}), fill = TRUE)
pruning_excluded_classes <- data.table::rbindlist(lapply(
names(libraries), function(name) {
report <- attr(libraries[[name]], "prune_report")
if (is.null(report) || is.null(report$excluded_classes) ||
!nrow(report$excluded_classes)) return(NULL)
out <- data.table::copy(report$excluded_classes)
out[, artifact := name]
data.table::setcolorder(
out, c("artifact", setdiff(names(out), "artifact"))
)
out
}
), fill = TRUE)
if (!nrow(pruning_excluded_classes)) {
pruning_excluded_classes <- .lib_prune_excluded_assessment_schema()
}
other_review <- data.table::rbindlist(lapply(names(libraries), function(name) {
report <- attr(libraries[[name]], "other_review_report")
if (is.null(report) || !nrow(report)) return(NULL)
data.table::copy(report)
}), fill = TRUE)
if (!nrow(other_review)) other_review <- .lib_other_review_schema()
other_filter <- data.table::rbindlist(lapply(names(libraries), function(name) {
report <- attr(libraries[[name]], "other_filter_report")
if (is.null(report) || !nrow(report)) return(NULL)
data.table::copy(report)
}), fill = TRUE)
if (!nrow(other_filter)) other_filter <- .lib_other_filter_schema()
pruning_reassignments <- data.table::rbindlist(lapply(
names(libraries), function(name) {
report <- attr(libraries[[name]], "prune_report")
if (is.null(report) || is.null(report$reassignments) ||
!nrow(report$reassignments)) return(NULL)
data.table::copy(report$reassignments)[, artifact := name][]
}
), fill = TRUE)
if (!nrow(pruning_reassignments)) {
pruning_reassignments <- .lib_prune_reassignment_schema()
}
pruning_removals <- data.table::rbindlist(lapply(
names(libraries), function(name) {
report <- attr(libraries[[name]], "prune_report")
if (is.null(report) || is.null(report$removals) ||
!nrow(report$removals)) return(NULL)
out <- data.table::copy(report$removals)
out[, artifact := name]
data.table::setcolorder(
out, c("artifact", setdiff(names(out), "artifact"))
)
out
}
), fill = TRUE)
if (!nrow(pruning_removals)) {
pruning_removals <- .lib_prune_removal_assessment_schema()
}
pruning_conflicts <- data.table::rbindlist(lapply(
names(libraries), function(name) {
report <- attr(libraries[[name]], "prune_report")
if (is.null(report) || is.null(report$cross_class_conflicts) ||
!nrow(report$cross_class_conflicts)) return(NULL)
out <- data.table::copy(report$cross_class_conflicts)
out[, artifact := name]
out
}
), fill = TRUE)
if (!nrow(pruning_conflicts)) {
pruning_conflicts <- .lib_prune_conflict_edge_schema()
pruning_conflicts[, artifact := character()]
}
raw_coverage <- attr(libraries$raw, "class_coverage_report")
before_filter <- if (is.null(raw_coverage) || nrow(raw_coverage) == 0L) {
NA_integer_
} else {
as.integer(raw_coverage$total[[nrow(raw_coverage)]])
}
after_filter <- ncol(libraries$raw$spectra)
metadata_drop <- data.table::data.table(
metadata_column = tables$metadata_drop$metadata_column,
status = ifelse(
tables$metadata_drop$metadata_column %in% names(libraries$raw$metadata),
"retained", "removed_or_absent"
)
)
dropped_spectrum_identities <- data.table::rbindlist(lapply(
libraries, function(object) {
attr(object, "dropped_spectrum_identities", exact = TRUE)
}
), fill = TRUE)
if (!"spectrum_identity" %in% names(dropped_spectrum_identities)) {
dropped_spectrum_identities <- data.table::data.table(
spectrum_identity = character()
)
} else {
dropped_spectrum_identities <- dropped_spectrum_identities[
!is.na(spectrum_identity) & nzchar(trimws(spectrum_identity)),
.(spectrum_identity = sort(unique(spectrum_identity)))
]
}
list(
class_prediction = prediction,
class_coverage = coverage,
type_coverage = type_coverage,
material_form_enrichment = material_form_enrichment,
material_form_matches = material_form_matches,
material_form_clashes = material_form_clashes,
common_use_enrichment = common_use_enrichment,
common_use_coverage = common_use_coverage,
other_review = other_review,
other_filter = other_filter,
pruning = pruning,
pruning_excluded_classes = pruning_excluded_classes,
pruning_reassignments = pruning_reassignments,
pruning_removals = pruning_removals,
pruning_conflicts = pruning_conflicts,
filters = data.table::data.table(
stage = "special_filter", before = before_filter,
after = after_filter,
removed = if (is.na(before_filter)) NA_integer_ else
before_filter - after_filter
),
metadata_drop = metadata_drop,
library_retention = .lib_library_retention_report(list(
.lib_library_stage_counts(libraries, stage = "final")
)),
dropped_spectrum_identities = dropped_spectrum_identities
)
}
.lib_model_assessment_tables <- function(models) {
table_fields <- c(
"tests", "lambda_metrics", "oob_metrics", "oob_class_accuracy",
"feature_importance", "training_parameters", "class_weights"
)
out <- stats::setNames(vector("list", length(table_fields)), table_fields)
for (algorithm in names(models)) {
for (recipe in names(models[[algorithm]])) {
for (type in names(models[[algorithm]][[recipe]])) {
model <- models[[algorithm]][[recipe]][[type]]
if (is.null(model)) next
for (field in table_fields) {
value <- model[[field]]
if (is.null(value) || !length(value)) next
value <- data.table::copy(data.table::as.data.table(value))
if (!nrow(value)) next
value[, `:=`(
algorithm = algorithm, artifact = recipe, model = type
)]
data.table::setcolorder(
value,
c("algorithm", "artifact", "model",
setdiff(names(value), c("algorithm", "artifact", "model")))
)
out[[field]][[length(out[[field]]) + 1L]] <- value
}
}
}
}
lapply(out, function(rows) {
if (!length(rows)) return(data.table::data.table())
data.table::rbindlist(rows, fill = TRUE, use.names = TRUE)
})
}
.lib_collect_automated_test_flags <- function(libraries) {
rows <- lapply(names(libraries), function(artifact) {
object <- libraries[[artifact]]
if (!is_OpenSpecy(object)) return(NULL)
metadata <- data.table::as.data.table(object$metadata)
required <- c("assessment_flag", "assessment_checks")
if (!all(required %in% names(metadata))) return(NULL)
keep <- !is.na(metadata$assessment_flag) & metadata$assessment_flag &
!is.na(metadata$assessment_checks) &
nzchar(trimws(metadata$assessment_checks))
if (!any(keep)) return(NULL)
ids <- .lib_ids(object, "sample_name")
technique <- if ("spectrum_type" %in% names(metadata)) {
as.character(metadata$spectrum_type)
} else {
rep("all", nrow(metadata))
}
checks <- strsplit(as.character(metadata$assessment_checks[keep]),
";\\s*")
data.table::data.table(
artifact = artifact,
technique = rep(technique[keep], lengths(checks)),
spectrum_id = rep(as.character(ids[keep]), lengths(checks)),
error_mode = trimws(unlist(checks, use.names = FALSE))
)[nzchar(error_mode)]
})
out <- unique(data.table::rbindlist(rows, fill = TRUE))
if (nrow(out)) return(out[])
data.table::data.table(
artifact = character(), technique = character(),
spectrum_id = character(), error_mode = character()
)
}
.lib_local_build_assessments <- function(libraries, medoids, models,
completed, model_warnings) {
artifacts <- c(
.lib_named_typed_objects(libraries),
.lib_named_typed_objects(medoids, prefix = "medoid_")
)
summary <- data.table::rbindlist(lapply(names(artifacts), function(name) {
data.table::data.table(
artifact = name,
valid_OpenSpecy = suppressWarnings(check_OpenSpecy(artifacts[[name]])),
spectra = ncol(artifacts[[name]]$spectra),
wavenumbers = length(artifacts[[name]]$wavenumber),
metadata_columns = ncol(artifacts[[name]]$metadata)
)
}), fill = TRUE)
model_summary <- data.table::rbindlist(lapply(names(models), function(algorithm) {
data.table::rbindlist(lapply(names(models[[algorithm]]), function(recipe) {
data.table::rbindlist(lapply(names(models[[algorithm]][[recipe]]), function(type) {
model <- models[[algorithm]][[recipe]][[type]]
oob <- if (is.null(model)) NULL else model$oob_metrics
data.table::data.table(
algorithm = algorithm, artifact = recipe, model = type,
trained = !is.null(model),
classes = if (is.null(model)) NA_integer_ else model$class_num,
observations = if (is.null(model)) NA_integer_ else
model$observation_count,
fill_replaced = if (is.null(model)) NA_integer_ else
model$fill_replaced,
lambda_selected = if (is.null(model)) NA_real_ else
model$lambda_selected,
oob_accuracy = if (is.null(oob)) NA_real_ else oob$accuracy,
oob_macro_class_accuracy = if (is.null(oob)) NA_real_ else
oob$macro_class_accuracy,
oob_brier_score = if (is.null(oob)) NA_real_ else
oob$brier_score
)
}), fill = TRUE)
}), fill = TRUE)
}), fill = TRUE)
support_rows <- list()
class_support_rows <- list()
for (recipe in names(medoids)) {
for (type in names(medoids[[recipe]])) {
audit <- attr(
medoids[[recipe]][[type]], "identification_support", exact = TRUE
)
if (is.null(audit)) next
audit <- data.table::copy(audit)
audit[, `:=`(stage = "medoid", algorithm = NA_character_,
artifact = recipe, model = type)]
support_rows[[length(support_rows) + 1L]] <- audit
}
}
for (algorithm in names(models)) {
for (recipe in names(models[[algorithm]])) {
for (type in names(models[[algorithm]][[recipe]])) {
model <- models[[algorithm]][[recipe]][[type]]
if (is.null(model) || is.null(model$support)) next
audit <- data.table::copy(model$support)
audit[, `:=`(stage = "model", algorithm = algorithm,
artifact = recipe, model = type)]
support_rows[[length(support_rows) + 1L]] <- audit
if (!is.null(model$class_support)) {
class_audit <- data.table::copy(model$class_support)
class_audit[, `:=`(algorithm = algorithm, artifact = recipe,
model = type)]
class_support_rows[[length(class_support_rows) + 1L]] <- class_audit
}
}
}
}
support <- if (length(support_rows)) {
data.table::rbindlist(support_rows, fill = TRUE)
} else {
out <- .lib_support_schema()
out[, `:=`(stage = character(), algorithm = character(), artifact = character(),
model = character())]
data.table::setcolorder(
out, c("stage", "algorithm", "artifact", "model", setdiff(names(out),
c("stage", "algorithm", "artifact", "model")))
)
out
}
class_support <- if (length(class_support_rows)) {
data.table::rbindlist(class_support_rows, fill = TRUE)
} else {
data.table::data.table(
algorithm = character(), artifact = character(), model = character(),
class = character(),
spectra = integer(), minimum_spectra = integer(), retained = logical()
)
}
library_artifacts <- .lib_named_typed_objects(libraries)
lookup_coverage <- data.table::rbindlist(lapply(names(library_artifacts), function(name) {
reports <- attr(library_artifacts[[name]], "metadata_lookup_reports")
if (is.null(reports) || length(reports) == 0L) return(NULL)
data.table::rbindlist(lapply(names(reports), function(stage) {
out <- data.table::copy(reports[[stage]])
out[, `:=`(artifact = name, stage = stage)]
out
}), fill = TRUE)
}), fill = TRUE)
identity_cleanup <- data.table::rbindlist(lapply(names(library_artifacts), function(name) {
out <- attr(library_artifacts[[name]], "spectrum_identity_cleanup_report")
if (is.null(out) || nrow(out) == 0L) return(NULL)
out <- data.table::copy(out)
out[, artifact := name]
out
}), fill = TRUE)
exclusions <- data.table::rbindlist(lapply(names(library_artifacts), function(name) {
out <- attr(library_artifacts[[name]], "build_stage_report")
if (is.null(out) || nrow(out) == 0L) return(NULL)
out <- data.table::copy(out)
out[, artifact := name]
out
}), fill = TRUE)
model_tables <- .lib_model_assessment_tables(models)
defaults <- list(
build_summary = summary,
lookup_coverage = lookup_coverage,
identity_cleanup = identity_cleanup,
class_prediction = data.table::data.table(),
class_coverage = data.table::data.table(),
type_coverage = data.table::data.table(),
material_form_enrichment = data.table::data.table(),
material_form_matches = data.table::data.table(),
material_form_clashes = data.table::data.table(),
common_use_enrichment = data.table::data.table(),
common_use_coverage = data.table::data.table(),
other_review = .lib_other_review_schema(),
other_filter = .lib_other_filter_schema(),
exclusions_deduplication = exclusions,
filters = data.table::data.table(),
metadata_drop = data.table::data.table(),
metadata_finalization = .lib_metadata_finalization_schema(),
pruning = data.table::data.table(),
pruning_excluded_classes = .lib_prune_excluded_assessment_schema(),
pruning_reassignments = .lib_prune_reassignment_schema(),
pruning_removals = .lib_prune_removal_assessment_schema(),
pruning_conflicts = .lib_prune_conflict_edge_schema(),
correlation_closure = data.table::data.table(),
medoid_model_summary = model_summary,
medoid_model_support = support,
model_class_support = class_support,
model_training_tests = model_tables$tests,
model_lambda_metrics = model_tables$lambda_metrics,
model_oob_metrics = model_tables$oob_metrics,
model_oob_class_accuracy = model_tables$oob_class_accuracy,
model_feature_importance = model_tables$feature_importance,
model_training_parameters = model_tables$training_parameters,
model_class_weights = model_tables$class_weights,
split_manifest = data.table::data.table(),
library_identification = data.table::data.table(),
library_confusion = data.table::data.table(),
model_identification = data.table::data.table(),
model_confusion = data.table::data.table(),
model_assessment_correlations =
.lib_model_assessment_correlation_schema(),
automated_test_flags = data.table::data.table(
artifact = character(), technique = character(),
spectrum_id = character(), error_mode = character()
),
assess_spec_shifts = data.table::data.table(),
old_new_compatibility = data.table::data.table(),
quality_control = .lib_quality_schema(),
library_retention = data.table::data.table(),
dropped_spectrum_identities = data.table::data.table(
spectrum_identity = character()
),
warnings = if (nrow(model_warnings)) model_warnings else
.lib_warning_schema(),
output_manifest = data.table::data.table()
)
if (!is.null(completed)) defaults[names(completed)] <- completed
range_flat <- data.table::rbindlist(lapply(
c(
.lib_named_typed_objects(libraries),
.lib_named_typed_objects(medoids, prefix = "medoid_")
),
function(object) attr(object, "range_flat_drops", exact = TRUE)
), fill = TRUE)
if (nrow(range_flat)) {
defaults$quality_control <- unique(data.table::rbindlist(
list(defaults$quality_control, range_flat), fill = TRUE
))
range_flat_identities <- range_flat[
!is.na(spectrum_identity) & nzchar(trimws(spectrum_identity)),
.(spectrum_identity)
]
defaults$dropped_spectrum_identities <- data.table::rbindlist(
list(defaults$dropped_spectrum_identities, range_flat_identities),
fill = TRUE
)[
!is.na(spectrum_identity) & nzchar(trimws(spectrum_identity)),
.(spectrum_identity = sort(unique(as.character(spectrum_identity))))
]
}
defaults
}
.lib_bind_assessment_tables <- function(assessments, table_names) {
rows <- lapply(intersect(table_names, names(assessments)), function(name) {
value <- assessments[[name]]
if (!inherits(value, c("data.frame", "data.table")) || !nrow(value)) {
return(NULL)
}
value <- data.table::copy(data.table::as.data.table(value))
value[, assessment_kind := name]
data.table::setcolorder(
value, c("assessment_kind", setdiff(names(value), "assessment_kind"))
)
value
})
data.table::rbindlist(rows, fill = TRUE, use.names = TRUE)
}
.lib_pivot_assessment_sources <- function(x, id_cols, value_cols,
shift_cols = character()) {
if (is.null(x) || !nrow(x)) return(data.table::data.table())
x <- data.table::copy(data.table::as.data.table(x))
id_cols <- intersect(id_cols, names(x))
value_cols <- intersect(value_cols, names(x))
if (!"source" %in% names(x) || !length(id_cols) || !length(value_cols)) {
return(x)
}
x <- x[source %in% c("old", "new")]
if (!nrow(x)) return(data.table::data.table())
duplicate_key <- c(id_cols, "source")
if (anyDuplicated(x[, ..duplicate_key])) {
stop("Assessment rows are not unique for old/new pivot keys",
call. = FALSE)
}
formula <- stats::as.formula(paste(paste(id_cols, collapse = " + "),
"~ source"))
out <- data.table::copy(data.table::dcast(
x, formula, value.var = value_cols, sep = "_", drop = TRUE
))
template <- x[0]
for (metric in value_cols) {
for (source in c("old", "new")) {
column <- paste0(metric, "_", source)
if (!column %in% names(out)) {
prototype <- template[[metric]]
value <- if (is.integer(prototype)) NA_integer_ else if (
is.numeric(prototype)
) NA_real_ else if (is.logical(prototype)) NA else NA_character_
data.table::set(out, j = column, value = value)
}
}
if (metric %in% shift_cols) {
data.table::set(
out, j = paste0(metric, "_shift"),
value = out[[paste0(metric, "_new")]] -
out[[paste0(metric, "_old")]]
)
}
}
paired <- unlist(lapply(value_cols, function(metric) {
columns <- c(paste0(metric, "_old"), paste0(metric, "_new"))
if (metric %in% shift_cols) columns <- c(columns, paste0(metric, "_shift"))
columns
}), use.names = FALSE)
data.table::setcolorder(out, c(id_cols, paired, setdiff(names(out), c(id_cols, paired))))
out[]
}
.lib_accuracy_review <- function(summary, medoid = FALSE) {
summary <- data.table::copy(data.table::as.data.table(summary))
if (!nrow(summary) || !"artifact" %in% names(summary)) {
return(data.table::data.table())
}
is_medoid <- grepl("^medoid_", summary$artifact)
out <- summary[is_medoid == medoid]
if (!nrow(out)) return(out)
out[, `:=`(
overall_accuracy_pct = 100 * overall_accuracy,
macro_class_accuracy_pct = 100 * macro_class_accuracy,
class_count = as.integer(evaluated_classes)
)]
keep <- intersect(c(
"algorithm", "artifact", "model", "technique", "source", "provenance",
"spectra", "class_count", "overall_accuracy_pct",
"macro_class_accuracy_pct"
), names(out))
out <- out[, ..keep]
out[, .source_order := match(source, c("old", "new"))]
data.table::setorderv(
out, intersect(c("algorithm", "artifact", "model", "technique",
".source_order"), names(out)),
rep(1L, length(intersect(
c("algorithm", "artifact", "model", "technique", ".source_order"),
names(out)
)))
)
out[, .source_order := NULL]
out[]
}
.lib_confusion_review <- function(confusion, medoid = NULL) {
rows <- data.table::copy(data.table::as.data.table(confusion))
if (!nrow(rows)) return(data.table::data.table())
if (!is.null(medoid)) {
is_medoid <- grepl("^medoid_", rows$artifact)
rows <- rows[is_medoid == medoid]
}
out <- rows[misidentified %in% TRUE]
if (!nrow(out)) return(out)
out[is.na(predicted_class) | !nzchar(predicted_class),
predicted_class := "unclassified"]
out[, expected_class_pct := 100 * expected_class_fraction]
keep <- intersect(c(
"algorithm", "artifact", "model", "technique", "source", "provenance",
"expected_class", "predicted_class", "spectra",
"expected_class_spectra", "expected_class_pct"
), names(out))
out <- out[, ..keep]
data.table::setorderv(
out,
intersect(c("algorithm", "artifact", "model", "technique", "source",
"spectra", "expected_class", "predicted_class"), names(out)),
c(rep(1L, length(intersect(
c("algorithm", "artifact", "model", "technique", "source"), names(out)
))), -1L, 1L, 1L)
)
out[]
}
.lib_model_diagnostics_review <- function(assessments) {
out <- data.table::copy(data.table::as.data.table(
assessments$model_assessment_correlations
))
if (nrow(out)) data.table::setorderv(
out, c("accuracy_difference_pct", "algorithm", "artifact", "model",
"technique", "error_mode"), c(1L, 1L, 1L, 1L, 1L, 1L)
)
out[]
}
.lib_cleanup_review <- function(assessments, table_names) {
rows <- lapply(intersect(table_names, names(assessments)), function(name) {
value <- assessments[[name]]
if (!inherits(value, c("data.frame", "data.table")) || !nrow(value)) {
return(NULL)
}
value <- data.table::as.data.table(value)
data.table::data.table(
assessment = name,
rows = as.integer(nrow(value)),
artifacts = as.integer(if ("artifact" %in% names(value)) {
data.table::uniqueN(value$artifact, na.rm = TRUE)
} else 0L),
spectrum_records = as.integer(if ("spectrum_id" %in% names(value)) {
data.table::uniqueN(value$spectrum_id, na.rm = TRUE)
} else if ("n" %in% names(value)) {
sum(value$n, na.rm = TRUE)
} else 0L),
removed_spectra = as.integer(if ("removed" %in% names(value)) {
sum(as.numeric(value$removed), na.rm = TRUE)
} else if ("spectra_removed" %in% names(value)) {
sum(value$spectra_removed, na.rm = TRUE)
} else 0L)
)
})
out <- data.table::rbindlist(rows, fill = TRUE)
if (nrow(out)) data.table::setorder(out, assessment)
out[]
}
.lib_functionality_review <- function(shifts) {
shifts <- data.table::copy(data.table::as.data.table(shifts))
if (!nrow(shifts)) return(data.table::data.table())
for (column in c("rate_old", "rate_new")) {
if (!column %in% names(shifts)) shifts[[column]] <- 0
shifts[is.na(get(column)), (column) := 0]
}
out <- shifts[, .(
artifact, check,
old_error_pct = 100 * rate_old,
new_error_pct = 100 * rate_new,
change_pct = 100 * (rate_new - rate_old)
)]
data.table::setorderv(out, c("change_pct", "artifact", "check"),
c(-1L, 1L, 1L))
out[]
}
.lib_assessment_review <- function(assessments) {
if (is.list(assessments) && identical(
names(assessments), c("cleanup", "ref_lib", "medoid", "model", "functionality")
)) return(assessments)
cleanup_names <- c(
"lookup_coverage", "identity_cleanup", "class_prediction", "class_coverage",
"type_coverage", "material_form_enrichment", "material_form_matches",
"material_form_clashes", "common_use_enrichment", "common_use_coverage",
"other_review", "other_filter", "exclusions_deduplication",
"filters", "metadata_drop", "metadata_finalization", "pruning",
"pruning_excluded_classes", "pruning_reassignments", "pruning_removals",
"pruning_conflicts", "correlation_closure",
"quality_control",
"library_retention"
)
cleanup_summary <- data.table::copy(data.table::as.data.table(
assessments$library_retention
))
if (nrow(cleanup_summary)) {
if ("reason" %in% names(cleanup_summary)) {
cleanup_summary[is.na(reason) | !nzchar(trimws(reason)),
reason := "retained"]
}
cleanup_summary[, assessment_kind := "library_retention"]
data.table::setcolorder(
cleanup_summary,
c("assessment_kind", setdiff(names(cleanup_summary), "assessment_kind"))
)
} else {
cleanup_summary <- .lib_cleanup_review(assessments, cleanup_names)
}
dropped <- assessments$dropped_spectrum_identities
if (is.null(dropped) || !nrow(dropped)) {
dropped <- data.table::data.table(spectrum_identity = character())
} else {
dropped <- data.table::data.table(
spectrum_identity = sort(unique(as.character(dropped$spectrum_identity)))
)[!is.na(spectrum_identity) & nzchar(trimws(spectrum_identity))]
}
ref_accuracy <- .lib_accuracy_review(
assessments$library_identification, medoid = FALSE
)
medoid_accuracy <- .lib_accuracy_review(
assessments$library_identification, medoid = TRUE
)
ref_confusion <- .lib_confusion_review(
assessments$library_confusion, medoid = FALSE
)
medoid_confusion <- .lib_confusion_review(
assessments$library_confusion, medoid = TRUE
)
model_accuracy <- .lib_accuracy_review(assessments$model_identification)
model_confusion <- .lib_confusion_review(assessments$model_confusion)
model_diagnostics <- .lib_model_diagnostics_review(assessments)
functionality <- .lib_functionality_review(assessments$assess_spec_shifts)
compact <- function(values) {
values[vapply(values, function(value) {
inherits(value, c("data.frame", "data.table")) && nrow(value) > 0L
}, logical(1))]
}
out <- list(
cleanup = compact(list(
summary = cleanup_summary,
dropped_spectrum_identities = dropped
)),
ref_lib = compact(list(accuracy = ref_accuracy, confusion = ref_confusion)),
medoid = compact(list(accuracy = medoid_accuracy, confusion = medoid_confusion)),
model = compact(list(
accuracy = model_accuracy, confusion = model_confusion,
error_mode_accuracy = model_diagnostics
)),
functionality = compact(list(comparison = functionality))
)
fallback <- c(
cleanup = "summary", ref_lib = "accuracy", medoid = "accuracy",
model = "error_mode_accuracy", functionality = "comparison"
)
for (process in names(out)) {
if (!length(out[[process]])) {
out[[process]][[fallback[[process]]]] <- data.table::data.table(
status = "not_available_for_this_build"
)
}
}
evidence_names <- intersect(
c("split_manifest", "library_tests", "model_training_tests", "model_tests",
"model_lambda_metrics", "model_oob_metrics", "model_oob_class_accuracy",
"model_feature_importance", "model_training_parameters",
"model_class_weights", "medoid_model_summary", "medoid_model_support",
"model_class_support", "warnings", "old_new_compatibility",
"build_summary", "assess_spec_shifts", "output_manifest",
"pruning_conflicts", "correlation_closure"),
names(assessments)
)
evidence <- assessments[evidence_names]
attr(evidence, "sha256") <- vapply(evidence, digest::digest, character(1),
algo = "sha256")
attr(out, "assessment_schema_version") <- "2.0.0"
attr(out, "evidence") <- evidence
attr(out, "upstream_assessments") <- assessments[intersect(
cleanup_names, names(assessments)
)]
out
}
.lib_named_typed_objects <- function(x, prefix = "") {
out <- list()
for (recipe in names(x)) {
item <- x[[recipe]]
if (is_OpenSpecy(item)) {
out[[paste0(prefix, recipe)]] <- item
} else if (is.list(item)) {
for (type in names(item)) {
if (is_OpenSpecy(item[[type]])) {
out[[paste0(prefix, recipe, "_", type)]] <- item[[type]]
}
}
}
}
out
}
.lib_metadata_finalization_schema <- function() {
data.table::data.table(
artifact = character(), spectra = integer(), before_columns = integer(),
after_columns = integer(), dropped_columns = integer(),
dropped_names = character(), minimum_missing = integer(),
maximum_missing = integer()
)
}
.lib_finalize_one_metadata <- function(x, artifact) {
if (!is_OpenSpecy(x)) {
stop("Metadata finalization requires an OpenSpecy object", call. = FALSE)
}
metadata <- data.table::copy(x$metadata)
before <- ncol(metadata)
missing <- vapply(metadata, function(column) {
as.integer(sum(is.na(column)))
}, integer(1L))
fully_missing <- if (nrow(metadata)) {
names(missing)[missing == nrow(metadata)]
} else {
character()
}
fully_missing <- setdiff(
fully_missing, c("material_form", "common_use")
)
if (length(fully_missing)) metadata[, (fully_missing) := NULL]
remaining_missing <- missing[setdiff(names(missing), fully_missing)]
if (length(remaining_missing)) {
ordered <- names(remaining_missing)[order(
remaining_missing, seq_along(remaining_missing)
)]
data.table::setcolorder(metadata, ordered)
}
out <- x
out$metadata <- metadata
if (!check_OpenSpecy(out)) {
stop("Metadata finalization broke the OpenSpecy object contract for '",
artifact, "'", call. = FALSE)
}
assessment <- data.table::data.table(
artifact = artifact,
spectra = as.integer(nrow(metadata)),
before_columns = as.integer(before),
after_columns = as.integer(ncol(metadata)),
dropped_columns = as.integer(length(fully_missing)),
dropped_names = paste(fully_missing, collapse = "; "),
minimum_missing = if (length(remaining_missing)) {
as.integer(min(remaining_missing))
} else {
NA_integer_
},
maximum_missing = if (length(remaining_missing)) {
as.integer(max(remaining_missing))
} else {
NA_integer_
}
)
list(object = out, assessment = assessment)
}
.lib_finalize_reference_metadata <- function(libraries, medoids) {
assessment <- list()
finalize <- function(collection, prefix = "") {
for (recipe in names(collection)) {
for (type in names(collection[[recipe]])) {
if (!is_OpenSpecy(collection[[recipe]][[type]])) next
artifact <- paste0(prefix, recipe, "_", type)
result <- .lib_finalize_one_metadata(
collection[[recipe]][[type]], artifact
)
collection[[recipe]][[type]] <- result$object
assessment[[artifact]] <<- result$assessment
}
}
collection
}
libraries <- finalize(libraries)
medoids <- finalize(medoids, prefix = "medoid_")
list(
libraries = libraries,
medoids = medoids,
assessment = if (length(assessment)) {
data.table::rbindlist(assessment, fill = TRUE)
} else {
.lib_metadata_finalization_schema()
}
)
}
.lib_model_assessment_correlation_schema <- function() {
data.table::data.table(
algorithm = character(), artifact = character(), model = character(),
technique = character(), error_mode = character(),
with_error_accuracy_pct = numeric(),
without_error_accuracy_pct = numeric(),
accuracy_difference_pct = numeric()
)
}
.lib_model_assessment_correlations <- function(tests, flags) {
schema <- .lib_model_assessment_correlation_schema()
if (is.null(tests) || !nrow(tests) || is.null(flags) || !nrow(flags)) {
return(schema)
}
tests <- data.table::copy(data.table::as.data.table(tests))
flags <- unique(data.table::copy(data.table::as.data.table(flags)))
test_required <- c(
"algorithm", "artifact", "model", "source", "technique",
"spectrum_id", "correct"
)
flag_required <- c("artifact", "technique", "spectrum_id", "error_mode")
missing <- c(setdiff(test_required, names(tests)),
setdiff(flag_required, names(flags)))
if (length(missing)) {
stop("Model error-mode assessment rows are missing: ",
paste(unique(missing), collapse = ", "), call. = FALSE)
}
tests <- tests[source == "new" & !is.na(correct), ..test_required]
flags <- flags[
!is.na(error_mode) & nzchar(trimws(error_mode)), ..flag_required
]
modes <- unique(flags[, .(artifact, technique, error_mode)])
if (!nrow(tests) || !nrow(modes)) return(schema)
rows <- merge(
tests, modes, by = c("artifact", "technique"), allow.cartesian = TRUE
)
flags[, has_error := TRUE]
rows <- merge(
rows, flags,
by = c("artifact", "technique", "spectrum_id", "error_mode"),
all.x = TRUE, sort = FALSE
)
rows[is.na(has_error), has_error := FALSE]
groups <- c("algorithm", "artifact", "model", "technique", "error_mode")
out <- rows[, .(
error_spectra = as.integer(sum(has_error)),
with_error_accuracy_pct = 100 * mean(correct[has_error]),
no_error_spectra = as.integer(sum(!has_error)),
without_error_accuracy_pct = 100 * mean(correct[!has_error])
), by = groups]
out <- out[error_spectra > 0L & no_error_spectra > 0L]
if (!nrow(out)) return(schema)
out[, accuracy_difference_pct :=
with_error_accuracy_pct - without_error_accuracy_pct]
out[, c("error_spectra", "no_error_spectra") := NULL]
data.table::setcolorder(out, names(schema))
data.table::setorderv(
out, c("accuracy_difference_pct", groups),
c(1L, rep(1L, length(groups)))
)
out[]
}
.lib_previous_signature <- function(path) {
if (is.null(path)) return("none")
resolved <- if (identical(path, "system")) {
system.file("extdata", package = "OpenSpecy")
} else {
path
}
types <- c(
"raw", "derivative", "nobaseline", "medoid_derivative",
"medoid_nobaseline", "model_derivative", "model_nobaseline"
)
files <- file.path(resolved, paste0(types, ".rds"))
digest::digest(.lib_file_signatures(files, checksum_limit = Inf),
algo = "sha256")
}
.lib_compare_reference_build <- function(build, previous_library_dir,
seed, holdout, progress,
checkpoints = NULL,
checkpoint_key = NULL,
checkpoint_fallback_key = NULL) {
prior <- .lib_load_previous_libraries(previous_library_dir, progress)
get_checkpoint <- function(stage, stable = FALSE) {
if (is.null(checkpoints)) return(NULL)
value <- checkpoints$get(stage, key = checkpoint_key)
can_fallback <- isTRUE(stable) && is.null(value) &&
!is.null(checkpoint_fallback_key) &&
!identical(checkpoint_key, checkpoint_fallback_key)
if (can_fallback) {
value <- checkpoints$get(stage, key = checkpoint_fallback_key)
if (!is.null(value)) checkpoints$put(stage, value, key = checkpoint_key)
}
value
}
reference_pairs <- list()
for (recipe in intersect(names(build$libraries),
c("raw", "derivative", "nobaseline"))) {
for (type in names(build$libraries[[recipe]])) {
artifact <- paste(recipe, type, sep = "_")
reference_pairs[[artifact]] <- list(
recipe = recipe, type = type, kind = "full"
)
}
}
for (recipe in intersect(names(build$medoids),
c("derivative", "nobaseline"))) {
legacy <- prior[[paste0("medoid_", recipe)]]
for (type in names(build$medoids[[recipe]])) {
artifact <- paste("medoid", recipe, type, sep = "_")
reference_pairs[[artifact]] <- list(
recipe = recipe, type = type, kind = "medoid"
)
}
}
materialize_pair <- function(spec) {
recipe <- spec$recipe
type <- spec$type
if (identical(spec$kind, "full")) {
old <- .lib_filter_optional_type(prior[[recipe]], type)
if (is.null(old)) return(NULL)
new <- build$libraries[[recipe]][[type]]
limits <- range(new$wavenumber)
old <- restrict_range(old, min = limits[1L], max = limits[2L],
make_rel = FALSE)
return(list(
new_reference = new, old_reference = old,
new_data = new, old_data = old
))
}
old <- .lib_filter_optional_type(
prior[[paste0("medoid_", recipe)]], type
)
old_full <- .lib_filter_optional_type(prior[[recipe]], type)
if (is.null(old) || is.null(old_full)) return(NULL)
new <- build$medoids[[recipe]][[type]]
limits <- range(new$wavenumber)
old <- restrict_range(old, min = limits[1L], max = limits[2L],
make_rel = FALSE)
list(
new_reference = new, old_reference = old,
new_data = .lib_restrict_to_reference(
build$libraries[[recipe]][[type]], new
),
old_data = .lib_restrict_to_reference(old_full, old)
)
}
compatibility <- get_checkpoint("assessment_compatibility", stable = TRUE)
compatibility_rows <- list()
split_rows <- list()
reference_tests <- list()
assessment_summaries <- list()
for (i in seq_along(reference_pairs)) {
artifact <- names(reference_pairs)[[i]]
if (isTRUE(progress)) {
message(sprintf(
"build_lib assessment: identify %s (%d/%d)",
artifact, i, length(reference_pairs)
))
}
pair <- materialize_pair(reference_pairs[[artifact]])
if (is.null(pair)) next
if (is.null(compatibility)) {
compatibility_rows[[artifact]] <- .lib_compatibility_rows(
pair$new_reference, pair$old_reference, artifact
)
}
for (source in c("new", "old")) {
is_medoid <- identical(reference_pairs[[artifact]]$kind, "medoid")
split <- NULL
if (!is_medoid) {
split_stage <- paste0("assessment_split_", artifact, "_", source)
split <- get_checkpoint(split_stage, stable = TRUE)
if (is.null(split)) {
split <- .lib_source_split(
pair[[paste0(source, "_data")]], artifact = artifact,
source = source,
seed = seed + i * 2L + match(source, c("new", "old")),
holdout = holdout
)
if (!is.null(checkpoints)) {
checkpoints$put(split_stage, split, key = checkpoint_key)
}
}
split_rows[[paste(artifact, source, sep = "_")]] <- split$manifest
}
stage <- paste0(
"assessment_reference_", if (is_medoid) "complete_" else "",
artifact, "_", source, if (is_medoid) "_v1" else ""
)
tests <- get_checkpoint(stage, stable = !is_medoid)
if (is.null(tests)) {
test_args <- list(
reference = pair[[paste0(source, "_reference")]],
data = pair[[paste0(source, "_data")]],
artifact = artifact, source = source, progress = progress
)
tests <- if (is_medoid) {
do.call(.lib_reference_complete_test, test_args)
} else {
test_args$split <- split
do.call(.lib_reference_holdout_test, test_args)
}
if (!is.null(checkpoints)) {
checkpoints$put(stage, tests, key = checkpoint_key)
}
}
reference_tests[[paste0(artifact, "_", source)]] <- tests
stage <- paste0("assessment_spectra_", artifact, "_", source)
summary <- get_checkpoint(stage, stable = TRUE)
if (is.null(summary)) {
assessment_started <- proc.time()[["elapsed"]]
if (isTRUE(progress)) {
message(sprintf(
"build_lib assessment: assess_spec %s/%s starting (spectra=%d)",
artifact, source,
ncol(pair[[paste0(source, "_reference")]]$spectra)
))
}
summary <- .lib_assess_spec_summary(
pair[[paste0(source, "_reference")]], artifact, source
)
if (isTRUE(progress)) {
message(sprintf(
"build_lib assessment: assess_spec %s/%s complete (%.1fs)",
artifact, source,
proc.time()[["elapsed"]] - assessment_started
))
}
if (!is.null(checkpoints)) {
checkpoints$put(stage, summary, key = checkpoint_key)
}
}
assessment_summaries[[paste0(artifact, "_", source)]] <- summary
}
rm(pair)
gc(verbose = FALSE)
}
if (is.null(compatibility)) {
compatibility <- data.table::rbindlist(compatibility_rows, fill = TRUE)
if (!is.null(checkpoints)) {
checkpoints$put("assessment_compatibility", compatibility,
key = checkpoint_key)
}
}
split_manifest <- data.table::rbindlist(split_rows, fill = TRUE)
reference_tests <- data.table::rbindlist(reference_tests, fill = TRUE)
if (!nrow(reference_tests)) {
stop(
"No comparable legacy reference-library spectra were assessed; ",
"verify that prior artifacts contain typed OpenSpecy components",
call. = FALSE
)
}
if (!nrow(compatibility)) {
stop("Legacy compatibility assessment produced no artifact rows",
call. = FALSE)
}
library_identification <- .lib_identification_summary(reference_tests)
library_confusion <- .lib_confusion_table(reference_tests)
assess_spec_shifts <- .lib_assessment_shift_table(
data.table::rbindlist(assessment_summaries, fill = TRUE)
)
model_tests <- list()
updated_models <- build$models
algorithms <- intersect(
c("logistic_regression", "random_forest"), names(updated_models)
)
for (algorithm in algorithms) {
recipes <- names(updated_models[[algorithm]])
sources <- if (identical(algorithm, "logistic_regression")) {
c("new", "old")
} else {
"new"
}
for (recipe in recipes) {
legacy_model <- if (identical(algorithm, "logistic_regression")) {
prior[[paste0("model_", recipe)]]
} else {
NULL
}
for (source in sources) {
model_set <- if (source == "new") {
updated_models[[algorithm]][[recipe]]
} else {
legacy_model
}
source_object <- function(type) {
if (source == "new") {
build$libraries[[recipe]][[type]]
} else {
.lib_filter_optional_type(prior[[recipe]], type)
}
}
for (type in names(model_set)) {
if (is.null(model_set[[type]])) next
component_types <- if (identical(type, "both")) {
c("ftir", "raman")
} else {
type
}
query_parts <- list()
for (actual_type in component_types) {
eligible <- tryCatch(
.lib_restrict_model_range(source_object(actual_type), actual_type),
error = function(error) NULL
)
if (!is.null(eligible)) query_parts[[actual_type]] <- eligible
}
if (!all(component_types %in% names(query_parts))) next
query <- if (length(component_types) == 1L) {
query_parts[[1L]]
} else {
.lib_bind_same_axis(query_parts, "combined model test spectra")
}
rm(query_parts)
gc(verbose = FALSE)
model_started <- proc.time()[["elapsed"]]
provenance <- paste0(source, "_", algorithm, "_complete_dataset")
if (isTRUE(progress)) message(
"build_lib assessment: existing model ", algorithm, "/",
recipe, "/", type, "/", source, " identifying complete dataset (",
ncol(query$spectra), ")"
)
stage <- paste0(
"assessment_model_complete_", algorithm, "_", recipe, "_", type,
"_", source, "_v1"
)
tests <- if (is.null(checkpoints)) NULL else
checkpoints$get(stage, key = checkpoint_key)
if (is.null(tests)) {
tests <- .lib_model_complete_test(
model_set[[type]], query, recipe, type,
algorithm = algorithm, source = source, provenance = provenance
)
if (!is.null(checkpoints)) {
checkpoints$put(stage, tests, key = checkpoint_key)
}
}
if (source == "new") {
updated_models[[algorithm]][[recipe]][[type]]$tests <- tests
}
key <- paste(algorithm, recipe, type, source, sep = "_")
model_tests[[key]] <- tests
if (isTRUE(progress)) message(sprintf(
paste0("build_lib assessment: existing model ",
"%s/%s/%s/%s complete (%.1fs)"),
algorithm, recipe, type, source,
proc.time()[["elapsed"]] - model_started
))
rm(query)
gc(verbose = FALSE, full = TRUE)
}
gc(verbose = FALSE, full = TRUE)
}
}
}
split_manifest <- data.table::rbindlist(
split_rows, fill = TRUE
)
model_tests <- data.table::rbindlist(model_tests, fill = TRUE)
model_identification <- .lib_identification_summary(model_tests)
model_confusion <- .lib_confusion_table(model_tests)
automated_test_flags <- build$assessments$automated_test_flags
if (is.null(automated_test_flags) || !nrow(automated_test_flags)) {
quality <- data.table::copy(data.table::as.data.table(
build$assessments$quality_control
))
if (nrow(quality) && all(c(
"artifact", "spectrum_type", "spectrum_id", "check"
) %in% names(quality))) {
automated_test_flags <- unique(quality[, .(
artifact, technique = spectrum_type, spectrum_id,
error_mode = check
)])
}
}
model_assessment_correlations <- .lib_model_assessment_correlations(
model_tests, automated_test_flags
)
list(
models = updated_models,
split_manifest = split_manifest,
library_tests = reference_tests,
library_identification = library_identification,
library_confusion = library_confusion,
model_identification = model_identification,
model_tests = model_tests,
model_confusion = model_confusion,
model_assessment_correlations = model_assessment_correlations,
assess_spec_shifts = assess_spec_shifts,
old_new_compatibility = compatibility
)
}
.lib_load_previous_libraries <- function(path, progress) {
types <- c(
"raw", "derivative", "nobaseline", "medoid_derivative",
"medoid_nobaseline", "model_derivative", "model_nobaseline"
)
resolved <- if (identical(path, "system")) {
system.file("extdata", package = "OpenSpecy")
} else {
path
}
if (!nzchar(resolved)) {
stop("Could not resolve 'previous_library_dir'", call. = FALSE)
}
missing <- !file.exists(file.path(resolved, paste0(types, ".rds")))
if (any(missing)) {
if (isTRUE(progress)) {
message("build_lib assessment: retrieving missing legacy artifacts")
}
get_lib(types[missing], path = path)
}
missing <- !file.exists(file.path(resolved, paste0(types, ".rds")))
if (any(missing)) {
stop("Legacy artifact(s) remain unavailable: ",
paste(types[missing], collapse = ", "), call. = FALSE)
}
setNames(lapply(file.path(resolved, paste0(types, ".rds")), readRDS), types)
}
.lib_compatibility_rows <- function(new, old, artifact) {
new_ids <- .lib_ids(new, "sample_name")
old_ids <- .lib_ids(old, "sample_name")
new_names <- names(new$metadata)
old_names <- names(old$metadata)
data.table::data.table(
artifact = artifact,
spectra_old = ncol(old$spectra), spectra_new = ncol(new$spectra),
spectra_shift = ncol(new$spectra) - ncol(old$spectra),
wavenumbers_old = length(old$wavenumber),
wavenumbers_new = length(new$wavenumber),
wavenumbers_shift = length(new$wavenumber) - length(old$wavenumber),
metadata_columns_old = length(old_names),
metadata_columns_new = length(new_names),
metadata_columns_shift = length(new_names) - length(old_names),
shared_identifiers = length(intersect(new_ids, old_ids)),
identifiers_old_only = length(setdiff(old_ids, new_ids)),
identifiers_new_only = length(setdiff(new_ids, old_ids)),
axes_identical = identical(new$wavenumber, old$wavenumber),
metadata_old_only = paste(setdiff(old_names, new_names), collapse = "; "),
metadata_new_only = paste(setdiff(new_names, old_names), collapse = "; ")
)
}
.lib_restrict_to_reference <- function(x, reference) {
limits <- range(reference$wavenumber)
out <- restrict_range(
x, min = limits[1L], max = limits[2L], make_rel = FALSE
)
.lib_filter_spectral_support(out, min_fraction = 0.1)$object
}
.lib_source_split <- function(x, artifact, source, seed, holdout) {
rows <- .lib_split_rows(x, source)
groups <- rows[, .(
material_class = .lib_first_value(material_class),
spectrum_type = .lib_first_value(spectrum_type)
), by = group_id]
groups[, stratum := paste(
ifelse(is.na(spectrum_type), "unknown", spectrum_type),
ifelse(is.na(material_class), "unclassified", material_class), sep = "\r"
)]
allocation <- groups[, .(available = .N), by = stratum]
allocation[, `:=`(
expected = available * holdout,
selected = as.integer(floor(available * holdout))
)]
target <- min(nrow(groups), max(1L, as.integer(round(nrow(groups) * holdout))))
set.seed(seed)
allocation[, `:=`(
remainder = expected - selected,
tie_break = stats::runif(.N)
)]
data.table::setorder(allocation, -remainder, tie_break, stratum)
remaining <- target - sum(allocation$selected)
while (remaining > 0L) {
eligible <- which(allocation$selected < allocation$available)
if (!length(eligible)) break
take <- head(eligible, remaining)
allocation$selected[take] <- allocation$selected[take] + 1L
remaining <- remaining - length(take)
}
test_groups <- unlist(lapply(seq_len(nrow(allocation)), function(i) {
count <- allocation$selected[[i]]
if (!count) return(character())
sample(groups[stratum == allocation$stratum[[i]], group_id], count)
}), use.names = FALSE)
groups[, `:=`(
artifact = artifact, source = source,
split = ifelse(group_id %in% test_groups, "test", "train")
)]
list(
manifest = groups[, .(
artifact, source, group_id, split, material_class, spectrum_type
)],
rows = rows
)
}
.lib_split_rows <- function(x, source, group_ids = NULL) {
metadata <- x$metadata
grouping <- NULL
if (is.null(group_ids)) {
grouping <- .lib_stable_group_info(x)
group_ids <- grouping$group_id
}
if (length(group_ids) != ncol(x$spectra)) {
stop("Comparison group identifiers are not aligned to spectra",
call. = FALSE)
}
data.table::data.table(
source = source, row = seq_along(group_ids), group_id = group_ids,
physical_id = if (is.null(grouping)) NA_character_ else grouping$physical_id,
content_hash = if (is.null(grouping)) NA_character_ else grouping$content_hash,
spectrum_id = as.character(.lib_ids(x, "sample_name")),
material_class = if ("material_class" %in% names(metadata))
as.character(metadata$material_class) else NA_character_,
spectrum_type = if ("spectrum_type" %in% names(metadata))
as.character(metadata$spectrum_type) else NA_character_
)
}
.lib_comparison_group_ids <- function(x) {
.lib_stable_group_info(x)$group_id
}
.lib_stable_group_info <- function(x) {
ids <- as.character(.lib_ids(x, "sample_name"))
metadata <- x$metadata
if ("sample_name_old" %in% names(metadata)) {
legacy_ids <- trimws(as.character(metadata$sample_name_old))
usable <- !is.na(legacy_ids) & nzchar(legacy_ids) &
tolower(legacy_ids) != "new format"
ids[usable] <- legacy_ids[usable]
}
missing <- is.na(ids) | !nzchar(trimws(ids))
ids[missing] <- paste0("row_", which(missing))
content_hash <- vapply(seq_len(ncol(x$spectra)), function(i) {
digest::digest(
list(x$wavenumber, as.numeric(x$spectra[, i])),
algo = "sha256"
)
}, character(1))
parent <- seq_along(ids)
find_root <- function(i) {
while (parent[[i]] != i) {
parent[[i]] <<- parent[[parent[[i]]]]
i <- parent[[i]]
}
i
}
unite <- function(left, right) {
left <- find_root(left)
right <- find_root(right)
if (left != right) parent[[right]] <<- left
}
edges <- data.table::rbindlist(list(
data.table::data.table(token = paste0("id:", ids), row = seq_along(ids))[
, if (.N > 1L) .(row = row[-1L], anchor = row[[1L]]) else NULL,
by = token
],
data.table::data.table(
token = paste0("content:", content_hash), row = seq_along(ids)
)[, if (.N > 1L) .(row = row[-1L], anchor = row[[1L]]) else NULL,
by = token]
), fill = TRUE)
if (nrow(edges)) {
for (i in seq_len(nrow(edges))) unite(edges$row[[i]], edges$anchor[[i]])
}
roots <- vapply(seq_along(ids), find_root, integer(1))
group_id <- data.table::data.table(
row = seq_along(ids), root = roots, physical_id = ids,
content_hash = content_hash
)[, .(
group_id = paste0("group_", substr(digest::digest(
sort(unique(c(physical_id, content_hash))), algo = "sha256"
), 1L, 24L))
), by = root][data.table::data.table(row = seq_along(ids), root = roots),
on = "root", group_id]
list(group_id = group_id, physical_id = ids, content_hash = content_hash)
}
.lib_first_value <- function(x) {
x <- as.character(x)
x <- x[!is.na(x) & nzchar(x)]
if (length(x)) x[[1L]] else NA_character_
}
.lib_reference_holdout_test <- function(reference, data, split,
artifact, source,
progress = FALSE) {
test_groups <- split$manifest[split == "test", group_id]
source_rows <- split$rows
test_idx <- source_rows[group_id %in% test_groups, row]
if (length(test_idx) == 0L) {
return(data.table::data.table())
}
query <- filter_spec(data, test_idx)
query_ids <- .lib_ids(query, "sample_name")
reference_ids <- .lib_ids(reference, "sample_name")
reference_groups <- split$rows$group_id[
match(reference_ids, split$rows$spectrum_id)
]
keep_reference <- !is.na(reference_groups) &
!reference_groups %in% test_groups
if (!any(keep_reference)) return(data.table::data.table())
library <- if (all(keep_reference)) reference else
filter_spec(reference, keep_reference)
library_ids <- .lib_ids(library, "sample_name")
started <- proc.time()[["elapsed"]]
if (isTRUE(progress)) {
message(sprintf(
paste0(
"build_lib assessment: %s/%s reference identification starting ",
"(test=%d; train=%d)"
),
artifact, source, length(test_idx), ncol(library$spectra)
))
}
cors <- cor_spec(query, library = library, compute = "optimized")
if (isTRUE(progress)) {
message(sprintf(
paste0(
"build_lib assessment: %s/%s full correlation complete ",
"(%d x %d; %.1fs)"
),
artifact, source, nrow(cors), ncol(cors),
proc.time()[["elapsed"]] - started
))
}
top <- max_cor_named(cors)
matched_rows <- match(names(top), library_ids)
rm(cors)
expected <- as.character(query$metadata$material_class)
predicted <- as.character(library$metadata$material_class[matched_rows])
if (isTRUE(progress)) {
message(sprintf(
"build_lib assessment: %s/%s reference identification complete (%.1fs)",
artifact, source, proc.time()[["elapsed"]] - started
))
}
data.table::data.table(
artifact = artifact, source = source,
spectrum_id = query_ids,
technique = as.character(query$metadata$spectrum_type),
expected_class = expected, predicted_class = predicted,
correct = expected == predicted,
score = as.numeric(top), split = "test",
provenance = "source_local_reference_holdout"
)
}
.lib_reference_complete_test <- function(reference, data, artifact, source,
progress = FALSE,
block_size = 1000L) {
block_size <- max(1L, as.integer(block_size))
query_ids <- .lib_ids(data, "sample_name")
library_ids <- .lib_ids(reference, "sample_name")
blocks <- split(
seq_len(ncol(data$spectra)),
ceiling(seq_len(ncol(data$spectra)) / block_size)
)
started <- proc.time()[["elapsed"]]
if (isTRUE(progress)) message(sprintf(
paste0("build_lib assessment: %s/%s medoid identifying complete ",
"dataset (query=%d; reference=%d; blocks=%d)"),
artifact, source, ncol(data$spectra), ncol(reference$spectra),
length(blocks)
))
rows <- lapply(seq_along(blocks), function(i) {
idx <- blocks[[i]]
query <- if (length(idx) == ncol(data$spectra)) data else
filter_spec(data, idx)
cors <- cor_spec(query, library = reference, compute = "optimized")
top <- max_cor_named(cors)
matched_rows <- match(names(top), library_ids)
rm(cors)
expected <- as.character(query$metadata$material_class)
predicted <- as.character(reference$metadata$material_class[matched_rows])
if (isTRUE(progress)) message(sprintf(
"build_lib assessment: %s/%s medoid block %d/%d complete",
artifact, source, i, length(blocks)
))
data.table::data.table(
artifact = artifact, source = source,
spectrum_id = query_ids[idx],
technique = as.character(query$metadata$spectrum_type),
expected_class = expected, predicted_class = predicted,
correct = expected == predicted, score = as.numeric(top),
split = "complete", provenance = "complete_medoid_reference_search"
)
})
if (isTRUE(progress)) message(sprintf(
"build_lib assessment: %s/%s complete medoid search finished (%.1fs)",
artifact, source, proc.time()[["elapsed"]] - started
))
data.table::rbindlist(rows, fill = TRUE)
}
.lib_model_complete_test <- function(model, x, artifact, type, algorithm, source,
provenance) {
fill <- model$fill
if (!is_OpenSpecy(fill)) {
variables <- as.numeric(model$all_variables)
fill <- as_OpenSpecy(
variables,
spectra = matrix(
0, nrow = length(variables), ncol = 1L,
dimnames = list(NULL, "model_zero_fill")
),
metadata = data.table::data.table(sample_name = "model_zero_fill")
)
}
prediction <- match_spec(x, library = model, fill = fill)
predicted <- if ("name" %in% names(prediction)) {
as.character(prediction$name)
} else if ("predicted_name" %in% names(prediction)) {
as.character(prediction$predicted_name)
} else {
rep(NA_character_, nrow(prediction))
}
expected <- as.character(x$metadata$material_class)
technique <- if ("spectrum_type" %in% names(x$metadata)) {
as.character(x$metadata$spectrum_type)
} else {
NA_character_
}
typed_expected <- paste(technique, expected, sep = "_")
use_typed <- !is.na(typed_expected) & typed_expected %in% model$class_names
expected[use_typed] <- typed_expected[use_typed]
data.table::data.table(
algorithm = algorithm, artifact = artifact, model = type, source = source,
spectrum_id = .lib_ids(x, "sample_name"), technique = technique,
expected_class = expected, predicted_class = predicted,
correct = expected == predicted,
score = if ("value" %in% names(prediction)) prediction$value else NA_real_,
split = "complete", provenance = provenance
)
}
.lib_align_model_input <- function(x, model) {
variables <- as.numeric(model$all_variables)
keep <- match(variables, x$wavenumber)
if (anyNA(keep)) {
stop("Model variables are not available on the comparison spectrum axis",
call. = FALSE)
}
out <- x
out$wavenumber <- x$wavenumber[keep]
out$spectra <- x$spectra[keep, , drop = FALSE]
out
}
.lib_identification_summary <- function(tests) {
if (nrow(tests) == 0L) return(data.table::data.table())
group_cols <- intersect(
c("algorithm", "artifact", "model", "source", "technique", "provenance"),
names(tests)
)
overall <- tests[, .(
spectra = .N, evaluated = sum(!is.na(correct)),
coverage = mean(!is.na(correct)),
overall_accuracy = if (all(is.na(correct))) NA_real_ else
mean(correct, na.rm = TRUE),
mean_score = if (all(is.na(score))) NA_real_ else mean(score, na.rm = TRUE)
), by = group_cols]
class_summary <- tests[, .(
class_accuracy = if (all(is.na(correct))) NA_real_ else
mean(correct, na.rm = TRUE),
class_spectra = .N,
class_evaluated = sum(!is.na(correct))
), by = c(group_cols, "expected_class")]
macro <- class_summary[, .(
macro_class_accuracy = if (all(is.na(class_accuracy))) NA_real_ else
mean(class_accuracy, na.rm = TRUE),
classes = .N,
evaluated_classes = sum(!is.na(class_accuracy))
), by = group_cols]
out <- merge(overall, macro, by = group_cols, all = TRUE)
data.table::setcolorder(
out,
c(group_cols, "macro_class_accuracy", "coverage", "overall_accuracy",
"spectra", "evaluated", "classes", "evaluated_classes", "mean_score")
)
out
}
.lib_confusion_table <- function(tests) {
if (!nrow(tests)) {
return(data.table::data.table(
algorithm = character(), artifact = character(), model = character(),
source = character(),
technique = character(), provenance = character(),
expected_class = character(), predicted_class = character(),
misidentified = logical(), spectra = integer(),
expected_class_spectra = integer(), expected_class_fraction = numeric()
))
}
group_cols <- intersect(
c("algorithm", "artifact", "model", "source", "technique", "provenance"),
names(tests)
)
out <- tests[, .(spectra = .N),
by = c(group_cols, "expected_class", "predicted_class")]
out[, misidentified := is.na(expected_class) | is.na(predicted_class) |
expected_class != predicted_class]
expected_groups <- c(group_cols, "expected_class")
out[, `:=`(
expected_class_spectra = as.integer(sum(spectra)),
expected_class_fraction = spectra / sum(spectra)
), by = expected_groups]
data.table::setcolorder(
out,
c(group_cols, "expected_class", "predicted_class", "misidentified",
"spectra", "expected_class_spectra", "expected_class_fraction")
)
data.table::setorderv(
out,
c(group_cols, "misidentified", "spectra", "expected_class",
"predicted_class"),
c(rep(1L, length(group_cols)), -1L, -1L, 1L, 1L), na.last = TRUE
)
out
}
.lib_assess_spec_summary <- function(x, artifact, source) {
assessed <- assess_spec(x, report = "all")
summary <- assessed[, .(
count = .N,
finding_count = sum(finding_count, na.rm = TRUE),
example_ids = paste(
head(unique(spectrum_id[status != "pass"]), 20L), collapse = "; "
)
), by = .(check, status)]
summary[, `:=`(
artifact = artifact, source = source,
spectra = ncol(x$spectra), rate = count / ncol(x$spectra)
)]
summary
}
.lib_assessment_shift_table <- function(summary) {
if (nrow(summary) == 0L) return(summary)
summary <- summary[status %in% c("error", "warning"), .(
count = sum(count, na.rm = TRUE),
finding_count = sum(finding_count, na.rm = TRUE),
example_ids = paste(unique(example_ids[nzchar(example_ids)]), collapse = "; "),
spectra = if (all(is.na(spectra))) NA_integer_ else max(spectra, na.rm = TRUE)
), by = .(artifact, source, check)]
summary[, rate := count / spectra]
if (!nrow(summary)) return(summary)
old <- summary[source == "old"]
new <- summary[source == "new"]
by <- c("artifact", "check")
out <- merge(
old, new, by = by, all = TRUE, suffixes = c("_old", "_new")
)
for (column in c("count", "finding_count", "spectra", "rate")) {
old_col <- paste0(column, "_old")
new_col <- paste0(column, "_new")
out[[paste0(column, "_shift")]] <- out[[new_col]] - out[[old_col]]
}
out$relative_rate_shift <- ifelse(
is.na(out$rate_old) | out$rate_old == 0,
NA_real_, (out$rate_new - out$rate_old) / out$rate_old
)
data.table::setorderv(
out, c("rate_shift", "artifact", "check"), c(-1L, 1L, 1L),
na.last = TRUE
)
out
}
.lib_validate_reference_build <- function(build) {
process_names <- c("cleanup", "ref_lib", "medoid", "model", "functionality")
if (!identical(names(build$assessments), process_names)) {
stop("Assessment review must contain the five ordered process groups",
call. = FALSE)
}
leaf_count <- sum(lengths(build$assessments))
if (leaf_count > 10L || any(lengths(build$assessments) == 0L)) {
stop("Assessment review must contain 1-10 nonempty process tables",
call. = FALSE)
}
leaves <- unlist(build$assessments, recursive = FALSE, use.names = TRUE)
empty <- !vapply(leaves, function(value) {
inherits(value, c("data.frame", "data.table")) && nrow(value) > 0L
}, logical(1))
if (any(empty)) {
stop("Assessment review contains empty or non-tabular leaves: ",
paste(names(leaves)[empty], collapse = ", "), call. = FALSE)
}
sparse <- unlist(lapply(names(leaves), function(name) {
value <- leaves[[name]]
if (!nrow(value)) return(character())
missing <- vapply(value, function(column) mean(is.na(column)), numeric(1L))
columns <- names(missing)[missing > 0.10]
if (!length(columns)) return(character())
paste0(name, "$", columns)
}), use.names = FALSE)
if (length(sparse)) {
stop("Assessment review contains columns with more than 10% missing ",
"values: ", paste(sparse, collapse = ", "), call. = FALSE)
}
artifacts <- c(
.lib_named_typed_objects(build$libraries),
.lib_named_typed_objects(build$medoids, prefix = "medoid_")
)
invalid <- vapply(artifacts, function(object) {
!isTRUE(suppressWarnings(check_OpenSpecy(object))) ||
any(!is.finite(object$wavenumber)) || anyDuplicated(object$wavenumber) ||
is.unsorted(object$wavenumber, strictly = TRUE) ||
ncol(object$spectra) != nrow(object$metadata)
}, logical(1))
if (any(invalid)) {
stop("Invalid OpenSpecy release artifact(s): ",
paste(names(artifacts)[invalid], collapse = ", "), call. = FALSE)
}
flat <- vapply(artifacts, function(object) {
any(vapply(seq_len(ncol(object$spectra)), function(i) {
values <- object$spectra[, i]
values <- values[is.finite(values)]
length(values) == 0L || diff(range(values)) <= 0
}, logical(1)))
}, logical(1))
if (any(flat)) {
stop("Flat or unavailable spectra remain in release artifact(s): ",
paste(names(artifacts)[flat], collapse = ", "), call. = FALSE)
}
expected <- list()
if (!is.null(build$models$logistic_regression)) {
expected$logistic_regression <- lapply(build$medoids, function(types) {
out <- names(types)
if (all(c("ftir", "raman") %in% out)) out <- c(out, "both")
out
})
}
if (!is.null(build$models$random_forest)) {
expected$random_forest <- lapply(build$libraries, names)
}
problems <- character()
if (is.null(expected$logistic_regression)) {
problems <- c(problems, "logistic_regression missing")
}
for (algorithm in names(expected)) {
for (recipe in names(expected[[algorithm]])) {
for (type in expected[[algorithm]][[recipe]]) {
model <- build$models[[algorithm]][[recipe]][[type]]
label <- paste(algorithm, recipe, type, sep = "/")
if (is.null(model)) {
problems <- c(problems, paste0(label, " missing"))
next
}
required <- c("model", "class_names", "class_num", "fill", "all_variables")
if (!all(required %in% names(model)) || !is_OpenSpecy(model$fill)) {
problems <- c(problems, paste0(label, " incomplete"))
}
if (identical(algorithm, "logistic_regression") &&
!.lib_selected_lambda_converged(model)) {
problems <- c(problems, paste0(label, " selected lambda unconverged"))
}
}
}
}
if (length(problems)) {
stop("Reference build completeness validation failed: ",
paste(problems, collapse = "; "), call. = FALSE)
}
evidence <- attr(build$assessments, "evidence", exact = TRUE)
split_manifest <- evidence$split_manifest
if (!is.null(split_manifest) && nrow(split_manifest)) {
overlap <- split_manifest[, data.table::uniqueN(split),
by = .(artifact, source, group_id)][V1 > 1L]
if (nrow(overlap)) {
stop("Assessment group leakage remains after split construction",
call. = FALSE)
}
}
invisible(build)
}
.lib_selected_lambda_converged <- function(model) {
# Full training checkpoints retain the explicit diagnostic. Release models
# intentionally omit assessment-only fields, but the retained one-lambda
# glmnet object still carries jerr, which is sufficient to validate it when
# a completed release is used as the input to a later assessment rebuild.
if (!is.null(model$selected_lambda_converged)) {
return(isTRUE(model$selected_lambda_converged))
}
error <- model$model$jerr
is.numeric(error) && length(error) == 1L && !is.na(error) && error == 0L
}
.lib_runtime_open_specy_attributes <- function() {
c(
"names", "class", "intensity_unit", "intensity_units",
"derivative_order", "baseline", "spectra_type", "transformations",
"identification_range"
)
}
.lib_keep_attributes <- function(x, keep) {
object_attributes <- attributes(x)
attributes(x) <- object_attributes[intersect(keep, names(object_attributes))]
x
}
.lib_slim_reference_object <- function(x) {
if (!is_OpenSpecy(x)) {
stop("Release sanitization requires an OpenSpecy object", call. = FALSE)
}
x <- .lib_keep_attributes(x, .lib_runtime_open_specy_attributes())
x$wavenumber <- .lib_keep_attributes(x$wavenumber, "names")
x$spectra <- .lib_keep_attributes(
x$spectra,
c("dim", "dimnames", "names", "row.names", "class", ".internal.selfref")
)
x$metadata <- .lib_keep_attributes(
x$metadata, c("names", "row.names", "class", ".internal.selfref")
)
x
}
.lib_slim_reference_collection <- function(collection) {
lapply(collection, function(recipe) {
if (is_OpenSpecy(recipe)) return(.lib_slim_reference_object(recipe))
lapply(recipe, .lib_slim_reference_object)
})
}
.lib_runtime_model_fields <- function() {
c(
"model", "model_type", "lambda_selected", "dimension_conversion",
"coefficients", "class_names", "class_num", "observation_count", "fill",
"fill_method", "variable_num", "all_variables", "variables_in"
)
}
.lib_slim_glmnet_model <- function(model, lambda_selected) {
if (!inherits(model, "glmnet")) return(model)
if (!is.numeric(lambda_selected) || length(lambda_selected) != 1L ||
!is.finite(lambda_selected) || !length(model$lambda)) {
stop("A finite selected lambda is required to slim a glmnet model",
call. = FALSE)
}
selected <- which.min(abs(model$lambda - lambda_selected))
subset_path <- function(value) {
if (is.null(value)) return(NULL)
if (is.list(value) && !inherits(value, "Matrix")) {
return(lapply(value, subset_path))
}
if (length(dim(value)) == 2L) return(value[, selected, drop = FALSE])
value[selected]
}
model$a0 <- subset_path(model$a0)
model$beta <- subset_path(model$beta)
model$dfmat <- subset_path(model$dfmat)
model$df <- subset_path(model$df)
model$lambda <- as.numeric(lambda_selected)
model$dev.ratio <- subset_path(model$dev.ratio)
if (length(model$dim) >= 2L) model$dim[[2L]] <- 1L
keep <- c(
"a0", "beta", "dfmat", "df", "dim", "lambda", "dev.ratio",
"nulldev", "npasses", "jerr", "offset", "classnames", "nobs"
)
out <- model[intersect(keep, names(model))]
class(out) <- class(model)
out
}
.lib_slim_model <- function(model) {
if (is.null(model)) return(NULL)
fields <- intersect(.lib_runtime_model_fields(), names(model))
out <- model[fields]
if (inherits(out$model, "glmnet")) {
out$model <- .lib_slim_glmnet_model(out$model, out$lambda_selected)
}
if (is_OpenSpecy(out$fill)) out$fill <- .lib_slim_reference_object(out$fill)
out
}
.lib_slim_model_collection <- function(models) {
lapply(models, function(recipes) {
lapply(recipes, function(types) lapply(types, .lib_slim_model))
})
}
.lib_release_index <- function(signature, artifact_manifest) {
files <- stats::setNames(
basename(as.character(artifact_manifest$path)),
as.character(artifact_manifest$component)
)
structure(
list(
schema = "OpenSpecy_reference_build_index_v1",
build_signature = signature,
artifacts = files,
assessments = "assessments.rds",
release_manifest = "release_manifest.rds"
),
build_signature = signature
)
}
.lib_is_release_index <- function(x) {
is.list(x) && identical(x$schema, "OpenSpecy_reference_build_index_v1") &&
is.character(x$artifacts) && length(x$artifacts) > 0L
}
.lib_load_release_index <- function(index, directory) {
artifact_path <- function(component) {
file <- index$artifacts[[component]]
if (is.null(file) || !nzchar(file)) return(NULL)
path <- file.path(directory, file)
if (!file.exists(path)) {
stop("Release index artifact is missing: ", path, call. = FALSE)
}
path
}
load_artifact <- function(component) {
path <- artifact_path(component)
if (is.null(path)) NULL else readRDS(path)
}
libraries <- stats::setNames(lapply(
intersect(c("raw", "derivative", "nobaseline"), names(index$artifacts)),
load_artifact
), intersect(c("raw", "derivative", "nobaseline"), names(index$artifacts)))
medoid_components <- intersect(
c("medoid_derivative", "medoid_nobaseline"), names(index$artifacts)
)
medoids <- stats::setNames(
lapply(medoid_components, load_artifact), sub("^medoid_", "", medoid_components)
)
models <- list(logistic_regression = list(), random_forest = list())
for (algorithm in names(models)) {
prefix <- paste0("model_", algorithm, "_")
components <- names(index$artifacts)[startsWith(names(index$artifacts), prefix)]
recipes <- sub(paste0("^", prefix), "", components)
models[[algorithm]] <- stats::setNames(lapply(components, load_artifact), recipes)
}
models <- Filter(length, models)
assessments_path <- file.path(directory, index$assessments)
assessments <- if (file.exists(assessments_path)) readRDS(assessments_path) else NULL
quarantine <- if ("quarantined_spectra" %in% names(index$artifacts)) {
load_artifact("quarantined_spectra")
} else NULL
list(
libraries = libraries, medoids = medoids, models = models,
assessments = assessments, quarantine = quarantine
)
}
.lib_promote_reference_build <- function(build, output_dir, signature, reuse,
progress = NULL, quarantine = NULL) {
release_dir <- file.path(output_dir, "releases", substr(signature, 1L, 12L))
dir.create(release_dir, recursive = TRUE, showWarnings = FALSE)
prior_manifest <- NULL
manifest_path <- file.path(release_dir, "release_manifest.rds")
if (isTRUE(reuse) && file.exists(manifest_path)) {
candidate_manifest <- tryCatch(
readRDS(manifest_path), error = function(error) NULL
)
if (inherits(candidate_manifest, c("data.frame", "data.table")) &&
identical(
attr(candidate_manifest, "build_signature", exact = TRUE),
signature
)) {
prior_manifest <- data.table::as.data.table(candidate_manifest)
}
}
release_libraries <- .lib_slim_reference_collection(build$libraries)
release_medoids <- .lib_slim_reference_collection(build$medoids)
release_models <- .lib_slim_model_collection(build$models)
model_artifacts <- list()
for (algorithm in names(release_models)) {
for (recipe in names(release_models[[algorithm]])) {
model_artifacts[[paste("model", algorithm, recipe, sep = "_")]] <-
release_models[[algorithm]][[recipe]]
}
}
logistic <- release_models$logistic_regression
if (!is.null(logistic)) {
for (recipe in intersect(c("derivative", "nobaseline"), names(logistic))) {
# Retain the historical filenames consumed by get_lib() and older clients.
model_artifacts[[paste0("model_", recipe)]] <- logistic[[recipe]]
}
}
artifacts <- c(
release_libraries,
setNames(release_medoids, paste0("medoid_", names(release_medoids))),
model_artifacts
)
if (!is.null(quarantine)) {
quarantine$manifest$release_signature <- signature
artifacts[["quarantined_spectra"]] <- quarantine
}
manifest <- lapply(names(artifacts), function(name) {
path <- file.path(release_dir, paste0(name, ".rds"))
if(is.function(progress)) {
progress(paste0("verifying/promoting release artifact: ", name))
}
verified <- .lib_verify_manifested_release_artifact(path, prior_manifest)
if (!is.null(verified)) verified else .lib_promote_rds(artifacts[[name]], path)
})
list(
directory = release_dir,
manifest = data.table::rbindlist(manifest, fill = TRUE)
)
}
.lib_verify_manifested_release_artifact <- function(path, manifest) {
if (is.null(manifest) || !file.exists(path)) return(NULL)
component <- tools::file_path_sans_ext(basename(path))
normalized <- normalizePath(path, mustWork = TRUE)
manifest_paths <- normalizePath(
as.character(manifest$path), mustWork = FALSE
)
row <- manifest[
as.character(manifest$component) == component & manifest_paths == normalized
]
if (nrow(row) != 1L || !identical(row$checksum_algorithm, "sha256")) {
return(NULL)
}
info <- file.info(path)
checksum <- .lib_sha256_file(path)
if (!identical(as.numeric(row$size), as.numeric(info$size)) ||
!identical(as.character(row$checksum), checksum)) {
return(NULL)
}
readable <- tryCatch({
readRDS(path)
TRUE
}, error = function(error) FALSE)
if (!isTRUE(readable)) return(NULL)
data.table::data.table(
component = component, status = "verified_existing",
path = normalized, size = as.numeric(info$size),
checksum_algorithm = "sha256", checksum = checksum
)
}
.lib_is_lookup_spec <- function(x) {
is.list(x) && !inherits(x, c("data.frame", "data.table")) &&
all(c("lookup", "by") %in% names(x))
}
.lib_normalize_lookup_spec <- function(x) {
if (!.lib_is_lookup_spec(x)) {
return(list(lookup = x, by = NULL, fallback_by = NULL,
fill_only = FALSE))
}
extra <- setdiff(names(x), c("lookup", "by", "fallback_by", "fill_only"))
if (length(extra) > 0L) {
stop("Explicit metadata lookup specifications only accept 'lookup', ",
"'by', 'fallback_by', and 'fill_only'", call. = FALSE)
}
if (is.null(x$by) || !is.character(x$by) || length(x$by) < 1L ||
anyNA(x$by) || any(!nzchar(x$by))) {
stop("Explicit metadata lookup 'by' must contain column names",
call. = FALSE)
}
if (!is.null(x$fallback_by) &&
(!is.character(x$fallback_by) || length(x$fallback_by) != 1L ||
is.na(x$fallback_by) || !nzchar(x$fallback_by))) {
stop("Explicit metadata lookup 'fallback_by' must be one column name",
call. = FALSE)
}
if (is.null(x$fallback_by)) x$fallback_by <- NULL
if (is.null(x$fill_only)) x$fill_only <- FALSE
if (!is.logical(x$fill_only) || length(x$fill_only) != 1L ||
is.na(x$fill_only)) {
stop("Explicit metadata lookup 'fill_only' must be TRUE or FALSE",
call. = FALSE)
}
x
}
.lib_auto_lookup_key <- function(metadata, lookup_table) {
metadata <- data.table::as.data.table(metadata)
lookup_table <- data.table::as.data.table(lookup_table)
shared <- intersect(names(metadata), names(lookup_table))
if (length(shared) == 0L) {
return(list(shared = shared, candidates = character()))
}
usable <- vapply(shared, function(col) {
metadata_values <- .lib_key_values(metadata[[col]])
lookup_values <- .lib_key_values(lookup_table[[col]], unique = FALSE)
if (length(metadata_values) == 0L || length(lookup_values) == 0L) {
return(FALSE)
}
has_overlap <- any(metadata_values %in% unique(lookup_values))
unique_lookup_keys <- !anyDuplicated(lookup_values)
has_overlap && unique_lookup_keys
}, logical(1))
list(shared = shared, candidates = shared[usable])
}
.lib_key_values <- function(x, unique = TRUE) {
values <- as.character(x)
values <- values[!is.na(values)]
values <- values[nzchar(values, keepNA = FALSE)]
if (isTRUE(unique)) values <- unique(values)
values
}
.lib_read_lookup <- function(x) {
if (is.character(x) && length(x) == 1 && file.exists(x)) {
return(data.table::fread(x))
}
data.table::as.data.table(data.table::copy(x))
}
#' @rdname lib_metadata_name_lookup
#' @export
lib_clean_name <- function(x) {
x <- iconv(as.character(x), to = "ASCII", sub = "")
x <- tolower(trimws(x))
x <- gsub("%", "perc", x, fixed = TRUE)
x <- gsub("->", "_", x, fixed = TRUE)
x <- gsub("[^a-z0-9_]+", "_", x)
x <- gsub("_+", "_", x)
x <- gsub("^_+|_+$", "", x)
x[is.na(x) | x == ""] <- "column"
x
}
#' @rdname lib_metadata_name_lookup
#' @export
lib_clean_metadata <- function(x,
name_lookup = lib_metadata_name_lookup(),
clean_values = FALSE) {
if (!is.logical(clean_values) || length(clean_values) != 1L ||
is.na(clean_values)) {
stop("'clean_values' must be TRUE or FALSE", call. = FALSE)
}
metadata <- data.table::as.data.table(data.table::copy(x))
original_names <- names(metadata)
cleaned_names <- lib_clean_name(original_names)
canonical_names <- cleaned_names
rule_priority <- rep(Inf, length(cleaned_names))
lookup <- NULL
if (!is.null(name_lookup)) {
match_without_underscores <-
attr(name_lookup, "match_without_underscores", exact = TRUE)
match_singular_plural <-
attr(name_lookup, "match_singular_plural", exact = TRUE)
if (is.null(match_without_underscores)) match_without_underscores <- TRUE
if (is.null(match_singular_plural)) match_singular_plural <- TRUE
lookup <- data.table::as.data.table(data.table::copy(name_lookup))
.lib_require_cols(lookup, "canonical_name", "metadata name lookup")
if (!"source_name" %in% names(lookup)) {
lookup$source_name <- NA_character_
}
if (!"regex" %in% names(lookup)) lookup$regex <- NA_character_
lookup <- lookup[, c("canonical_name", "source_name", "regex"),
with = FALSE]
lookup$canonical_name <- lib_clean_name(lookup$canonical_name)
exact <- !is.na(lookup$source_name)
lookup$source_name[exact] <- lib_clean_name(lookup$source_name[exact])
empty_regex <- !is.na(lookup$regex) & lookup$regex == ""
lookup$regex[empty_regex] <- NA_character_
lookup <- unique(lookup)
has_source <- !is.na(lookup$source_name)
has_regex <- !is.na(lookup$regex)
if (any(has_source == has_regex)) {
stop("Each metadata name rule must contain exactly one of ",
"'source_name' or 'regex'", call. = FALSE)
}
exact_rules <- lookup[has_source, ]
source_groups <- split(exact_rules$canonical_name,
exact_rules$source_name)
ambiguous <- names(source_groups)[vapply(
source_groups,
function(value) length(unique(value)) > 1L,
logical(1)
)]
if (length(ambiguous) > 0) {
stop("Exact metadata name aliases map to multiple canonical names: ",
paste(ambiguous, collapse = ", "), call. = FALSE)
}
matched <- match(cleaned_names, exact_rules$source_name)
found <- !is.na(matched)
canonical_names[found] <- exact_rules$canonical_name[matched[found]]
exact_is_canonical <- exact_rules$source_name ==
exact_rules$canonical_name
rule_priority[found] <- ifelse(
exact_is_canonical[matched[found]],
matched[found],
2L * nrow(exact_rules) + matched[found]
)
smart_key <- function(value) {
if (isTRUE(match_without_underscores)) {
value <- gsub("_", "", value, fixed = TRUE)
}
if (isTRUE(match_singular_plural)) value <- sub("s$", "", value)
value
}
unresolved <- !found
if (any(unresolved) &&
(isTRUE(match_without_underscores) ||
isTRUE(match_singular_plural)) &&
nrow(exact_rules) > 0L) {
rule_keys <- smart_key(exact_rules$source_name)
name_keys <- smart_key(cleaned_names)
key_groups <- split(exact_rules$canonical_name, rule_keys)
ambiguous_keys <- names(key_groups)[vapply(
key_groups,
function(value) length(unique(value)) > 1L,
logical(1)
)]
conflicting <- which(unresolved & name_keys %in% ambiguous_keys)
if (length(conflicting) > 0L) {
details <- vapply(conflicting, function(i) {
canonical <- unique(key_groups[[name_keys[i]]])
paste0("'", original_names[i], "' -> ",
paste(canonical, collapse = ", "))
}, character(1))
stop("Automatic metadata name matching is ambiguous: ",
paste(details, collapse = "; "),
". Add an exact alias or disable the relevant smart matching ",
"option.", call. = FALSE)
}
smart_match <- match(name_keys[unresolved], rule_keys)
smart_found <- !is.na(smart_match)
unresolved_rows <- which(unresolved)
rows <- unresolved_rows[smart_found]
matched_rules <- smart_match[smart_found]
canonical_names[rows] <- exact_rules$canonical_name[matched_rules]
rule_priority[rows] <- ifelse(
exact_is_canonical[matched_rules],
nrow(exact_rules) + matched_rules,
3L * nrow(exact_rules) + matched_rules
)
found[rows] <- TRUE
}
regex_rules <- lookup[has_regex, ]
regex_matches <- vector("list", length(cleaned_names))
if (nrow(regex_rules) > 0L) {
pattern_hits <- lapply(seq_len(nrow(regex_rules)), function(i) {
tryCatch(
grepl(regex_rules$regex[i], cleaned_names, perl = TRUE),
warning = function(w) {
stop("Invalid metadata name regex '", regex_rules$regex[i],
"' for '", regex_rules$canonical_name[i], "': ",
conditionMessage(w), call. = FALSE)
},
error = function(e) {
stop("Invalid metadata name regex '", regex_rules$regex[i],
"' for '", regex_rules$canonical_name[i], "': ",
conditionMessage(e), call. = FALSE)
}
)
})
for (i in seq_along(cleaned_names)) {
regex_matches[[i]] <- which(vapply(
pattern_hits,
function(hit) isTRUE(hit[i]),
logical(1)
))
}
overlapping <- which(lengths(regex_matches) > 1L)
if (length(overlapping) > 0L) {
details <- vapply(overlapping, function(i) {
rows <- regex_matches[[i]]
rules <- paste0(
"'", regex_rules$regex[rows], "' -> '",
regex_rules$canonical_name[rows], "'"
)
paste0("'", original_names[i], "' matched ",
paste(rules, collapse = ", "))
}, character(1))
stop("Multiple metadata name regular expressions matched the same ",
"column: ", paste(details, collapse = "; "),
". Make the patterns mutually exclusive.", call. = FALSE)
}
regex_found <- !found & lengths(regex_matches) == 1L
for (i in which(regex_found)) {
row <- regex_matches[[i]]
canonical_names[i] <- regex_rules$canonical_name[row]
rule_priority[i] <- 4L * nrow(exact_rules) + row
}
}
}
output_names <- unique(canonical_names)
output <- lapply(output_names, function(canonical) {
positions <- which(canonical_names == canonical)
if (!is.null(lookup)) {
positions <- positions[order(
cleaned_names[positions] != canonical,
rule_priority[positions],
positions
)]
} else {
positions <- positions[order(cleaned_names[positions] != canonical,
positions)]
}
values <- lapply(positions, function(position) metadata[[position]])
signatures <- vapply(values, function(value) {
paste(typeof(value), paste(class(value), collapse = "/"), sep = ":")
}, character(1))
if (length(unique(signatures)) > 1L ||
any(vapply(values, is.factor, logical(1)))) {
values <- lapply(values, as.character)
}
result <- values[[1]]
if (length(values) > 1L) {
for (candidate in values[-1L]) {
fill <- is.na(result) & !is.na(candidate)
result[fill] <- candidate[fill]
}
}
result
})
names(output) <- output_names
output <- data.table::as.data.table(output)
if (isTRUE(clean_values)) output <- .lib_clean_metadata_values(output)
output
}
.lib_clean_metadata_values <- function(x) {
out <- data.table::as.data.table(data.table::copy(x))
for (col in names(out)) {
value <- out[[col]]
if (!is.character(value) && !is.factor(value)) next
value <- iconv(as.character(value), to = "ASCII", sub = "")
value <- tolower(trimws(value))
value[value %in% c("", "na", "null", "not available")] <- NA_character_
data.table::set(out, j = col, value = value)
}
out
}
.lib_coalesce_joined_metadata <- function(metadata, columns,
suffixes = c(".x", ".y"),
lookup_precedence = TRUE) {
metadata <- data.table::as.data.table(metadata)
for (col in columns) {
left <- paste0(col, suffixes[[1L]])
right <- paste0(col, suffixes[[2L]])
if (!all(c(left, right) %in% names(metadata))) next
result <- metadata[[left]]
replacement <- metadata[[right]]
use_replacement <- !is.na(replacement)
if (is.character(replacement)) {
use_replacement <- use_replacement & nzchar(replacement)
}
if (!isTRUE(lookup_precedence)) {
keep_existing <- !is.na(result)
if (is.character(result)) keep_existing <- keep_existing & nzchar(result)
use_replacement <- use_replacement & !keep_existing
}
result[use_replacement] <- replacement[use_replacement]
data.table::set(metadata, j = left, value = result)
data.table::setnames(metadata, left, col)
metadata[, (right) := NULL]
}
metadata
}
.lib_require_cols <- function(x, cols, label) {
missing <- setdiff(cols, names(x))
if (length(missing) > 0) {
stop("Missing ", label, " columns: ", paste(missing, collapse = ", "),
call. = FALSE)
}
}
.lib_alert_join_report <- function(report, require_complete) {
if (nrow(report) == 0) return(invisible(NULL))
summary <- report[, .(n = sum(n)), by = .(problem, column)]
msg <- paste(apply(summary, 1, function(x) {
paste0(x[["problem"]], " in ", x[["column"]], ": ", x[["n"]])
}), collapse = "; ")
if (require_complete) stop(msg, call. = FALSE)
warning(msg, call. = FALSE)
invisible(NULL)
}
.lib_ids <- function(x, id_col) {
if (id_col %in% names(x$metadata)) return(as.character(x$metadata[[id_col]]))
colnames(x$spectra)
}
.lib_filter_excluded <- function(x, exclude_ids, id_col = "sample_name") {
exclude_ids <- as.character(exclude_ids)
metadata_cols <- intersect(
c(id_col, paste0(id_col, "_old"), "col_id"),
names(x$metadata)
)
metadata_hit <- rep(FALSE, nrow(x$metadata))
for (col in metadata_cols) {
metadata_hit <- metadata_hit |
as.character(x$metadata[[col]]) %in% exclude_ids
}
spectra_hit <- colnames(x$spectra) %in% exclude_ids
keep <- !(metadata_hit | spectra_hit)
if (!all(keep)) x <- filter_spec(x, keep)
x
}
.lib_dedupe_existing_ids <- function(x, id_col = "sample_name",
duplicate = "first") {
if (!id_col %in% names(x$metadata)) return(NULL)
ids <- as.character(x$metadata[[id_col]])
if (length(ids) != ncol(x$spectra) ||
any(is.na(ids) | !nzchar(ids))) {
return(NULL)
}
x$metadata[[id_col]] <- ids
colnames(x$spectra) <- ids
x$metadata$col_id <- ids
keep <- rep(TRUE, length(ids))
if (duplicate == "first") keep <- keep & !duplicated(ids)
if (duplicate == "remove_all") {
keep <- keep & !(duplicated(ids) | duplicated(ids, fromLast = TRUE))
}
if (!all(keep)) x <- filter_spec(x, keep)
x
}
.pam_group_ids <- function(x, id_col, k, progress = FALSE,
group_label = "group", ...) {
x <- as_OpenSpecy(x)
ids <- .lib_ids(x, id_col)
if (ncol(x$spectra) <= k) return(ids)
if (ncol(x$spectra) > 3000L) {
return(.pam_large_group_ids(
x, id_col = id_col, k = k, progress = progress,
group_label = group_label, ...
))
}
correlation_started <- proc.time()[["elapsed"]]
if (isTRUE(progress)) {
message(sprintf(
"reduce_lib: correlation starting (%s; n=%d)",
group_label, length(ids)
))
}
cors <- cor_spec(x, x, compute = "optimized")
cors[is.na(cors)] <- 0
cors <- pmax(pmin(cors, 1), -1)
diag(cors) <- 1
distance <- stats::as.dist(1 - cors)
rm(cors)
if (isTRUE(progress)) {
message(sprintf(
"reduce_lib: correlation complete (%s; n=%d; %.1fs)",
group_label, length(ids),
proc.time()[["elapsed"]] - correlation_started
))
}
user_args <- list(...)
if (length(user_args) > 0L) {
if (is.null(names(user_args)) || any(names(user_args) == "")) {
stop("PAM arguments passed through '...' must be named",
call. = FALSE)
}
supported <- c(
"medoids", "nstart", "do.swap", "variant", "pamonce", "trace.lev"
)
unsupported <- setdiff(names(user_args), supported)
if (length(unsupported) > 0L) {
stop("Unsupported reduce_lib() PAM argument(s): ",
paste(unsupported, collapse = ", "), call. = FALSE)
}
}
pam_args <- list(
x = distance, k = min(k, length(ids) - 1L), diss = TRUE,
variant = "faster"
)
pam_args[names(user_args)] <- user_args
if ("pamonce" %in% names(user_args) && !"variant" %in% names(user_args)) {
pam_args$variant <- NULL
}
if (isTRUE(progress)) {
message(sprintf(
"reduce_lib: PAM starting (%s; n=%d; k=%d; variant=%s)",
group_label, length(ids), pam_args$k,
if (is.null(pam_args$variant)) "custom" else pam_args$variant
))
}
pam_started <- proc.time()[["elapsed"]]
seed_exists <- exists(".Random.seed", envir = .GlobalEnv, inherits = FALSE)
if (seed_exists) old_seed <- get(".Random.seed", envir = .GlobalEnv)
on.exit({
if (seed_exists) {
assign(".Random.seed", old_seed, envir = .GlobalEnv)
} else if (exists(".Random.seed", envir = .GlobalEnv, inherits = FALSE)) {
rm(".Random.seed", envir = .GlobalEnv)
}
}, add = TRUE)
set.seed(.lib_pam_seed(ids))
result <- do.call(cluster::pam, pam_args)
if (isTRUE(progress)) {
message(sprintf(
"reduce_lib: PAM complete (%s; n=%d; k=%d; %.1fs)",
group_label, length(ids), length(result$id.med),
proc.time()[["elapsed"]] - pam_started
))
}
ids[result$id.med]
}
.pam_large_group_ids <- function(x, id_col, k, progress = FALSE,
group_label = "group", samples = 5L,
sample_size = 1000L, ...) {
ids <- .lib_ids(x, id_col)
count <- length(ids)
sample_size <- min(count, max(as.integer(sample_size), 2L * k + 1L))
samples <- max(1L, as.integer(samples))
if (isTRUE(progress)) {
message(sprintf(
paste0("reduce_lib: sampled PAM starting ",
"(%s; n=%d; k=%d; samples=%d; sample_size=%d)"),
group_label, count, k, samples, sample_size
))
}
normalized <- .lib_prune_normalize(
x$spectra, x$wavenumber, c(Inf, Inf)
)
seed_exists <- exists(".Random.seed", envir = .GlobalEnv, inherits = FALSE)
if (seed_exists) old_seed <- get(".Random.seed", envir = .GlobalEnv)
on.exit({
if (seed_exists) {
assign(".Random.seed", old_seed, envir = .GlobalEnv)
} else if (exists(".Random.seed", envir = .GlobalEnv, inherits = FALSE)) {
rm(".Random.seed", envir = .GlobalEnv)
}
}, add = TRUE)
set.seed(.lib_pam_seed(ids))
best_indices <- integer()
best_objective <- Inf
for (sample_i in seq_len(samples)) {
sampled <- sort(sample.int(count, sample_size))
sampled_object <- filter_spec(x, sampled)
sampled_ids <- .pam_group_ids(
sampled_object, id_col = id_col, k = k, progress = FALSE,
group_label = group_label, ...
)
candidate_indices <- match(sampled_ids, ids)
correlations <- tcrossprod(
normalized, normalized[candidate_indices, , drop = FALSE]
)
correlations[!is.finite(correlations)] <- -1
correlations <- pmax(pmin(correlations, 1), -1)
objective <- sum(1 - matrixStats::rowMaxs(correlations))
if (objective < best_objective) {
best_objective <- objective
best_indices <- candidate_indices
}
rm(sampled_object, sampled_ids, correlations)
gc(verbose = FALSE, full = TRUE)
}
if (isTRUE(progress)) {
message(sprintf(
paste0("reduce_lib: sampled PAM complete ",
"(%s; n=%d; k=%d; objective=%.6g)"),
group_label, count, length(best_indices), best_objective
))
}
ids[best_indices]
}
.lib_pam_seed <- function(ids) {
hash <- digest::digest(as.character(ids), algo = "xxhash32")
as.integer(strtoi(substr(hash, 1L, 7L), base = 16L))
}
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.