--- title: "Version SHAs: The Three-SHA Identity Model" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Version SHAs: The Three-SHA Identity Model} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", eval = FALSE ) ``` > **Companion to**: [Getting Started](getting-started.html). Read this when > you've noticed that `datom_history()` shows a `version` column and a > `data_sha` column and want to know why there is more than one hash -- or > when `datom_write()` has told you a column is not hashable. datom identifies a table with **three SHAs**, each answering a different question: | SHA | Question it answers | Computed over | |---|---|---| | `data_sha` | *Is this the same table content?* | the table's values, canonically encoded (`datom-cv1`) | | `metadata_sha` | *Is this the same version?* | the table's semantic metadata, as canonical JSON | | `parquet_sha` | *Are the stored bytes intact?* | the exact parquet object that was uploaded | A fourth hash, `original_file_sha`, is recorded for tables onboarded from a file by `datom_sync()`. It is not part of table identity -- it is *file* provenance, and it is what lets a re-scan say "this input has not changed" without reading the file. The rest of this article explains each one, the type contract `data_sha` depends on, and the identity decisions baked into it. ## `data_sha`: the canonical content hash `data_sha` is a hash of the table's **values**, computed with the `datom-cv1` algorithm. Sketching the layout: ```r # per column, in table column order col_digest <- sha256(utf8(type_tag) || utf8(column_name) || 0x00 || payload) # then the table data_sha <- sha256("datom-cv1" || f64le(nrow) || f64le(ncol) || concat(col_digest_hex ...)) ``` Two properties matter more than the layout: - **No I/O and no parquet.** `data_sha` is computed from the in-memory data frame. It never writes a file, never calls arrow, and never coerces the container. - **The container is not identity.** A `tibble`, a `data.table`, and a `data.frame` holding the same values hash the same. Attributes that are not values (row names, `tbl_df` class, arbitrary `attr()`s) do not participate. ### Why not just hash the parquet bytes An earlier design hashed the serialized parquet file. That is simpler, and wrong for a versioning tool: parquet bytes move with the **writer**, not the data. An arrow upgrade, a different compression default, or a different dictionary-encoding heuristic all produce different bytes for identical content -- and every one of those would have minted a spurious new version. Hashing values instead of bytes decouples identity from the serializer. The serializer's bytes are still hashed, but under a different name and for a different purpose: that is `parquet_sha`, below. ## The datom table contract Hashing values means datom must decide, per column type, *what the bytes of that value are*. That decision cannot be guessed, so `datom-cv1` supports a defined set of types and refuses everything else with specific advice. Supported: `logical`, `integer`, `double`, `character`, `factor`, `Date` (including `data.table::IDate`), `POSIXct`, `difftime`/`hms`, `data.table::ITime`, `bit64::integer64`, and labelled vectors (`haven_labelled`, `labelled`, `labelled_spss`) over any of those. ### Where this bites Most clinical and analytical tables are already inside the contract. The cases that are not tend to arrive the same handful of ways: - **A `tidyr::nest()` result** carries a list column of data frames. - **`strptime()`** returns `POSIXlt`, not `POSIXct` (which is a list under the hood, and hashing a list of broken-down time parts is not the same question as hashing an instant). - **`sf`** geometry columns are `sfc` lists; **`units`** columns wrap a numeric with a unit attribute. - **A blob column** from a database round-trip is a list of raw vectors. None of these are errors in your data. They are values whose canonical byte form is a modelling decision, and datom asks you to make it explicitly rather than picking one silently. ### Check before you write `datom_check_hashable()` is the pre-flight entry point. It is pure -- no connection, no store, no network -- so you can run it on any data frame: ```{r, eval = TRUE} library(datom) clean <- data.frame( id = 1:3, score = c(1.5, 2.5, 3.5), grp = factor(c("x", "y", "x")), day = as.Date(c("2026-01-01", "2026-01-02", "2026-01-03")) ) datom_check_hashable(clean) ``` ```{r, eval = TRUE} messy <- data.frame(id = 1:2) messy$notes <- list(c("a", "b"), "c") messy$z <- c(1 + 2i, 3 + 4i) report <- datom_check_hashable(messy) report[report$status == "unsupported", c("column", "class")] ``` The report is returned invisibly as a data frame, so it composes: filter it, `kable()` it, or assert on it in a pipeline. `datom_write()` runs the same check itself, **before** it touches git or storage, and reports every offending column in one error rather than one per attempt. A refusal leaves no partial state behind. ### The recourse table Each refused type has one canonical piece of advice. The table below is generated by running `datom_check_hashable()` over one fixture per case, so it is the same text `datom_write()` aborts with -- the documentation cannot drift from the code. ```{r, eval = TRUE, echo = FALSE} one_col <- function(value) { d <- data.frame(placeholder = rep(NA, max(1L, length(value)))) d$col <- value d["placeholder"] <- NULL d } # One fixture per recourse case. datom dispatches on the class tag, so a bare # structure() carrying the class is enough to render the advice for the # package-specific cases without taking a dependency on sf / units / zoo. fixtures <- list( as.POSIXlt("2026-01-01", tz = "UTC"), structure(list(1, 2), class = "sfc"), list(data.frame(a = 1), data.frame(a = 2)), list(1:2, letters[1:2]), structure(c(1, 2), class = "units"), structure(c(2026.0, 2026.1), class = "yearmon"), c(1 + 2i, 3 + 4i), as.raw(1:2), structure(1:2, class = "myclass") ) report <- do.call(rbind, lapply(fixtures, function(v) { suppressMessages(datom_check_hashable(one_col(v))) })) knitr::kable( data.frame(Column_class = report$class, Recourse = report$recourse), col.names = c("Column class", "Recourse") ) ``` ### The ingestion allowlist `datom_sync()` onboards **flat tabular files only**: `csv`, `tsv`, `txt`, `psv`, `parquet`, `sas7bdat`, `xpt`, `sav`, `zsav`, `por`, `dta`, `xls`, `xlsx`. Anything else -- `.rds`, `.json`, `.xml` -- is refused up front. `datom_sync_manifest()` flags it as `unsupported_format` and still processes its allowlisted siblings; `datom_sync()` reports the same recourse in the `error` column for that row. The reason is the table contract: a container format can deserialize to anything, including the list and exotic columns the hash refuses. Refusing at the door keeps the failure legible ("this format is not onboarded") instead of deferring it to a type error deep in the write. **The escape hatch, and its tradeoff.** You can always read an unsupported source yourself and pass the resulting data frame to `datom_write()`: ```{r} dm <- readRDS("dm.rds") datom_write(conn, data = dm, name = "dm") ``` The rule to know: that table is **derived**, not imported. It has no `original_file_sha`, so input-file change detection does not apply to it. A re-run will not report "unchanged" from the file's bytes; it compares the data. If you need file-level change detection for that source, convert it to CSV or parquet once and let `datom_sync()` onboard it. ## The identity decisions Every entry below is a deliberate choice about what counts as "the same table". | Decision | Rationale | |---|---| | `logical`, `integer`, `double` unify (same encoding, same tag) | `1L` and `1` are the same number. Round-tripping a table through CSV, parquet, or a database routinely flips storage type without changing the data. | | Factor **levels** and **orderedness** are not identity | A factor's values are hashed as character. Re-leveling for a plot, or dropping unused levels, must not mint a new data version. | | Value **labels** are not identity | `haven_labelled` columns strip to their underlying type. A relabelled SAS import carries the same numbers. | | `POSIXct` **tzone** is not identity | The value hashed is the instant (epoch seconds). `tzone` is a display attribute; the same instant printed in two zones is one instant. | | All `NaN` payloads collapse to one canonical `NaN`; `-0` encodes as `+0` | `0/0` and `NaN` are indistinguishable arithmetically, and IEEE-754 leaves NaN payload bits and signed zero to the platform. Letting them differ would make identity platform-dependent. | | `NA_real_` stays distinct from `NaN` | "Missing" and "not a number" are different statements about the data, and R keeps them apart. | | Doubles are hashed **bit-exact** -- no rounding, no tolerance | A tolerance would make identity non-transitive (a ~ b and b ~ c but a != c). If a pipeline produces last-bit differences, that is a fact about the pipeline; round explicitly if you want it hidden. | | Unicode NFC and NFD forms differ | Normalizing would require an ICU dependency and a normalization-form decision baked into every future version. Byte-identical text is the narrow, stable promise. | | Row order and column order are **significant**; nothing is sorted | Sorting to make identity order-independent needs a collation order, which is locale-dependent -- reintroducing the platform dependence the rest of this design removes. A reordered table is a different table; `arrange()` deliberately if order should not matter. | **On the apparent asymmetry**: `NaN` payloads are canonicalized while NFD text is not, and storage types unify while row order does not. The line is drawn at **platform non-determinism**, not at "things a human would call equivalent". Where the same logical value can have different bytes for reasons outside the user's control (NaN payload bits, signed zero, integer-vs-double storage after a round-trip), datom canonicalizes -- otherwise identity would depend on the machine. Where a difference is a real, visible, user-controlled property of the data (row order, NFC vs NFD, secs vs mins), datom preserves it: collapsing those would need a policy decision that belongs to the user, and any policy datom picked would be wrong for someone. ## `metadata_sha`: the version `metadata_sha` is what `datom_history()` calls `version` and what you pass to `datom_read(version = ...)`. It hashes the table's semantic metadata: ```r volatile <- c("created_at", "datom_version", "parquet_sha", "column_hashes", "size_bytes") semantic <- metadata[setdiff(names(metadata), volatile)] sorted <- semantic[sort(names(semantic), method = "radix")] canonical <- jsonlite::toJSON(sorted, auto_unbox = TRUE) digest::digest(canonical, algo = "sha256", serialize = FALSE) ``` Three details are load-bearing: 1. **The JSON canonical form is hashed, not the R object.** R's `serialize()` is type-sensitive (`10L` and `10` differ); JSON-parsed metadata has lost that distinction. A reader pulling metadata from storage must compute the same `metadata_sha` as the writer who built it in memory, so the shared representation is the JSON text. `serialize = FALSE` is what makes `digest()` hash those bytes. 2. **`method = "radix"` sorts field names by byte value.** Plain `sort()` is locale-collated, so two machines with different `LC_COLLATE` would order fields differently and produce different versions for identical metadata. 3. **The volatile set is excluded on purpose.** `created_at` and `datom_version` are wall-clock and package facts. `parquet_sha` and `size_bytes` move with the arrow version for identical content -- letting them in would undo the whole point of hashing values. `column_hashes` is a deterministic function of the same values that produce `data_sha`, so it carries no independent information. Conversely, `original_file_sha` and `hash_algo` **are** semantic: a new source file is a new version of the table's provenance, and a new hash algorithm is a new identity regime. ### The dedup guard is full-history When a version's `metadata_sha` already appears **anywhere** in `version_history.json`, no entry is appended -- the current pointer in `metadata.json` is still updated. This matters when you re-sync an older file that content-matches: without a full-history scan, that write would append a second entry with a `version` value already present, and a later `datom_read(version = ...)` would report the SHA as ambiguous. ## `parquet_sha`: stored-object integrity `parquet_sha` is the SHA-256 of the exact parquet object in storage. It is not identity -- it is a tamper and corruption check: - On read, the downloaded object is hashed and compared **before** parsing. A mismatch aborts with the key, the expected hash, and the actual one; it never hands you data it cannot vouch for. - When a write is `metadata_only` (the content is unchanged), the current `parquet_sha` is **carried forward** and nothing is uploaded. - When a write reverts to content already in history, the recorded `parquet_sha` is **reused** and the stored object is left alone. Re-serializing can differ byte-for-byte, which would break the older version's integrity pin for no benefit. - Metadata written before this hash existed has no `parquet_sha`, and the check is **skipped** rather than failed -- a read-time grace, not a migration. ## Change classification Every `datom_write()` (including the one inside `datom_sync()`) lands in exactly one of three outcomes: | Outcome | When | Effect | |---|---|---| | `none` | `data_sha` and `metadata_sha` both unchanged | no commit, no upload, no history entry | | `metadata_only` | same `data_sha`, different `metadata_sha` (e.g. a re-export with new `original_file_sha`, or new parents) | new version, `parquet_sha` carried forward, **no upload** | | `full` | `data_sha` changed | new version, parquet stored at `{name}/{data_sha}.parquet` | The `metadata_only` case is the one that pays for the whole design: an EDC system re-exporting the same data with a new timestamp in the header produces different file bytes and identical content. datom records the new provenance and stores nothing twice. ## The column index Alongside `data_sha`, metadata carries `column_hashes`: an ordered array of `{name, sha}`, one entry per column, in table column order, untruncated. It is the same per-column digest that fed `data_sha`, computed once and reused. Two things follow. `data_sha` can be re-derived from the index plus the dimensions without downloading any data, which makes it cheaply self-checkable. And two versions that differ in exactly one column differ in exactly one entry -- the anchor a future column-level diff builds on. ## How far the cross-language claim goes - **Within R, pinned with `renv`: guaranteed.** The goldens in the test suite pin the byte layout across the supported platform matrix. - **The specification is language-implementable.** `datom-cv1` is defined in terms of bytes -- UTF-8 tags, little-endian IEEE-754 doubles, NUL-terminated strings, SHA-256 -- not R semantics. `dev/datom_cv1_reference.R` is a standalone base-R + `digest` reference implementation that the package is tested byte-for-byte against, and it doubles as the spec any other language would implement. What is **not** claimed is that an arbitrary Python or Julia reader will agree by accident; agreement requires implementing the same encodings, starting with that reference. - **Raw-file onboarding identity is language-independent today.** `original_file_sha` is a SHA-256 of the file's bytes, so any tool in any language computes it identically. Change detection on inputs therefore does not depend on the hashing algorithm at all. ### One caveat that is not ours to fix `data_sha` is a portable identity of *doubles*. Getting from **text to doubles** is a separate step, and in base R it is neither correctly rounded nor platform-independent. R does its own decimal parsing (`R_strtod` in `src/main/util.c`): it accumulates digits into a `long double` and then scales by a power of ten. So the result depends on how wide `long double` is on the build -- and `as.numeric()`, decimal literals, `scan()` and `read.csv()` all go through it. This is known, accepted R behaviour rather than a bug: CRAN runs a dedicated check flavour with R configured `--disable-long-double` precisely because results differ, and package authors are told to tolerate it. Check your own platform in one line: ```{r, eval = TRUE} .Machine$sizeof.longdouble # 8 means long double == double ``` `8` means no extra precision, and decimal parsing on that build deviates from the correctly-rounded value. It is **not** an Apple-versus-Intel split: | Build | `long double` | Decimal parsing | |---|---|---| | x86_64 (Linux, Windows, Intel mac) | 80-bit extended | agrees with correct rounding on everything tested | | aarch64 **Linux**, s390x, riscv64 | 128-bit quad | agrees | | **Apple silicon macOS**, 32-bit ARM | same as `double` | **deviates** | | any build with `--disable-long-double` (CRAN's noLD flavour) | forced to `double` | **deviates** | Note that Linux and macOS on the *same* arm64 chip family fall on opposite sides -- the variable is the `long double` width, not the architecture. **When it bites.** Deviation is possible whenever `mantissa * 10^exponent` cannot be reached by a single correctly-rounded operation at the platform's `long double` precision -- that is, when the accumulated mantissa exceeds the significand, or `10^|exponent|` is not itself exactly representable. `10^22` is the largest power of ten exactly representable as a double, and `10^23` the first that is not, which is why on Apple silicon a bare `1e-23` is the first one-digit value to drift. Longer mantissas drift sooner: `3.14159265358979e-9` is already 1 ULP off, and `1e300` / `1e-300` are 4. Short decimals -- `0.1`, `1`, `1e15`, `3.14159265358979` -- are exact and agree everywhere, because only a single rounding occurs. **The consequence for datom.** Two machines whose builds fall on opposite sides of that table can parse *different doubles* from the same CSV text, and will then compute different `data_sha` values -- both correct, for genuinely different tables. No hash can paper over it; the divergence happens before datom sees the data. Mitigations, in order of preference: - **Read via `arrow`, or store as parquet.** Arrow's CSV reader uses `fast_float`, which is correctly rounded and platform-independent by design; parquet stores the doubles themselves, so no decimal parsing happens at all. This is the recommendation. - **Keep the ingesting platform fixed** for a given table if you must go through base-R CSV reading. - **For exact literals in code**, use C99 hex-float notation -- `0x1.999999999999ap-4` is exactly `0.1`, and `0x1.fcp+996` a large value with no base-10 rounding anywhere. Documented in `?NumericConstants` and portable. One warning: `data.table::fread()` is **not** a fix. It has its own parser that is also not correctly rounded, and its errors differ from base R's -- the two can disagree on the same string. Worth keeping in perspective: this is a property of the platform's decimal parser, not of datom, and it affects any analysis reading that CSV with or without datom. What datom adds is that the difference becomes *visible* instead of silent. ## What you can rely on - A version is uniquely identified by its `metadata_sha`, and that SHA never appears twice in a table's history. - The same content under different provenance shares one `data_sha` and one stored parquet object, with a distinct `metadata_sha` per version. - Identical writes are no-ops at every layer: no commit, no upload, no history entry. - A reader on another machine, in another locale, computes the same `metadata_sha` as the writer. - Data you read back is the data that was written, or the read fails loudly.