Reflection files record their provenance: commit, command line, input file

The merged .mtz and the unmerged .mtz carry, after the existing "From
Rugnux <version>" record, HISTORY records with the git commit and date,
the command line, the input file (absolute path) and a cheap identity of
it - size and modification time, the master file's for HDF5, nothing
hashed - and the collection date. MTZ allows 30 records of 80
characters; a longer value runs on over records starting with '+', cut
so that no record ends in a blank a reader would strip.

The mmCIF names the commit in _software.description and the command
line, input file and identity under _jfjoch_provenance (the local prefix
the other private items use; no PDBx item holds them), the first two as
text fields so any character survives.

OutputProvenance is filled once in the CLI and travels in ProcessConfig
to every writer of a reflection file. Reflection data are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
This commit is contained in:
2026-10-09 17:52:43 +02:00
co-authored by Claude Opus 5.5
parent fc6b91555f
commit 5584460de0
7 changed files with 166 additions and 17 deletions
+54 -4
View File
@@ -47,7 +47,7 @@ TEST_CASE("Merged MTZ: the data dataset is id 1 and carries the wavelength", "[w
const auto experiment = TestExperiment();
const auto path = (std::filesystem::temp_directory_path() / "rugnux_merged_wavelength.mtz").string();
WriteMtzReflections(reflections, TETRAGONAL_CELL, experiment, path);
WriteMtzReflections(reflections, TETRAGONAL_CELL, experiment, OutputProvenance{}, path);
const gemmi::Mtz mtz = gemmi::read_mtz_file(path);
std::filesystem::remove(path);
@@ -108,7 +108,7 @@ TEST_CASE("Unmerged MTZ: LP is Lorentz-polarization, QE the sensor efficiency, F
}
const auto path = (std::filesystem::temp_directory_path() / "rugnux_unmerged_qe.mtz").string();
WriteUnmergedMtzReflections({outcome}, TETRAGONAL_CELL, experiment, false, path);
WriteUnmergedMtzReflections({outcome}, TETRAGONAL_CELL, experiment, false, OutputProvenance{}, path);
const gemmi::Mtz mtz = gemmi::read_mtz_file(path);
std::filesystem::remove(path);
@@ -180,8 +180,10 @@ TEST_CASE("Unmerged MTZ: built on several workers, the same rows as on one", "[w
for (bool sum_partials : {true, false}) {
INFO("sum_partials " << sum_partials);
const gemmi::Mtz serial = UnmergedMtz(outcomes, TETRAGONAL_CELL, experiment, sum_partials, 1);
const gemmi::Mtz parallel = UnmergedMtz(outcomes, TETRAGONAL_CELL, experiment, sum_partials, 8);
const gemmi::Mtz serial = UnmergedMtz(outcomes, TETRAGONAL_CELL, experiment, sum_partials,
OutputProvenance{}, 1);
const gemmi::Mtz parallel = UnmergedMtz(outcomes, TETRAGONAL_CELL, experiment, sum_partials,
OutputProvenance{}, 8);
REQUIRE(serial.nreflections > 100000);
REQUIRE(parallel.nreflections == serial.nreflections);
CHECK(std::memcmp(parallel.data.data(), serial.data.data(), serial.data.size() * sizeof(float)) == 0);
@@ -260,6 +262,9 @@ TEST_CASE("Merged mmCIF: PDBx keys, merging R-factors and the scaled unmerged bl
};
OutputProvenance provenance;
provenance.collection_date = "2026-07-13T13:48:52.413Z";
provenance.command_line = "rugnux -o 'my run' /data/x_master.h5";
provenance.input_file = "/data/x_master.h5";
provenance.input_identity = "1234 bytes, modified 2026-07-13T14:00:00.000Z";
const auto path = (std::filesystem::temp_directory_path() / "rugnux_merged_test.cif").string();
WriteMmcifReflections(reflections, TETRAGONAL_CELL, experiment, statistics, ErrorModelReport{},
@@ -278,6 +283,12 @@ TEST_CASE("Merged mmCIF: PDBx keys, merging R-factors and the scaled unmerged bl
== Catch::Approx(std::sqrt(2 * 0.98 / 1.98)).margin(1e-4));
CHECK(*merged.find_value("_diffrn.ambient_temp") == "100.0");
CHECK(*merged.find_value("_diffrn_detector.pdbx_collection_date") == "2026-07-13");
// Provenance: the command line survives its own quotes, as a text field.
CHECK(gemmi::cif::as_string(*merged.find_value("_jfjoch_provenance.command_line"))
== provenance.command_line);
CHECK(gemmi::cif::as_string(*merged.find_value("_jfjoch_provenance.input_file")) == provenance.input_file);
CHECK(gemmi::cif::as_string(*merged.find_value("_jfjoch_provenance.input_identity"))
== provenance.input_identity);
gemmi::cif::Table software = merged.find("_software.", {"pdbx_ordinal", "classification"});
REQUIRE(software.length() == 2);
@@ -303,3 +314,42 @@ TEST_CASE("Merged mmCIF: PDBx keys, merging R-factors and the scaled unmerged bl
CHECK(gemmi::cif::as_number(obs[0][3]) == Catch::Approx(200.0));
CHECK(gemmi::cif::as_number(obs[1][5]) == Catch::Approx(0.8));
}
TEST_CASE("MTZ history: command line and input, in records of at most 80 characters",
"[write_reflections][portable]") {
jfjoch_test::SyntheticMergeParams params;
params.true_space_group = "P 43 21 2";
params.twin_supergroup = "P 43 21 2";
params.d_min_A = 5.0;
const auto reflections = jfjoch_test::GenerateSyntheticMerged(params);
OutputProvenance provenance;
// A space falls exactly on the first record's last character, which a reader would strip.
provenance.command_line = "rugnux -o out " + std::string(56, 'x') + " " + std::string(150, 'x') + "_master.h5";
provenance.input_file = "/data/" + std::string(150, 'x') + "_master.h5";
provenance.input_identity = "1234 bytes, modified 2026-07-13T14:00:00.000Z";
const auto path = (std::filesystem::temp_directory_path() / "rugnux_history.mtz").string();
WriteMtzReflections(reflections, TETRAGONAL_CELL, TestExperiment(), provenance, path);
const gemmi::Mtz mtz = gemmi::read_mtz_file(path);
std::filesystem::remove(path);
REQUIRE(!mtz.history.empty());
CHECK(mtz.history.front().rfind("From Rugnux ", 0) == 0);
std::string joined;
for (const auto &line : mtz.history) {
CHECK(line.size() <= 80);
joined += line;
}
// Each value is all there, run on over records that start with '+'.
std::string command;
for (size_t i = 0; i < mtz.history.size(); ++i)
if (mtz.history[i].rfind("Command: ", 0) == 0) {
command = mtz.history[i].substr(9);
for (size_t j = i + 1; j < mtz.history.size() && mtz.history[j].rfind("+", 0) == 0; ++j)
command += mtz.history[j].substr(1);
}
CHECK(command == provenance.command_line);
CHECK(joined.find("Input file: " + provenance.input_identity) != std::string::npos);
CHECK(mtz.history.size() <= 30);
}