rmoriebricklayer rmoriebricklayer hex logo

Analyze a published table, and be able to show later exactly which data the result came from.

What it is

rmoriebricklayer is for individuals who work from a variety of published and unpublished tables: a ministry’s open-data portal, Statistics Canada, a police oversight agency, or your own data. It does three things with such a table.

  1. It analyzes the table. anltab() takes counts by period and group and reports what changed between periods, with exact intervals, trends and rates, and says how far the publisher’s rounding and suppressed cells could move each figure.
  2. It pins the data the result came from. A capsule records where the table came from, the SHA-256 digest of the exact file, the columns and values it should have, and the address of an archived copy. Rerun later, rmbl verify says whether the source still matches, and rmbl drift says, column by column, what moved if it does not.
  3. It signs the record. A capsule can be signed (with a shared key, or with the post-quantum signature standards), so a reader can check who produced a result and that nothing changed afterwards.

The rest of the package (the statistics for administrative tables, the plots, the dashboard, the cryptography written from the standards) serves one of these three.

Why it exists

I analyze Canadian justice and health statistics from published tables. Noticed that three things kept on going wrong in many cases, and a checksum alone catches none of them.

How I use it

A table of people in Ontario’s adult correctional institutions (OTIS, shipped with the package), analyzed, plotted and written up:

library(rmoriebricklayer)
f <- system.file("extdata", "otis_a01_individuals.csv", package = "rmoriebricklayer")
otis <- read.csv(f)
a <- anltab(otis, value = "individuals", prd = "year", by = c("table", "group"), rnd = 5)
a                                              # what changed, how sure, what was withheld
plot(a)
rptanl(a, file.path(tempdir(), "otis.html"))   # one self-contained page

Next year, the question is whether the table behind those numbers is still the same:

pin <- sha256file(f)            # recorded with the result
identical(sha256file(f), pin)   # TRUE: the same bytes, so the same table

# a re-release that silently reports the counts in a different unit (tens)
moved <- otis
moved$individuals <- moved$individuals * 10
cpsdrft(otis, moved)$anydrft    # TRUE: the column's distribution moved
cpsdrft(otis, otis)$anydrft     # FALSE: an unchanged table raises no alarm

The digest catches any change to the file, however small. The distribution checks say whether the numbers moved as a whole; on a table of 15 rows they will not notice a change to two or three cells, which is why the capsule keeps both.

A capsule does this for every source of an analysis at once; usecpstmpl() starts one and vignette("capsules-and-provenance") walks through it.

What it does

Security posture

Everything cryptographic in this package is a hand-written implementation: ML-KEM, ML-DSA, SLH-DSA, HQC, XMSS, the CTR_DRBG, SHA-2, SHA-3, BLAKE2b, ECDSA and RSA verification, the ASN.1/DER parser and the X.509 / OCSP / timestamp stack. Each property below is measured in CI on every change, by a check you can run yourself; none is a claim about the design.

Property How it is checked Where
Correct Byte-identical to OpenSSL 3.5 (ML-KEM, ML-DSA, SLH-DSA) and to the NIST / reference known-answer vectors (HQC 300 KATs, CTR_DRBG CAVP, SHA-3, XMSS); SHA-2, HMAC, PBKDF2, BLAKE2b, CRC-32 against digest/openssl and the RFC vectors tests/testthat/test-fips-*.R, test-hqc*.R, test-xmss-kat.R, test-byte-parity.R
Rejects what a verifier must reject 1,247 Project Wycheproof vectors: ECDSA P-256/SHA-256 (484), P-384/SHA-384 (504), RSASSA-PKCS1-v1_5 2048/SHA-256 (259) – malformed DER, non-canonical integers, r or s out of range, points off the curve, every padding and DigestInfo variant. Every valid vector verifies, every invalid one is refused, and the legacy “acceptable” encodings are refused too tests/testthat/test-wycheproof.R
Constant time ctgrind: every operation that touches a secret runs under valgrind memcheck with the secret marked undefined, so a branch or a memory address depending on it is an error. 22 cases – ML-KEM 512/768/1024 (keygen, encaps, decaps and implicit rejection), ML-DSA 44/65/87 (keygen, sign), SLH-DSA (SHAKE and SHA-2 families), HQC 1/3/5 and round 4, XMSS, CTR_DRBG on the portable and the AES-NI path, HMAC, PBKDF2, keyed BLAKE2b, SHA-2, SHA-3, digest comparison – all clean with GCC and with Clang. The only values the schemes branch on are the ones their specifications publish (a rejected sample, the challenge, the hints, R), each declassified at one named line inst/ctcheck/, workflow constant-time
Secrets are wiped After each of those operations the harness scans the dead stack below it for copies of the seed, the signing key, the password: none remain same harness, ZEROISATION lines
Memory safe The whole test suite under AddressSanitizer and UndefinedBehaviorSanitizer (R-devel built with both) workflow sanitizers
Survives hostile arguments Every one of the 123 C++ entry points runs behind a generated try/catch barrier (a C++ exception is an R error, never std::terminate), and the 15 written in plain C (the file lock, its release and the locked call, the path probe, the CSV splitter, the checked writer, the checked gzip reader, the progress bar and spinner, the survey replicate totals, the survey row keys, their digest and set digest, the survey replicate column pass, and the interrupt check before a key prompt) report errors through R itself; argument types, lengths and sizes are checked before any allocation; quadratic loops and the RSA arithmetic are interruptible; the RSA exponent, SHAKE output, PBKDF2 iterations and SIU inputs are bounded. A C stack overflow is not an exception, so the SIU text passes over a whole document are loops, not regexes, and every line the extractors see is capped; a test runs ten hostile inputs through every one-string SIU entry point in a subprocess src/rmbl_barrier.cpp, inst/scripts/gen_barrier.R, tests/testthat/test-review2.R, tests/testthat/test-review3.R
No request to a private address One check in the transport: the authority parsed as a URL parser does, IPv4/IPv6 literals canonicalised (127.1, 0x7f000001, ::ffff:7f00:1), local names refused by suffix, every resolved address tested and the connection pinned to it (no DNS rebinding), redirects re-checked hop by hop, https never downgraded src/rmbl_fetch.cpp, tests/testthat/test-review2.R
Robust against hostile input libFuzzer with ASan + UBSan over the DER parser and RSA arithmetic, the decimal-to-double conversion (checked against the C library bit for bit on every input both accept), and the SIU report parsers; weekly long runs inst/fuzz/, workflow fuzz
Standards vectors The C2SP Wycheproof post-quantum sets and the NIST ACVP sets for ML-KEM, ML-DSA, SLH-DSA, SHA-3, SHAKE and HMAC: 6,848 checks, 0 failures, each not-applicable vector named with its reason (the published ACVP PBKDF set is SHA2-224 only, so none of its vectors apply to this package’s PBKDF2-HMAC-SHA256, which the test suite checks against the RFC 7914 vectors instead); the masked and the plain paths are compared on every ML-KEM decapsulation and ML-DSA signing vector tools/vectors/, workflow vectors
Agrees with OpenSSL on any input libFuzzer differential fuzzing against OpenSSL 3.5: ML-KEM, ML-DSA, SLH-DSA-SHA2-128f, the SHA-2, SHA-3 and SHAKE digests, BLAKE2b, HMAC and PBKDF2 must agree byte for byte inst/fuzz/fuzz_ossl.cpp, workflow fuzz
Timing on hardware dudect fixed-versus-random Welch t on x86-64 and arm64 Linux and on Apple silicon (with and without DIT): ML-KEM decapsulation plain and masked, valid against invalid with both classes varying, each phase alone, encapsulation, HQC decapsulation, the digest comparison, PBKDF2; an early-exit comparison is the control that must be flagged inst/dudect/, workflow dudect
Power, in simulation TVLA on the code compiled for a Cortex-M4 and run in an emulator, under the Hamming-weight and the Hamming-distance models: every masked kernel shows no first-order leakage under either, every unmasked control leaks inst/tvla/, workflow tvla
Masked by default First-order masking of ML-KEM decapsulation and ML-DSA signing, output identical to the unmasked computation. The share-combining gadgets run in assembly on Cortex-M, x86-64 and aarch64 (plain C elsewhere, with the same results) with a fixed register order, zeroed temporaries and a zero store between the two shares; code that handles one share at a time runs each share in its own pass with the registers zeroed in between. Shares are recombined for what the algorithm publishes: the shared secret, the signature and, during signing, the public high bits, the hint and the rejection decision (on rejected attempts too, as FIPS 204 reveals them) src/rmbl_masked.h
DER means DER The parser refuses BER: long-form lengths below 128, lengths with leading zeros, non-minimal tags, end-of-contents octets, trailing bytes; ECDSA-Sig-Value must be exactly two canonical INTEGERs and the PKCS#1 block exactly the RFC 8017 DigestInfo src/rmbl_asn1.cpp, R/x509.R, R/timestamp.R

Outside what these checks model: electromagnetic emanation, fault injection, glitches and coupling a leakage model omits, and higher-order attacks on two-share masking. The power assessment is a simulation, not a capture from a board. SECURITY.md has the threat model, the reporting address and the list of what each check would and would not catch. The checks are reproducible on any Linux machine with valgrind and clang: bash inst/ctcheck/build.sh && bash inst/ctcheck/run.sh and bash inst/fuzz/build.sh && bash inst/fuzz/run.sh.

Verification

Every hash, keyed hash, checksum, key derivation, base64 and JSON output is compared against an independent implementation — digest, openssl, jsonlite and base R’s own inflater — over a length sweep crossing each construction’s block boundaries, so the digest this package records for a set of bytes is the number anybody else would compute for them. Published vectors are checked too: SHA-512 (FIPS 180-4), HMAC-SHA-256 (RFC 4231), PBKDF2-HMAC-SHA256, BLAKE2b (RFC 7693) and CRC-32 (ITU V.42). The statistics are anchored on base R (stats::poisson.test, stats::glm, stats::cor.test, stats::qpois) or on closed forms recomputed by hand.

The worked example in examples/otis-mrp/ goes further than checking the package: it recomputes 147 published year-over-year tables across 29 datasets — 8,214 cells — from the source data and compares every one, alongside the descriptives, the matched sample and the causal estimates. It also checks what a cell-by-cell comparison cannot: three of those datasets reach the same population by different routes, so a wrong grain rule would move both sides of a cell comparison together and pass, while a01 distinct individuals against c01 and c04 totals fails. The datasets are not shipped — point OTIS_DATASETS_DIR at a copy you have, or set OTIS_YOY_DOWNLOAD=1 to fetch them from the province.

The compiled kernels are published for LinkingTo, and a consumer package is built and run against inst/include/rmoriebricklayer.h as part of the test suite — a signature mismatch is a compile error, while a misregistered name compiles cleanly and fails only when called.

The XMSS signature scheme is byte-compatible with the RFC 8391 reference implementation. The whole 2500-byte signature for XMSS-SHA2_10_256 – index, randomiser, WOTS+ signature and authentication path – matches it exactly, checked against embedded vectors in the test suite so the check needs no network.

The standardised schemes are byte-identical to OpenSSL. ML-DSA (FIPS 204) at all three parameter sets and SLH-DSA (FIPS 205) at all twelve – six over SHAKE, six over SHA-2 – are implemented here, with no system dependency. Every one of the fifteen is checked against OpenSSL 3.5: in deterministic mode the two implementations produce the SAME BYTES, over several message and context lengths, and each verifies the other’s signatures. OpenSSL’s keys and the digests of its signatures are embedded in the test suite, so the check needs no network and no system library.

That cross-check is the claim, not reference parity. This implementation matched the pq-crystals and sphincsplus reference code byte for byte while disagreeing with the standards in two places – FIPS 204 and FIPS 205 both prepend a context domain separator that the reference code omits, and FIPS 205 reads the FORS indices most significant bit first where SPHINCS+ read them least significant bit first. A signature scheme that verifies only its own output passes every security-property test there is, so only an independent implementation can find that class of bug.

ML-KEM (FIPS 203) is here too, at all three levels, along with the pre-hashed variants of both signature standards and ML-DSA’s external-mu interface. ML-KEM keys generated from the same seed agree with OpenSSL’s byte for byte, its ciphertexts decapsulate here to the secret it reports, and a corrupted ciphertext produces the same rejection secret in both – which is the check that catches a wrong compression width, since compressing and decompressing with the same wrong width round-trips perfectly.

HQC (Hamming Quasi-Cyclic), the code-based KEM NIST selected in March 2025 to stand beside ML-KEM, is here as well at HQC-1, HQC-3 and HQC-5 (hqckg(), hqcencap(), hqcdecap()), in two revisions. The default, v5, reproduces all 300 of the authors’ official known-answer vectors (specification of 2025-08-22, reference implementation v5.0.0), runs in constant time with respect to secrets (checked with valgrind: no secret reaches a branch, a memory index or a variable shift), gives the same bytes on big-endian machines (the reference code does not), and uses the carry-less multiply instruction on x86-64 and ARMv8 when the processor has it. version = "round4" is the fourth-round submission of 2023-04-30 — the revision liboqs (up to 0.12) and PQClean ship — with its 64-byte shared secret; it shares v5’s codes and parameters and reproduces all 300 of that revision’s official known-answer vectors on both multipliers. The two are not interchangeable: use round 4 to exchange keys with software built on it, v5 otherwise. A secret key can be kept as its seed alone: hqccmpkey() writes v5’s compressed format, dk = seed_KEM (32 bytes, defined by the v5 specification), or for round 4 the 96 to 112 bytes its key generation draws (a convention of this package: round 4 defines no compressed format), and hqcdecap() re-derives the key pair and refuses a seed that does not give the key’s own public half. NIST has named the HQC standard FIPS 207 but has not published it or a draft of it yet (checked 2026-10-05); v5 already carries the changes NIST listed for it, including the seed-only key, and the published standard may still differ in detail.

The random bit generator reproduces all 720 AES-256 no-derivation-function vectors of NIST’s DRBG validation suite (no reseed, reseed, and prediction resistance) on both AES paths, and seeded with the bytes 0..47 it is the randombytes() of NIST’s rng.c, the generator the post-quantum known-answer files were written with.

The JSON codec is checked against jsonlite itself, output byte for byte and parsed values exactly. The Hawkes likelihood, gradient and fit are checked on the same inputs as morie’s Python tests, so every Hawkes test is also a cross-language parity check; cunif() is the splitmix64 stream both draw from. The SIU parser’s police service agrees with the panel-reviewed English corpus on 2,147 of 2,163 French reports and 111 of 118 English ones.

Signing is fast enough to be tested unconditionally: an SLH-DSA s parameter set signs in about a second, down from seven, after the Keccak round was made branch-free, the tweakable hash stopped heap-allocating a few million times per signature, and the SHA-2 sets learned to resume from a cached midstate.

It is also verified against its security properties: a valid signature verifies, and every tampering of the message, signature, authentication path, index or key fails.

Algorithms

Several methods re-implement established ones and are tested against them: the design-based survey estimators (svydes(), svyest(), svycmp()) against the survey package’s svymean(), svytotal(), svyratio(), svyby() and svyciprop(); the Mann-Kendall trend test and Theil-Sen slope (trndtst()) against stats::cor.test()’s Kendall tau and its exact p-values; the Hawkes-process fits (chwkfit()) against the reference fits of MORIE’s Python package, which uses the same C++ core; and the hashes and signatures against digest, openssl and published test vectors (see Verification above). Others are, as far as we know, new in R: the bounds that a release’s rounding and suppression imply for its cells (pubbnd()) and the reproducible data capsules with signed manifests.

Life cycle

Since 1.0.0 the package is maturing: the exported function names and arguments are stable. A function or argument that is to change or go is first deprecated with a warning for one minor release, and the change is listed in NEWS.md.

Running the extended tests

R CMD check and devtools::test() never reach a real host. Setting RMBL_LIVE_TESTS=1 (exactly 1, together with NOT_CRAN=true) switches on the live tests in the same testthat suite: downloads from cloud.r-project.org and www.r-project.org, a redirect chain at httpbin.org, and Wayback Machine lookups at archive.org (never a capture request). They write only to temporary files and take about a minute on a broadband connection:

NOT_CRAN=true RMBL_LIVE_TESTS=1 Rscript -e 'devtools::test()'

Installation

Current release from r-universe (prebuilt binaries for macOS and Windows, a source build on Linux) or from CRAN (https://CRAN.R-project.org/package=rmoriebricklayer).

With pak (pak.r-lib.org) (progress bars, parallel downloads, compiler output hidden unless a build fails, and the named packages always upgraded to the current release). Keep the repos arguments as written: under Rscript there is no mirror chooser, so a bare install.packages() stops with “trying to use CRAN without setting a mirror”, and if CRAN carries older versions than r-universe.

if (!requireNamespace("pak", quietly = TRUE)) {
  install.packages("pak", repos = "https://cloud.r-project.org")
}
pak::repo_add(rootcoder007 = "https://rootcoder007.r-universe.dev")
pak::pkg_install("rmoriebricklayer")

From a terminal (single quotes outside, double quotes inside, so the shell passes the R code through untouched):

Rscript -e 'if (!requireNamespace("pak", quietly = TRUE)) install.packages("pak", repos = "https://cloud.r-project.org"); pak::repo_add(rootcoder007 = "https://rootcoder007.r-universe.dev"); pak::pkg_install("rmoriebricklayer")'

Without pak, plain install.packages() does the same with R’s own output:

Rscript -e 'install.packages("rmoriebricklayer", repos = c("https://rootcoder007.r-universe.dev", "https://cloud.r-project.org"))'

Development version from GitHub (a source build; needs a C/C++ toolchain):

Rscript -e 'if (!requireNamespace("pak", quietly = TRUE)) install.packages("pak", repos = "https://cloud.r-project.org"); pak::repo_add(rootcoder007 = "https://rootcoder007.r-universe.dev"); pak::pkg_install("rootcoder007/rmorie-bricklayer")'
# without pak
Rscript -e 'repos <- c("https://rootcoder007.r-universe.dev", "https://cloud.r-project.org"); install.packages("remotes", repos = repos); remotes::install_github("rootcoder007/rmorie-bricklayer", repos = repos, upgrade = "always")'

On macOS, CRAN’s R (from CRAN.R-project.org/bin/macosx) installs r-universe’s prebuilt binaries in seconds. Homebrew’s R cannot use them, so there every package is compiled from source.

Quick example

The shortest path, on the OTIS table that ships with the package:

library(rmoriebricklayer)
otis <- read.csv(system.file("extdata", "otis_a01_individuals.csv",
                             package = "rmoriebricklayer"))
a <- anltab(otis, value = "individuals", prd = "year",
            by = c("table", "group"), rnd = 5)
a                                   # what changed, how sure, what was withheld
rptanl(a, "otis.html")     # one self-contained file

The full capsule, step by step. It runs as written, offline: the provenance file pins the source, the schema and a recipe for a synthetic stand-in, and the stand-in is what gets validated and recorded when the portal is not reachable.

library(rmoriebricklayer)

# 1. The pin: portal endpoint, resource pattern, expected schema, recipe.
prov_path <- file.path(tempdir(), "data_provenance.json")
writeLines('{
  "dataset": {
    "title": "Demo library statistics",
    "ckan_api_endpoint": "https://data.ontario.ca/api/3/action/package_show?id=ontario-public-library-statistics"
  },
  "resource": { "name_match_pattern": "2014" },
  "schema": {
    "expected_columns": ["year", "visits", "alert"],
    "structural_invariants": { "min_data_rows": 5 },
    "expected_value_sets": { "year": [2024, 2025] },
    "synthetic_recipe": {
      "n_rows": 25, "seed": 42,
      "columns": {
        "year":   { "type": "sample",    "values": [2024, 2025] },
        "visits": { "type": "poisson",   "lambda": 3, "min": 1 },
        "alert":  { "type": "bernoulli", "p": 0.2 },
        "id":     { "type": "id_pattern", "pattern": "p-{seq:05d}" }
      }
    }
  }
}', prov_path)
prov <- loadprov(prov_path)

# 2. The data. With network: resolve the pinned endpoint to a current URL and
#    download (Wayback fallback). Without: a reproducible stand-in from the
#    recipe, marked synthetic all the way through.
#   url  <- rslvckan(prov)
#   path <- fdl(url, file.path(tempdir(), "data.csv"))
path <- file.path(tempdir(), "data.csv")
gen  <- mksyncsv(prov$schema$synthetic_recipe, path)

# 3. Integrity and schema.
sha256file(path)                               # pin this in the provenance
df     <- read.csv(path)
issues <- valsch(df, prov)             # names, types, value sets, rows

# 4. The manifest: named cross-checks, then the two capsule artifacts.
man <- mkmnf(list(project = "my-study", synthetic = TRUE))
man <- recrd(man, "rows", obs = nrow(df), expd = gen$rows, syn = TRUE)
wrmnfjs(man, file.path(tempdir(), "manifest.json"))
wrsmrytxt(man, tempdir(),
                  paths = list(input = path, results = tempdir()),
                  whatwasdone = "* validated the schema and recorded the row count")

Then ask whether the data itself moved, and sign the answer:

# Did the distribution change, not just the bytes?
set.seed(1)
ref  <- data.frame(value = rnorm(300), size = runif(300, 1, 10),
                   grade = sample(c("a", "b", "c"), 300, TRUE))
same <- data.frame(value = rnorm(300), size = runif(300, 1, 10),
                   grade = sample(c("a", "b", "c"), 300, TRUE))
cpsdrft(ref, same)$anydrft          # a fresh draw: FALSE
moved <- same
moved$size <- moved$size * 3              # a silently rescaled column
moved$grade[1:100] <- "z"                 # and a new category
cpsdrft(ref, moved)                 # both are caught

# Authenticate the manifest so a verifier knows who produced it.
digest <- sha256file(file.path(tempdir(), "manifest.json"))
key <- pqckg()                       # post-quantum, hash-based
sig <- cpssign(digest, key)
cpsvrf(digest, sig, signpubkey(key))
key <- xmssadv(key, sig)            # the next signature must use this

An XMSS index must never sign twice, so the package keeps a record of the highest index each key has used (xmss_used_indices.txt in tools::R_user_dir("rmoriebricklayer", "config"), locked by the operating system while a signature reserves its index). A key generated by pqckg() is entered at birth. A key from another machine, one rebuilt from its seeds, or one used in CI or a container with a fresh home directory is unknown to the record and is refused at index 0 until xmssregkey(key) vouches that it has never signed; or point options(rmoriebricklayer.xmssidxfile = ) at a record that persists. See ?xmssadv.

See vignette("capsules-and-provenance") for the distributional checks and vignette("capsules-and-provenance") for signing, Merkle pinning and manifest chains.

Asking a model

The package can put a question to a language model, from R or from the shell. You bring the model; the package runs no model service of its own and sends nothing anywhere you did not set up. The routes are tried in this order, and the first that is set up is used:

  1. An endpoint of your own. Set MORIE_LLM_BASE_URL to any OpenAI-compatible server (with MORIE_LLM_API_KEY and MORIE_LLM_MODEL when it needs them).
  2. A provider whose key is set: OpenAI, Anthropic, Google Gemini, OpenRouter, Groq, Together AI, Fireworks AI, DeepSeek, Mistral, xAI, Cerebras, Perplexity, Hugging Face, OpenCode Zen, Cohere and NVIDIA, each from its usual variable (OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, …) or a key saved with rmbl config set <provider>.key. Anthropic’s and Gemini’s own APIs are spoken natively.
  3. Ollama, found at OLLAMA_HOST (or OLLAMA_BASE_URL), default localhost:11434. Its server has to be running: ollama serve in a terminal (or the Ollama desktop app, or the ollama system service the Linux installer sets up), then ollama pull NAME for a model. rmbl doctor says “not running” until it answers; the model is OLLAMA_MODEL or the first one the server lists, including the cloud models Ollama serves. Ollama’s own cloud API works the same way: rmbl config set ollama.url ollama.com and rmbl config set ollama.key (your Ollama API key). OLLAMA_HOST=off skips it.

While it works, rmbl ask (or rmbl aye, rmbl tellme) shows a spinner with the seconds so far. The same question again is answered from this machine at once (--fresh asks the model again; MORIE_LLM_CACHE=off turns that off), and an error of these packages can be explained without any model: rmbl explain-error "the error text" (R: explerr()) names the function that raised it, the exported functions it comes from and what the message asks for.

The model gets the packages’ help: the name, arguments and title of every function of rmoriebricklayer (and of rmoriedata and rmorie when installed), and tools to search the help, read a page, its examples, a vignette and the dataset list before it writes code. A large model (a provider’s, or an Ollama cloud model) gets all of it; a small model running locally gets the functions nearest the question and can still look up any other. MORIE_LLM_CONTEXT sets the size by hand.

library(rmoriebricklayer)

llmprvd()   # the providers, their addresses and key variables
llmstat()      # which route would answer from this machine, and why
Sys.setenv(ANTHROPIC_API_KEY = "...")    # or llmcfg(anthropic.key = "...")
llmask("How do I estimate a weighted proportion with bootstrap replicate weights?")
llmask("The same question, on the local model.", route = "ollama")
llmmdls(route = "anthropic")   # the models that route offers; attr(, "default")

Choosing the route and model

Every setting can be saved once, from R or the shell, without touching the environment or the source. Saved settings live in ~/.config/morie/llm.json (private to you); an environment variable that is set still wins for that session.

llmcfg()                                        # every setting, its value and source
llmcfg(route = "anthropic")                     # always this provider
llmcfg(anthropic.model = "claude-opus-5-5")     # its model
llmcfg(ollama.url = "http://192.168.1.20:11434", ollama.model = "qwen3:8b")
llmcfg(own.url = "http://localhost:1234/v1", own.model = "my-model", own.key = "sk-...")
llmcfg(route = NULL)                            # back to the automatic order
Setting What it does Environment variable
route auto (own endpoint, a provider with a key, then Ollama), own, ollama or a provider id MORIE_LLM_ROUTE
own.url, own.model, own.key your OpenAI-compatible server (LM Studio, vLLM, llama.cpp, a gateway) MORIE_LLM_BASE_URL, MORIE_LLM_MODEL, MORIE_LLM_API_KEY
ollama.url, ollama.model, ollama.key an Ollama server here or on your network; ollama.url off skips it OLLAMA_HOST, OLLAMA_MODEL, OLLAMA_API_KEY
<provider>.key, <provider>.model a provider’s key (stored privately, shown only as “set”) and model the provider’s key variable, MORIE_<PROVIDER>_MODEL

With route = "auto", a local Ollama server that has no model pulled is passed over.

Signing in (dlogin(), rmbl login) is for the curated tables at data.rmorie.com only; it is never a language model.

The same verbs exist on the command line once the launcher is on your PATH:

Rscript -e 'rmoriebricklayer::instcli()'   # links ~/.local/bin/rmoriebricklayer and ~/.local/bin/rmbl
rmoriebricklayer help                           # every verb; `help start`, `help llm`, `help config`, `help r`
rmoriebricklayer doctor                         # which routes answer here, and the one ask will use
rmoriebricklayer ask "your question"            # own endpoint, a provider with a key, then Ollama
rmoriebricklayer ask --model NAME "your question"
rmoriebricklayer ask --route anthropic "your question"   # one route, this time
rmoriebricklayer aye "your question"            # aye and tellme are ask under other names
rmoriebricklayer tellme "your question"
rmoriebricklayer config                         # every language-model setting and where it comes from
rmoriebricklayer config setup                   # pick a provider, paste its key, pick a model
rmoriebricklayer config set route anthropic     # always this provider
rmoriebricklayer config set anthropic.key       # asks for the key; saved privately
rmoriebricklayer config set ollama.url http://192.168.1.20:11434
rmoriebricklayer config set own.url http://localhost:1234/v1
rmoriebricklayer config unset route             # back to the automatic order
rmoriebricklayer models --route ollama          # the models a route offers, the one in use marked
rmoriebricklayer login --token                  # the data key: paste the key you were issued
rmoriebricklayer login --email you@example.com  # or: a code is emailed, type it at the prompt
rmoriebricklayer login --email you@example.com --code 123456   # the same, code passed (scripts)
rmoriebricklayer login                          # or: GitHub device flow
rmoriebricklayer login --no-browser             # server / SSH: prints a link + code for any device
rmoriebricklayer logout                         # forget the data key
rmoriebricklayer data list                      # the curated tables at data.rmorie.com
rmoriebricklayer data pull db/table --out t.csv # download one (your MORIE key)
rmoriebricklayer bundle "your request"          # agentbndl() from the shell
rmoriebricklayer verify capsules --fail-on warn # every capsule under a folder, as the rmbl GitHub Action checks it
rmoriebricklayer verify capsules --json         # the check rows as JSON (hmac secret: RMBL_KEY)
rmoriebricklayer drift capsules                 # fetch each source again and compare it with the pin
rmoriebricklayer drift capsules --psi-warn 0.1 --psi-fail 0.25   # the PSI bands of the column table
rmoriebricklayer drift capsules --allow-file --cache .rmbl-cache --snapshot   # file:// sources, a cache, snapshots
rmoriebricklayer verify capsules --base base-copy/   # each manifest against its base copy (a file or a folder)
rmoriebricklayer refresh capsules               # pin each source's new bytes (cpsrfrsh())
RMBL_KEY=... rmoriebricklayer sign capsules     # pin each plain provenance and sign each capsule (cpsseal())
rmoriebricklayer sign capsules --scheme ML-DSA-44 --key-file key.json --overwrite
rmoriebricklayer report capsules --format md    # the Markdown report; never fails
rmoriebricklayer functions [PATTERN]            # exported functions and their titles
rmoriebricklayer describe NAME                  # one function's help page
rmoriebricklayer examples NAME                  # its examples
rmoriebricklayer version

rmbl is the same command under a short name; every verb works under either:

rmbl help
rmbl doctor
rmbl config setup
rmbl ask "your question"
rmbl aye "your question"
rmbl tellme "your question"
rmbl "how do I weight a survey?"               # a quoted question with no verb is asked too
rmbl login --token
rmbl models

The capsule verbs are cpschk() and cpsseal() from R, and ghann(), ghcmt() and ghout() give the annotations, the sticky pull-request comment and the step outputs the rmbl GitHub Action prints.

Part of the MORIE family

rmoriebricklayer is the reproducibility / provenance layer of the MORIE (github.com/rootcoder007/morie) ecosystem, alongside rmorie (github.com/rootcoder007/rmorie) and rmoriedata (github.com/rootcoder007/rmoriedata).

Acknowledgements

The Indigenous mental wellness estimators stand on the work of Professor Suzanne Stewart (Yellowknives Dene First Nation), Director of the Waakebiness-Bryce Institute for Indigenous Health at the University of Toronto’s Dalla Lana School of Public Health, and Professor Angela Mashford-Pringle (Timiskaming First Nation), its Associate Director. Their research made it possible to bring their frameworks into this library as estimators. Any error in that translation is ours.

Citation

If you use rmoriebricklayer in your research, please cite the software:

Ruhela, V. S. (2026). rmoriebricklayer: Reproducible Data Capsules with Provenance and Fallback. R package version 1.0.2. doi:10.32614/CRAN.package.rmoriebricklayer. https://CRAN.R-project.org/package=rmoriebricklayer

BibTeX (or run citation("rmoriebricklayer") after installation for the entry stamped with the exact installed version, sourced from inst/CITATION):

@Manual{ruhela_rmoriebricklayer_2026,
  title  = {rmoriebricklayer: Reproducible Data Capsules with Provenance and Fallback},
  author = {Ruhela, Vansh Singh},
  year   = {2026},
  doi    = {10.32614/CRAN.package.rmoriebricklayer},
  url    = {https://CRAN.R-project.org/package=rmoriebricklayer}
}

See CITATION.cff (github.com/rootcoder007/rmorie-bricklayer/blob/main/CITATION.cff) for the machine-readable metadata GitHub’s “Cite this repository” button uses.

License

AGPL-3.0-or-later.

Code of Conduct

Please note that this project is released with a Contributor Code of Conduct (github.com/rootcoder007/rmorie-bricklayer/blob/main/CODE_OF_CONDUCT.md). By contributing, you agree to abide by its terms.