R/040_DataFingerprint.R

Defines functions warn_fingerprint_mismatch fingerprint_diff same_data .hash_table .hash_object .serialization_offset .hash_file .hash_bytes data_fingerprint hash_data_structure hash_encoding

Documented in data_fingerprint same_data

# 040_DataFingerprint.R
# ::rtemis::
# 2026- EDG rtemis.org

# Dataset identity for provenance records: what data a run actually used, so
# that across a batch of experiments a user can tell whether runs shared an
# input or drifted undocumented. A path alone does not establish this -- the
# file at that path can change -- so a `DataFingerprint` pairs a content hash
# with the cheap structural facts (dimensions, column names) that make a mismatch
# *diagnosable*.
#
# Three hash methods, answering three different questions:
# - "file"    raw bytes of the source file. Answers "the same file?", and is
#             sensitive to changes that do not affect the data (line endings,
#             trailing whitespace).
# - "object"  the serialized R object. The default: cheap, always available,
#             but sensitive to R-internal representation: the container, the
#             order attributes are stored in, factor level order, integer vs
#             double storage, and whether a column is still an ALTREP compact
#             sequence or has been materialized. Two tables that are
#             `identical()` can differ.
# - "table"   canonical logical content via Arrow IPC. Strongest and most
#             portable, but requires the suggested `arrow` package.
#
# `method` is the question asked; `encoding` is the answer's byte recipe. Two
# digests are comparable only when both `encoding` and `algorithm` match: the
# recipe must be the same, and so must the function applied to it. See
# `DATA_HASH_ENCODINGS`.
#
# `language` and `data_structure` record what produced the digest, measured like
# every other property here. They make a difference *diagnosable* -- two "object" digests over one dataset held as a
# data.frame and as a data.table differ, and the pair of values says so.

# %% Constants ----
# Hash methods, ordered weakest to strongest guarantee.
DATA_HASH_METHODS <- c("file", "object", "table")

# Hash algorithms accepted for `DataFingerprint@algorithm`, each dispatched to
# its `openssl` implementation by `.hash_bytes()`. Adding a name here without
# adding its branch there is caught by the switch's own error.
DATA_HASH_ALGORITHMS <- c(
  "sha256",
  "sha512",
  "sha384",
  "sha224",
  "sha3-256",
  "sha3-512",
  "blake2b",
  "blake2s",
  "sha1",
  "md5"
)

# Pinned so that "object" hashes are reproducible: the bytes `serialize()`
# produces depend on the serialization format version, so leaving this to the
# default would make those hashes irreproducible across R versions that change
# it. Changing this value is a BREAKING change to fingerprint comparability.
DATA_HASH_SERIALIZE_VERSION <- 3L

# The byte recipe each method produces, keyed by the method.
#
# `method` says what kind of identity was asked for -- the same file, the same
# object, the same logical table -- and means that in any implementation.
# `encoding` names the exact byte recipe, which is what two digests must share
# to be comparable at all: "object" is a family, not a definition, so an R
# fingerprint and a Python one would both say "object" while hashing entirely
# different bytes. Comparing encodings reports that case as not comparable.
#
# A token carries its format version where the format has one, so bumping
# `DATA_HASH_SERIALIZE_VERSION` renames the encoding and the fingerprints it
# affects report as not comparable. Which ones it
# affects is not obvious: v2 and v3 payloads coincide for a plain data.frame
# and diverge for any ALTREP column, which v3 writes compactly -- exactly the
# kind of silent, input-dependent change the token exists to make visible.
DATA_HASH_ENCODINGS <- c(
  file = "file-bytes",
  object = paste0("r-serialize-v", DATA_HASH_SERIALIZE_VERSION),
  table = "arrow-ipc"
)

# The language this build hashes in, recorded on every fingerprint it writes.
# A constant here and a stored property there: a fingerprint read back from
# another implementation carries that implementation's value, so the field is
# carried on the wire.
DATA_HASH_LANGUAGE <- "R"

# Characters of the hash shown in `repr()`. Enough to compare at a glance.
DATA_HASH_DISPLAY_CHARS <- 12L


# %% DataFingerprint ----
#' DataFingerprint Class
#'
#' @description
#' Identity of a dataset used by a run: a content hash plus the structural facts
#' needed to diagnose a mismatch. Created by `data_fingerprint()`.
#'
#' Every property but `method`, `algorithm` and `source` is measured from the
#' data, so this class is documented here with `@field`; it has no `setup_*`
#' function.
#'
#' @field method Character \{"file", "object", "table"\}: What was hashed.
#' @field algorithm Character \{"sha256", "sha512", "sha384", "sha224", "sha3-256", "sha3-512", "blake2b", "blake2s", "sha1", "md5"\}: Hash algorithm.
#' @field encoding Character: The exact byte recipe hashed, named so that it
#'   identifies itself outside R. Two digests are comparable only if they share
#'   one.
#' @field hash Character: Hash digest, as hex.
#' @field n_rows Integer [0, Inf): Number of rows (cases).
#' @field n_cols Integer [0, Inf): Number of columns.
#' @field column_names Optional Character vector: Column names, in order.
#' @field source Optional Character: Path the data was read from.
#' @field language Character: The language that computed the hash.
#' @field data_structure Character: The form the data was held in when hashed.
#'
#' @author EDG
#' @noRd
DataFingerprint <- schema_class(
  name = "DataFingerprint",
  package = "rtemis",
  properties = list(
    method = prop_string(
      "object",
      enum = DATA_HASH_METHODS,
      description = "What was hashed: the source file's bytes, the serialized R object, or the canonical Arrow table."
    ),
    algorithm = prop_string(
      "sha256",
      enum = DATA_HASH_ALGORITHMS,
      description = "Hash algorithm, recorded so a hash is verifiable rather than merely comparable."
    ),
    # Stored, because a computed property is absent from the wire and a foreign
    # reader needs this field: it
    # is recoverable from `method` only by an implementation that already knows
    # which language wrote the record. Left free-form for the same reason -- a
    # token from another implementation must be readable here, not rejected,
    # and an unrecognized one correctly means "not comparable".
    encoding = prop_string(
      "",
      description = "The exact byte recipe hashed. Two digests are comparable only if they share one."
    ),
    # No meaningful default: a fingerprint without a hash is not a fingerprint,
    # so the class validator rejects the empty default and bare construction
    # fails with an informative message.
    hash = prop_string("", description = "Hash digest, as hex."),
    n_rows = prop_integer(
      0L,
      min = 0L,
      description = "Number of rows (cases)."
    ),
    n_cols = prop_integer(
      0L,
      min = 0L,
      description = "Number of columns."
    ),
    column_names = prop_string(
      NULL,
      nullable = TRUE,
      vector = TRUE,
      description = "Column names, in order."
    ),
    source = prop_string(
      NULL,
      nullable = TRUE,
      description = "Path the data was read from. Unset for in-memory input."
    ),
    # Recorded, not asserted. Whether a second implementation reproduces a
    # digest is a question a specification answers, and none exists; what can be
    # stated without one is what this build actually did. Free-form for the same
    # reason `encoding` is: a value written elsewhere must be readable here.
    language = prop_string(
      "",
      description = "The language that computed the hash."
    ),
    # Names the container, which is the first thing to look at when two "object"
    # digests over one dataset disagree. It does not account for every such
    # difference -- two plain data.frames can hash differently when their
    # attributes are stored in a different order -- so it narrows a mismatch
    # without explaining it. Under "table" it is recorded but cannot be the
    # cause: that encoding hashes one canonical form whatever the container.
    data_structure = prop_string(
      "",
      description = "The form the data was held in when hashed."
    )
  ),
  rules = list(
    NonEmptyStrings(
      id = "datafingerprint.identity",
      properties = c("hash", "encoding", "language", "data_structure"),
      message = "hash, encoding, language, and data_structure must be non-empty."
    ),
    PresenceRule(
      id = "datafingerprint.file-source",
      property = "source",
      when = SchemaPredicate(property = "method", equals = "file"),
      message = "source must be set when method is file."
    ),
    LengthMatches(
      id = "datafingerprint.column-names",
      values = "column_names",
      count = "n_cols",
      message = "column_names must have one entry per column (n_cols) when set."
    )
  ),
  publication = SchemaPublication(
    role = "document",
    slug = "datafingerprint",
    title = "rtemis DataFingerprint",
    description = "Identity of one dataset: a content hash plus the structural facts that make a mismatch diagnosable rather than merely detectable.",
    order = 3L,
    kind = "component"
  )
) # /rtemis::DataFingerprint


# %% hash_encoding ----
#' The byte recipe a hash method produces
#'
#' @param method Character: One of `DATA_HASH_METHODS`.
#'
#' @return Character: Encoding token.
#'
#' @author EDG
#' @keywords internal
#' @noRd
hash_encoding <- function(method) {
  if (!method %in% names(DATA_HASH_ENCODINGS)) {
    rtemis.core::abort(
      "Unknown hash method: ",
      method,
      ".",
      class = c("rtemis_value_error", "rtemis_input_error")
    )
  }
  unname(DATA_HASH_ENCODINGS[[method]])
} # /rtemis::hash_encoding


# %% hash_data_structure ----
#' The form data was held in when it was hashed
#'
#' The first class, not the whole vector: a tibble is `c("tbl_df", "tbl",
#' "data.frame")` and the parent classes it inherits are not the form it was
#' held in. Recorded to narrow a mismatch between two "object" digests, which
#' see the container.
#'
#' @param x tabular data.
#'
#' @return Character: Class name.
#'
#' @author EDG
#' @keywords internal
#' @noRd
hash_data_structure <- function(x) {
  class(x)[[1L]]
} # /rtemis::hash_data_structure


# %% data_fingerprint ----
#' Fingerprint a dataset
#'
#' Computes a `DataFingerprint`: a content hash of `x` plus the structural facts
#' (dimensions, column names) that make a mismatch between two runs diagnosable.
#' Used to record which data a run actually used, so undocumented drift between
#' experiments is visible.
#'
#' @param x tabular data: The dataset. Required for every method, so that
#'   dimensions and column names are always recorded, even when hashing file
#'   bytes.
#' @param method Character \{"file", "object", "table"\}: What to hash. "object"
#'   hashes the serialized R object (cheap, R-only). "file" hashes the raw bytes
#'   of `source`. "table" hashes the canonical Arrow IPC representation
#'   (cross-language; requires the `arrow` package), and is the only method
#'   under which one table held as a data.frame, a data.table, a tibble or a
#'   matrix gives one hash.
#' @param algorithm Character \{"sha256", "sha512", "sha384", "sha224",
#'   "sha3-256", "sha3-512", "blake2b", "blake2s", "sha1", "md5"\}: Hash
#'   algorithm. "blake2b" and "blake2s" are the fast options; "sha1" and "md5"
#'   are for interoperability with systems that record those.
#' @param source Optional Character: Path `x` was read from. Required when
#'   `method` is "file".
#'
#' @return `DataFingerprint` object.
#'
#' @author EDG
#' @export
#' @examples
#' fp <- data_fingerprint(iris)
#' fp
#' # A logically identical table in a different R container hashes differently
#' # under "object", which is what `@data_structure` records. FALSE:
#' data_fingerprint(iris)@hash ==
#'   data_fingerprint(data.table::as.data.table(iris))@hash
data_fingerprint <- function(
  x,
  method = "object",
  algorithm = "sha256",
  source = NULL
) {
  check_character(method, allow_null = FALSE)
  check_character(algorithm, allow_null = FALSE)
  if (!method %in% DATA_HASH_METHODS) {
    rtemis.core::abort(
      "`method` must be one of ",
      paste0("'", DATA_HASH_METHODS, "'", collapse = ", "),
      ".",
      class = c("rtemis_value_error", "rtemis_input_error")
    )
  }
  if (!algorithm %in% DATA_HASH_ALGORITHMS) {
    rtemis.core::abort(
      "`algorithm` must be one of ",
      paste0("'", DATA_HASH_ALGORITHMS, "'", collapse = ", "),
      ".",
      class = c("rtemis_value_error", "rtemis_input_error")
    )
  }
  hash <- switch(
    method,
    file = .hash_file(source, algorithm),
    object = .hash_object(x, algorithm),
    table = .hash_table(x, algorithm)
  )
  DataFingerprint(
    method = method,
    encoding = hash_encoding(method),
    algorithm = algorithm,
    hash = hash,
    n_rows = as.integer(NROW(x)),
    n_cols = as.integer(NCOL(x)),
    column_names = colnames(x),
    source = source,
    language = DATA_HASH_LANGUAGE,
    data_structure = hash_data_structure(x)
  )
} # /rtemis::data_fingerprint


# %% .hash_bytes ----
#' Hash a raw vector or a connection with the named algorithm
#'
#' The single point where an algorithm name becomes an implementation, so the
#' three hash methods cannot drift apart in which algorithms they accept.
#'
#' @param x Raw vector, or a connection to be streamed.
#' @param algorithm Character: One of `DATA_HASH_ALGORITHMS`.
#'
#' @return Character: Hash digest, as hex.
#'
#' @author EDG
#' @keywords internal
#' @noRd
.hash_bytes <- function(x, algorithm) {
  hasher <- switch(
    algorithm,
    sha256 = openssl::sha256,
    sha512 = openssl::sha512,
    sha384 = openssl::sha384,
    sha224 = openssl::sha224,
    `sha3-256` = function(x) openssl::sha3(x, size = 256L),
    `sha3-512` = function(x) openssl::sha3(x, size = 512L),
    blake2b = openssl::blake2b,
    blake2s = openssl::blake2s,
    sha1 = openssl::sha1,
    md5 = openssl::md5,
    rtemis.core::abort(
      "Unknown hash algorithm: ",
      algorithm,
      ".",
      class = c("rtemis_value_error", "rtemis_input_error")
    )
  )
  # `as.character()` on an `openssl` hash keeps its c("hash", "<algorithm>")
  # class. That class rides into `@hash` unnoticed -- `prop_string` checks the
  # type and a classed character is still a character -- and surfaces much later
  # as "No method asJSON S3 class: sha256" when a run record is written.
  # `as.vector()` drops it.
  as.vector(as.character(hasher(x)))
} # /rtemis::.hash_bytes


# %% .hash_file ----
#' Hash the raw bytes of a file
#'
#' Hashes a connection: `openssl` streams it in
#' chunks, so a file larger than memory is fingerprintable.
#'
#' @param path Character: File path.
#' @param algorithm Character: Hash algorithm.
#'
#' @return Character: Hash digest.
#'
#' @author EDG
#' @keywords internal
#' @noRd
.hash_file <- function(path, algorithm) {
  if (is.null(path)) {
    rtemis.core::abort(
      "`source` is required when `method` is 'file'.",
      class = c("rtemis_null_input", "rtemis_input_error")
    )
  }
  if (!file.exists(path)) {
    rtemis.core::abort(
      "File not found: ",
      path,
      ".",
      class = c("rtemis_value_error", "rtemis_input_error")
    )
  }
  con <- file(path, open = "rb")
  on.exit(close(con), add = TRUE)
  .hash_bytes(con, algorithm)
} # /rtemis::.hash_file


# %% .serialization_offset ----
#' Where the object starts in a serialization stream
#'
#' A serialization stream opens with a header describing the R that wrote it,
#' not the object: hashing it would make identical data fingerprint differently
#' on another R build or in another locale. A version-3 header is "X\\n", then
#' three 4-byte big-endian integers (format version, writing R version, minimum
#' reading R version), then a 4-byte length and that many bytes naming the
#' native encoding. Everything after that is the object.
#'
#' @param bytes Raw vector: A version-3 serialization stream.
#'
#' @return Integer: Number of leading bytes that describe the writer.
#'
#' @author EDG
#' @keywords internal
#' @noRd
.serialization_offset <- function(bytes) {
  encoding_nchar <- readBin(
    bytes[15L:18L],
    what = "integer",
    n = 1L,
    size = 4L,
    endian = "big"
  )
  18L + encoding_nchar
} # /rtemis::.serialization_offset


# %% .hash_object ----
#' Hash the serialized R object
#'
#' The serialization version is pinned (see `DATA_HASH_SERIALIZE_VERSION`) and
#' the header skipped (see `.serialization_offset()`), so the hash depends on
#' the object alone, whichever R hashed it.
#'
#' The header is skipped by seeking a connection over the stream. Dropping the
#' bytes gives the same digest, but `bytes[-seq_len()]` allocates a second copy of the whole stream, and negative indexing pays for
#' the complement of the index it was given: on an 80 MB frame that is around
#' 0.36 s and 80 MB to remove 23 bytes, which is most of the cost of
#' fingerprinting. `openssl` reads a connection from its current position, so
#' seeking costs neither.
#'
#' @param x R object.
#' @param algorithm Character: Hash algorithm.
#'
#' @return Character: Hash digest.
#'
#' @author EDG
#' @keywords internal
#' @noRd
.hash_object <- function(x, algorithm) {
  bytes <- serialize(
    x,
    connection = NULL,
    version = DATA_HASH_SERIALIZE_VERSION
  )
  con <- rawConnection(bytes, open = "rb")
  on.exit(close(con), add = TRUE)
  seek(con, .serialization_offset(bytes))
  .hash_bytes(con, algorithm)
} # /rtemis::.hash_object


# %% .hash_table ----
#' Hash the canonical Arrow IPC representation of a table
#'
#' Arrow's IPC stream is a language-neutral binary form, and the spec fixes how
#' the *data* is encoded: for one table, every record-batch message another
#' implementation writes is byte-identical to the one written here, across
#' double, int32, string, bool and dictionary columns, nulls included. That is
#' what makes a digest over this form reproducible outside R at all, and it is
#' what `.hash_object()` has no counterpart to.
#'
#' What the spec does not fix is the flatbuffer layout of the *schema* message,
#' and two implementations of it can differ while describing the same schema.
#' They do: `metadata <- NULL` here leaves `custom_metadata` present and empty,
#' while pyarrow's `replace_schema_metadata(None)` omits the field, which is 8
#' to 16 bytes of vtable and padding and so a different digest for the same
#' table. pyarrow reproduces these digests exactly when it writes an empty
#' metadata map (`replace_schema_metadata({})`) instead of dropping the field.
#'
#' So a second implementation reproduces these digests only by matching three
#' things, none of which follows from reading the Arrow spec alone. Nothing here
#' claims it will -- `@language` and `@data_structure` record what produced a
#' digest instead, and the rules below are what a specification would have to
#' pin down if one is ever written:
#'
#' - schema metadata is an *empty map*, not an absent field;
#' - one record batch, since chunk boundaries are message boundaries and a
#'   table split in two hashes differently from the same table held whole;
#' - the type mapping used here -- R integer to `int32`, character to `string`
#'   rather than `large_string`, factor to a dictionary of `string` with `int8`
#'   indices.
#'
#' A pyarrow reader coming from pandas matches none of the three by default:
#' `Table.from_pandas()` attaches a `pandas` metadata key, and infers `int64`
#' and `large_string` where this produces `int32` and `string`.
#'
#' @param x tabular data.
#' @param algorithm Character: Hash algorithm.
#'
#' @return Character: Hash digest.
#'
#' @author EDG
#' @keywords internal
#' @noRd
.hash_table <- function(x, algorithm) {
  check_dependencies("arrow")
  # `as_arrow_table()` has no method for a matrix, which the other two methods
  # take without complaint -- and `decomp()` fingerprints one. The conversion is
  # exact: a matrix and the data.frame of it produce the same Arrow table and so
  # the same digest, so the three methods stay interchangeable for a given
  # input.
  if (!is.data.frame(x)) {
    x <- as.data.frame(x)
  }
  tbl <- arrow::as_arrow_table(x)
  # arrow stores R attributes and class under the schema metadata key "r" so it
  # can round-trip data.table/tibble-ness. That metadata differs between a
  # data.frame and a data.table holding identical data, so it is cleared and
  # only the logical table is hashed.
  # `metadata` is an active binding of the R6 Table, set with `assign()`;
  # `[[<-` on a Table addresses a column of that name.
  # Clearing leaves an empty `custom_metadata` map, which is part of the byte
  # recipe another implementation matches; see the details above.
  assign("metadata", NULL, envir = tbl)
  buffer <- arrow::write_to_raw(tbl, format = "stream")
  .hash_bytes(buffer, algorithm)
} # /rtemis::.hash_table


# %% same_data ----
#' Do two fingerprints identify the same dataset?
#'
#' Two fingerprints are comparable only if they were produced the same way, so
#' this is FALSE when `encoding` or `algorithm` differ.
#'
#' @param x,y `DataFingerprint` objects.
#'
#' @return Logical.
#'
#' @author EDG
#' @export
#' @examples
#' same_data(data_fingerprint(iris), data_fingerprint(iris))
same_data <- function(x, y) {
  if (!S7_inherits(x, DataFingerprint) || !S7_inherits(y, DataFingerprint)) {
    rtemis.core::abort(
      "`x` and `y` must be DataFingerprint objects.",
      class = c("rtemis_type_error", "rtemis_input_error")
    )
  }
  # Compared by `encoding`: two implementations both call R's
  # `serialize()` and Python's `pickle` "object", so matching methods do not
  # make matching digests, while matching encodings do.
  identical(x@encoding, y@encoding) &&
    identical(x@algorithm, y@algorithm) &&
    identical(x@hash, y@hash)
} # /rtemis::same_data


# %% fingerprint_diff ----
#' Describe how two fingerprints differ, for an informative message
#'
#' A hash alone says "different"; the structural facts say *how*. Returns the
#' most specific difference available, so a warning can tell a user whether they
#' have different rows, a different shape, or renamed columns.
#'
#' @param x,y `DataFingerprint` objects.
#'
#' @return Character: One-line description, or `NULL` if the fingerprints match.
#'
#' @author EDG
#' @keywords internal
#' @noRd
fingerprint_diff <- function(x, y) {
  if (same_data(x, y)) {
    return(NULL)
  }
  if (
    !identical(x@encoding, y@encoding) || !identical(x@algorithm, y@algorithm)
  ) {
    return(paste0(
      "not comparable (",
      x@encoding,
      "/",
      x@algorithm,
      " vs ",
      y@encoding,
      "/",
      y@algorithm,
      ")"
    ))
  }
  if (!identical(x@n_rows, y@n_rows)) {
    return(paste0(
      "different number of rows (",
      x@n_rows,
      " vs ",
      y@n_rows,
      ")"
    ))
  }
  if (!identical(x@n_cols, y@n_cols)) {
    return(paste0(
      "different number of columns (",
      x@n_cols,
      " vs ",
      y@n_cols,
      ")"
    ))
  }
  if (!identical(x@column_names, y@column_names)) {
    return("same shape, different column names")
  }
  # Reached by elimination: comparable fingerprints, same shape, same column
  # names, different hash. Only "table" hashes a canonical form, so only there
  # does that isolate the contents. "object" and "file" hash a representation
  # too -- integer against double storage, factor level order, row order,
  # attribute order, line endings -- and each of those differs while the data
  # does not, so naming the values alone would send a reader hunting for a
  # change that never happened.
  if (identical(x@encoding, "arrow-ipc")) {
    return("same shape and column names, different values")
  }
  # The container is the cheapest of those representation differences to check
  # and the most common, so it is named when it differs. It narrows the search; it does not close it, since two frames of
  # the same class still differ by attribute order or storage mode.
  if (!identical(x@data_structure, y@data_structure)) {
    return(paste0(
      "same shape and column names, held as ",
      x@data_structure,
      " vs ",
      y@data_structure,
      " (which changes an ",
      x@encoding,
      " hash on its own)"
    ))
  }
  "same shape and column names, different values or representation"
} # /rtemis::fingerprint_diff


# %% warn_fingerprint_mismatch ----
#' Notify when a set of models was not trained on the same data
#'
#' Comparing metrics across models trained on different inputs can be a silent
#' failure -- the numbers look comparable and may not be. Every model that
#' carries a fingerprint is compared against the first; models without one
#' (trained before fingerprinting, or as a nested sub-model) are skipped.
#'
#' Printed as a message, never as an R warning or an error: comparing models
#' across different data is often deliberate -- feature selection, ablations,
#' differing preprocessing. It reports a fact; the user judges it.
#'
#' @param models List of `Supervised` / `SupervisedRes` objects.
#'
#' @return `TRUE` if a mismatch was reported, otherwise `FALSE`, invisibly.
#'
#' @author EDG
#' @keywords internal
#' @noRd
warn_fingerprint_mismatch <- function(models) {
  fps <- lapply(models, function(m) prop(m, "data_fingerprint"))
  named <- names(models)
  keep <- !vapply(fps, is.null, logical(1L))
  if (sum(keep) < 2L) {
    return(invisible(FALSE))
  }
  fps <- fps[keep]
  labels <- if (is.null(named)) {
    vapply(models[keep], function(m) m@algorithm, character(1L))
  } else {
    named[keep]
  }
  reference <- fps[[1L]]
  diffs <- character()
  for (i in seq_along(fps)[-1L]) {
    d <- fingerprint_diff(reference, fps[[i]])
    if (!is.null(d)) {
      diffs <- c(diffs, paste0("  ", labels[[i]], ": ", d))
    }
  }
  if (length(diffs) == 0L) {
    return(invisible(FALSE))
  }
  # A printed notice, deliberately NOT a real R warning: comparing models
  # trained on different data is often entirely legitimate (feature selection,
  # ablations, different preprocessing), so this must not be escalatable to an
  # error by `options(warn = 2)` in a valid workflow. It informs; it does not
  # accuse.
  rtemis.core::warn(
    "Models were not all trained on the same data. Compared to '",
    labels[[1L]],
    "':\n",
    paste(diffs, collapse = "\n")
  )
  invisible(TRUE)
} # /rtemis::warn_fingerprint_mismatch


# %% repr.DataFingerprint ----
#' repr DataFingerprint
#'
#' @author EDG
#' @keywords internal
#' @noRd
method(repr, DataFingerprint) <- function(x, pad = 0L, output_type = NULL) {
  paste0(
    repr_S7name("DataFingerprint", pad = pad, output_type = output_type),
    repr_ls(
      list(
        hash = paste0(
          substr(x@hash, 1L, DATA_HASH_DISPLAY_CHARS),
          "\u2026 (",
          x@algorithm,
          ", ",
          x@method,
          ")"
        ),
        dim = paste0(x@n_rows, " x ", x@n_cols),
        source = x@source,
        # One line: they are read together, and separately they invite being
        # read as a claim about who else could reproduce the digest.
        computed_by = paste0(x@language, " / ", x@data_structure)
      ),
      pad = pad,
      print_class = FALSE,
      output_type = output_type
    )
  )
} # /rtemis::repr.DataFingerprint


# %% print.DataFingerprint ----
#' Print DataFingerprint
#'
#' @param x `DataFingerprint` object.
#' @param pad Integer: Left padding.
#' @param output_type Optional Character: Output format.
#' @param ... Not used.
#'
#' @author EDG
#' @noRd
method(print, DataFingerprint) <- function(
  x,
  pad = 0L,
  output_type = NULL,
  ...
) {
  cat(repr(x, pad = pad, output_type = output_type))
  invisible(x)
} # /rtemis::print.DataFingerprint

Try the rtemis package in your browser

Any scripts or data that you put into this service are public.

rtemis documentation built on Oct. 20, 2026, 1:06 a.m.