diff --git a/roofit/hs3/src/JSONFactories_HistFactory.cxx b/roofit/hs3/src/JSONFactories_HistFactory.cxx index 8d8c925e2d905..df59000016fc0 100644 --- a/roofit/hs3/src/JSONFactories_HistFactory.cxx +++ b/roofit/hs3/src/JSONFactories_HistFactory.cxx @@ -502,6 +502,10 @@ class HistFactoryImporter : public RooFit::JSONIO::Importer { std::vector errs(sumW.size()); for (size_t i = 0; i < sumW.size(); ++i) { + if (sumW[i] == 0.) { + errs[i] = 0.; + continue; + } errs[i] = std::sqrt(sumW2[i]) / sumW[i]; // avoid negative sigma. This NP will be set constant anyway later errs[i] = std::max(errs[i], 0.); @@ -1136,6 +1140,16 @@ void configureStatError(Channel &channel) bool exportChannel(RooJSONFactoryWSTool *tool, const Channel &channel, JSONNode &elem) { + // Write the constraint reference (either by name or by type) for any + // modifier that supports an external Gaussian/Poisson/etc. constraint. + auto writeConstraint = [](JSONNode &mod, auto const &sys) { + if (sys.constraint) { + mod["constraint_name"] << sys.constraint->GetName(); + } else if (sys.constraintType) { + mod["constraint_type"] << toString(sys.constraintType); + } + }; + bool observablesWritten = false; for (const auto &sample : channel.samples) { @@ -1167,11 +1181,7 @@ bool exportChannel(RooJSONFactoryWSTool *tool, const Channel &channel, JSONNode if (sys.interpolationCode != 4) { mod["interpolation"] << sys.interpolationCode; } - if (sys.constraint) { - mod["constraint_name"] << sys.constraint->GetName(); - } else if (sys.constraintType) { - mod["constraint_type"] << toString(sys.constraintType); - } + writeConstraint(mod, sys); auto &data = mod["data"].set_map(); data["lo"] << sys.low; data["hi"] << sys.high; @@ -1183,11 +1193,7 @@ bool exportChannel(RooJSONFactoryWSTool *tool, const Channel &channel, JSONNode mod["name"] << sys.name; mod["type"] << "histosys"; mod["parameter"] << sys.param->GetName(); - if (sys.constraint) { - mod["constraint_name"] << sys.constraint->GetName(); - } else if (sys.constraintType) { - mod["constraint_type"] << toString(sys.constraintType); - } + writeConstraint(mod, sys); auto &data = mod["data"].set_map(); if (channel.nBins != sys.low.size() || channel.nBins != sys.high.size()) { std::stringstream ss; @@ -1205,20 +1211,12 @@ bool exportChannel(RooJSONFactoryWSTool *tool, const Channel &channel, JSONNode mod["name"] << sys.name; mod["type"] << "shapesys"; optionallyExportGammaParameters(mod, sys.name, sys.parameters); - if (sys.constraint) { - mod["constraint_name"] << sys.constraint->GetName(); - } else if (sys.constraintType) { - mod["constraint_type"] << toString(sys.constraintType); - } + writeConstraint(mod, sys); + auto &vals = mod["data"].set_map()["vals"]; if (sys.constraint || sys.constraintType) { - auto &vals = mod["data"].set_map()["vals"]; vals.fill_seq(sys.constraints); } else { - auto &vals = mod["data"].set_map()["vals"]; - vals.set_seq(); - for (std::size_t i = 0; i < sys.parameters.size(); ++i) { - vals.append_child() << 0; - } + vals.fill_seq(std::vector(sys.parameters.size(), 0.0)); } } diff --git a/roofit/hs3/src/JSONFactories_RooFitCore.cxx b/roofit/hs3/src/JSONFactories_RooFitCore.cxx index 88586a419f541..c3b771a9ee033 100644 --- a/roofit/hs3/src/JSONFactories_RooFitCore.cxx +++ b/roofit/hs3/src/JSONFactories_RooFitCore.cxx @@ -825,25 +825,30 @@ class RooFormulaArgStreamer : public RooFit::JSONIO::Exporter { expr.ReplaceAll("TMath::Erf", "erf"); } }; +// Write the "x" reference and the coefficient list for polynomial-like +// pdfs/funcs, including the implicit defaults below "lowestOrder" so that the +// output is self-documenting. +template +void writePolynomialBody(const Pdf *pdf, JSONNode &elem) +{ + elem["x"] << pdf->x().GetName(); + auto &coefs = elem["coefficients"].set_seq(); + for (int i = 0; i < pdf->lowestOrder(); ++i) { + coefs.append_child() << (i == 0 ? "1.0" : "0.0"); + } + for (const auto &coef : pdf->coefList()) { + coefs.append_child() << coef->GetName(); + } +} + template class RooPolynomialStreamer : public RooFit::JSONIO::Exporter { public: std::string const &key() const override; bool exportObject(RooJSONFactoryWSTool *, const RooAbsArg *func, JSONNode &elem) const override { - auto *pdf = static_cast(func); elem["type"] << key(); - elem["x"] << pdf->x().GetName(); - auto &coefs = elem["coefficients"].set_seq(); - // Write out the default coefficient that RooFit uses for the lower - // orders before the order of the first coefficient. Like this, the - // output is more self-documenting. - for (int i = 0; i < pdf->lowestOrder(); ++i) { - coefs.append_child() << (i == 0 ? "1.0" : "0.0"); - } - for (const auto &coef : pdf->coefList()) { - coefs.append_child() << coef->GetName(); - } + writePolynomialBody(static_cast(func), elem); return true; } }; @@ -853,19 +858,8 @@ class RooLegacyExpPolyStreamer : public RooFit::JSONIO::Exporter { std::string const &key() const override; bool exportObject(RooJSONFactoryWSTool *, const RooAbsArg *func, JSONNode &elem) const override { - auto *pdf = static_cast(func); elem["type"] << key(); - elem["x"] << pdf->x().GetName(); - auto &coefs = elem["coefficients"].set_seq(); - // Write out the default coefficient that RooFit uses for the lower - // orders before the order of the first coefficient. Like this, the - // output is more self-documenting. - for (int i = 0; i < pdf->lowestOrder(); ++i) { - coefs.append_child() << (i == 0 ? "1.0" : "0.0"); - } - for (const auto &coef : pdf->coefList()) { - coefs.append_child() << coef->GetName(); - } + writePolynomialBody(static_cast(func), elem); return true; } }; @@ -1102,18 +1096,16 @@ class ParamHistFuncStreamer : public RooFit::JSONIO::Exporter { RooJSONFactoryWSTool::testValidName(name, false); JSONNode &obsNode = observablesNode.append_child().set_map(); obsNode["name"] << name; - if (var->getBinning().isUniform()) { + auto const &binning = var->getBinning(); + if (binning.isUniform()) { obsNode["min"] << var->getMin(); obsNode["max"] << var->getMax(); obsNode["nbins"] << var->getBins(); } else { - auto &edges = obsNode["edges"]; - edges.set_seq(); - double val = var->getBinning().binLow(0); - edges.append_child() << val; - for (int i = 0; i < var->getBinning().numBins(); ++i) { - val = var->getBinning().binHigh(i); - edges.append_child() << val; + auto &edges = obsNode["edges"].set_seq(); + edges.append_child() << binning.binLow(0); + for (int i = 0; i < binning.numBins(); ++i) { + edges.append_child() << binning.binHigh(i); } } } @@ -1256,7 +1248,7 @@ STATIC_EXECUTE([]() { registerExporter(RooFFTConvPdf::Class(), false); registerExporter(RooExtendPdf::Class(), false); registerExporter(ParamHistFunc::Class(), false); - registerExporter(RooSpline::Class(), false); + registerExporter(RooSpline::Class(), false); }); } // namespace diff --git a/roofit/hs3/src/RooJSONFactoryWSTool.cxx b/roofit/hs3/src/RooJSONFactoryWSTool.cxx index 72d91125d2425..20d87473e408a 100644 --- a/roofit/hs3/src/RooJSONFactoryWSTool.cxx +++ b/roofit/hs3/src/RooJSONFactoryWSTool.cxx @@ -986,6 +986,9 @@ void RooJSONFactoryWSTool::exportVariable(const RooAbsArg *v, JSONNode &node, bo var["value"] << rrv->getVal(); if (rrv->isConstant() && storeConstant) { var["const"] << rrv->isConstant(); + } else { + var["min"] << rrv->getMin(); + var["max"] << rrv->getMax(); } if (rrv->getBins() != 100 && storeBins) { var["nbins"] << rrv->getBins(); @@ -1507,10 +1510,6 @@ void RooJSONFactoryWSTool::exportData(RooAbsData const &data) return; JSONNode &output = appendNamedChild((*_rootnodeOutput)["data"], data.GetName()); - /*std::ofstream file("/home/scello/Data/ZvvH126_5.txt", std::ios::app); - if (!file.is_open()) { - std::cerr << "Error: Could not open file for writing.\n"; - }*/ // This works around a problem in RooStats/HistFactory that was only fixed // in ROOT 6.30: until then, the weight variable of the observed dataset, @@ -1832,35 +1831,21 @@ void RooJSONFactoryWSTool::exportSingleModelConfig(JSONNode &rootnode, RooFit::M auto &domainsNode = rootnode["domains"]; - if (mc.GetNuisanceParameters() && mc.GetNuisanceParameters()->size() > 0) { - std::string npDomainName = analysisName + "_nuisance_parameters"; - domains.append_child() << npDomainName; - RooFit::JSONIO::Detail::Domains::ProductDomain npDomain; - for (auto *np : static_range_cast(*mc.GetNuisanceParameters())) { - npDomain.readVariable(*np); - } - npDomain.writeJSON(appendNamedChild(domainsNode, npDomainName)); - } - - if (mc.GetGlobalObservables() && mc.GetGlobalObservables()->size() > 0) { - std::string globDomainName = analysisName + "_global_observables"; - domains.append_child() << globDomainName; - RooFit::JSONIO::Detail::Domains::ProductDomain globDomain; - for (auto *glob : static_range_cast(*mc.GetGlobalObservables())) { - globDomain.readVariable(*glob); + auto writeProductDomain = [&](const char *suffix, RooArgSet const *args) { + if (!args || args->empty()) + return; + const std::string domainName = analysisName + suffix; + domains.append_child() << domainName; + RooFit::JSONIO::Detail::Domains::ProductDomain domain; + for (auto *var : static_range_cast(*args)) { + domain.readVariable(*var); } - globDomain.writeJSON(appendNamedChild(domainsNode, globDomainName)); - } + domain.writeJSON(appendNamedChild(domainsNode, domainName)); + }; - if (mc.GetParametersOfInterest() && mc.GetParametersOfInterest()->size() > 0) { - std::string poiDomainName = analysisName + "_parameters_of_interest"; - domains.append_child() << poiDomainName; - RooFit::JSONIO::Detail::Domains::ProductDomain poiDomain; - for (auto *poi : static_range_cast(*mc.GetParametersOfInterest())) { - poiDomain.readVariable(*poi); - } - poiDomain.writeJSON(appendNamedChild(domainsNode, poiDomainName)); - } + writeProductDomain("_nuisance_parameters", mc.GetNuisanceParameters()); + writeProductDomain("_global_observables", mc.GetGlobalObservables()); + writeProductDomain("_parameters_of_interest", mc.GetParametersOfInterest()); auto &modelConfigAux = getRooFitInternal(rootnode, "ModelConfigs", analysisName); modelConfigAux.set_map(); @@ -1947,18 +1932,16 @@ void RooJSONFactoryWSTool::exportAllObjects(JSONNode &n) // the ones that the pdfs encoded implicitly (like in the case of // HistFactory). for (RooAbsArg *arg : *snsh) { - if (exportedObjectNames.find(arg->GetName()) != exportedObjectNames.end()) { - bool do_export = false; - for (const auto &pdf : allpdfs) { - if (pdf->dependsOn(*arg)) { - do_export = true; - } - } - if (do_export) { - RooJSONFactoryWSTool::testValidName(arg->GetName(), true); - snapshotSorted.add(*arg); + bool do_export = false; + for (const auto &pdf : allpdfs) { + if (pdf->dependsOn(*arg)) { + do_export = true; } } + if (do_export) { + RooJSONFactoryWSTool::testValidName(arg->GetName(), true); + snapshotSorted.add(*arg); + } } snapshotSorted.sort(); std::string name(snsh->GetName()); @@ -2478,44 +2461,6 @@ RooWorkspace RooJSONFactoryWSTool::cleanWS(const RooWorkspace &ws, bool onlyMode tmpWS.import(*obj); } - /* - if (auto* mc = dynamic_cast(obj)) { - // Import the PDF - tmpWS.import(*mc->GetPdf()); - - // Import all observables - RooArgSet* obs = (RooArgSet*)mc->GetObservables()->snapshot(); - tmpWS.import(*obs); - - // Import global observables - RooArgSet* globObs = (RooArgSet*)mc->GetGlobalObservables()->snapshot(); - tmpWS.import(*globObs); - - // Import POIs - RooArgSet* pois = (RooArgSet*)mc->GetParametersOfInterest()->snapshot(); - tmpWS.import(*pois); - - // Import nuisance parameters - RooArgSet* nuis = (RooArgSet*)mc->GetNuisanceParameters()->snapshot(); - tmpWS.import(*nuis); - - - RooFit::ModelConfig* mc_new = new RooFit::ModelConfig(mc->GetName(), mc->GetName()); - - mc_new->SetPdf(*tmpWS.pdf(mc->GetPdf()->GetName())); - mc_new->SetObservables(*tmpWS.set(obs->GetName())); - mc_new->SetGlobalObservables(*tmpWS.set(globObs->GetName())); - mc_new->SetParametersOfInterest(*tmpWS.set(pois->GetName())); - mc_new->SetNuisanceParameters(*tmpWS.set(nuis->GetName())); - - // Import the ModelConfig into the new workspace - tmpWS.import(*mc_new); - }else { - - tmpWS.import(*obj); - } - */ - for (auto *snsh : ws.getSnapshots()) { auto *snshSet = dynamic_cast(snsh); if (snshSet) { @@ -2533,32 +2478,17 @@ RooWorkspace RooJSONFactoryWSTool::sanitizeWS(const RooWorkspace &ws) RooWorkspace tmpWS = cleanWS(ws, false); - for (auto *obj : tmpWS.allVars()) { - if (!isValidName(obj->GetName())) { - obj->SetName(sanitizeName(obj->GetName()).c_str()); - } - } - - // Functions - for (auto *obj : tmpWS.allFunctions()) { - if (!isValidName(obj->GetName())) { - obj->SetName(sanitizeName(obj->GetName()).c_str()); - } - } - - // PDFs - for (auto *obj : tmpWS.allPdfs()) { - if (!isValidName(obj->GetName())) { - obj->SetName(sanitizeName(obj->GetName()).c_str()); - } - } - - // Resolution Models - for (auto *obj : tmpWS.allResolutionModels()) { - if (!isValidName(obj->GetName())) { - obj->SetName(sanitizeName(obj->GetName()).c_str()); + auto sanitizeIfNeeded = [](auto const &list) { + for (auto *obj : list) { + if (!isValidName(obj->GetName())) { + obj->SetName(sanitizeName(obj->GetName()).c_str()); + } } - } + }; + sanitizeIfNeeded(tmpWS.allVars()); + sanitizeIfNeeded(tmpWS.allFunctions()); + sanitizeIfNeeded(tmpWS.allPdfs()); + sanitizeIfNeeded(tmpWS.allResolutionModels()); // Datasets for (auto *data : tmpWS.allData()) { // Sanitize dataset name @@ -2569,26 +2499,6 @@ RooWorkspace RooJSONFactoryWSTool::sanitizeWS(const RooWorkspace &ws) obj->SetName(sanitizeName(obj->GetName()).c_str()); } } - /* // Sanitize dataset observables - const RooArgSet* obsSet = data->get(); - if (obsSet) { - RooArgSet* mutableObs = const_cast(obsSet); - std::string oldSetName = mutableObs->GetName(); - std::string newSetName = sanitizeName(oldSetName); - if (oldSetName != newSetName) { - mutableObs->setName(newSetName.c_str()); - } - } - - for (auto* arg : *obsSet) { - std::string oldObsName = arg->GetName(); - std::string newObsName = sanitizeName(oldObsName); - if (oldObsName != newObsName) { - arg->SetName(newObsName.c_str()); - data->changeObservableName(arg->GetName(), newObsName.c_str()); - } - } - */ for (auto *data : tmpWS.allEmbeddedData()) { // Sanitize dataset name data->SetName(sanitizeName(data->GetName()).c_str()); diff --git a/roofit/hs3/test/testRooFitHS3.cxx b/roofit/hs3/test/testRooFitHS3.cxx index faebc939927a1..86481361a5812 100644 --- a/roofit/hs3/test/testRooFitHS3.cxx +++ b/roofit/hs3/test/testRooFitHS3.cxx @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +28,8 @@ #include #include +#include + #include #include @@ -639,3 +642,203 @@ TEST(RooFitHS3, RegisterExporterByClassName) // exporters is removed. EXPECT_EQ(RooFit::JSONIO::removeExporters("TestExporter"), 4); } + +// Round-trip an unbinned RooDataSet and verify that the observable's range +// (min/max) is preserved through JSON. The "axes" node of an unbinned dataset +// is read back via min/max/nbins fields, so non-constant variables must export +// these fields directly on the variable node. +TEST(RooFitHS3, UnbinnedDatasetAxisRange) +{ + constexpr double xMin = -2.5; + constexpr double xMax = 7.5; + + RooWorkspace ws1{"ws_unbinned"}; + { + RooRealVar x{"x", "x", xMin, xMax}; + RooDataSet ds{"ds", "unbinned dataset", RooArgSet{x}}; + for (double val : {-1.0, 0.5, 2.0, 3.5, 6.0}) { + x.setVal(val); + ds.add(RooArgSet{x}); + } + ws1.import(ds, RooFit::Silence()); + } + + const std::string json1 = RooJSONFactoryWSTool{ws1}.exportJSONtoString(); + + RooWorkspace ws2{"ws_unbinned_2"}; + ASSERT_TRUE(RooJSONFactoryWSTool{ws2}.importJSONfromString(json1)); + + auto *ds2 = dynamic_cast(ws2.data("ds")); + ASSERT_NE(ds2, nullptr); + EXPECT_EQ(ds2->numEntries(), 5); + + RooRealVar *x2 = ws2.var("x"); + ASSERT_NE(x2, nullptr); + EXPECT_DOUBLE_EQ(x2->getMin(), xMin); + EXPECT_DOUBLE_EQ(x2->getMax(), xMax); + + // The exported "axes" node of an unbinned dataset must carry the + // observable range so the file is self-describing. Before the fix, only + // the variable name and current value were written there (the range was + // only present in the separate "domains" block). + const auto axesPos = json1.find("\"axes\":[{"); + ASSERT_NE(axesPos, std::string::npos) << json1; + const auto axesEnd = json1.find("}]", axesPos); + ASSERT_NE(axesEnd, std::string::npos) << json1; + const std::string axesNode = json1.substr(axesPos, axesEnd - axesPos); + EXPECT_NE(axesNode.find("\"min\":-2.5"), std::string::npos) << axesNode; + EXPECT_NE(axesNode.find("\"max\":7.5"), std::string::npos) << axesNode; +} + +// HistFactory channels with samples that have a zero-yield bin together with a +// staterror modifier used to produce NaN gamma errors because the relative +// error is computed as sqrt(sumW2)/sumW. Importing such a channel should now +// produce a finite (zero) error for that bin. +TEST(RooFitHS3, HistFactoryZeroYieldBin) +{ + const std::string jsonStr = R"({ + "metadata": {"hs3_version": "0.1.90"}, + "distributions": [ + { + "name": "model_channel0", + "type": "histfactory_dist", + "axes": [ + {"name": "obs_channel0", "min": 0.0, "max": 2.0, "nbins": 2} + ], + "samples": [ + { + "name": "sig", + "data": {"contents": [10.0, 0.0]}, + "modifiers": [ + {"name": "mu", "type": "normfactor"}, + {"name": "mcstat", "type": "staterror"} + ] + } + ] + } + ], + "data": [ + { + "name": "obsData_channel0", + "type": "binned", + "axes": [ + {"name": "obs_channel0", "min": 0.0, "max": 2.0, "nbins": 2} + ], + "contents": [10.0, 0.0] + } + ] + })"; + + RooHelpers::LocalChangeMsgLevel changeMsgLvl(RooFit::WARNING); + + RooWorkspace ws{"ws_zero_yield"}; + ASSERT_TRUE(RooJSONFactoryWSTool{ws}.importJSONfromString(jsonStr)); + + // The mc_stat ParamHistFunc is created with one gamma per bin. Their nominal + // constraint values are derived from the relative bin error. For the + // zero-yield bin the new behaviour avoids the 0/0 NaN and uses 0 instead. + bool foundFiniteNomGamma = false; + for (auto *arg : ws.allVars()) { + const std::string name = arg->GetName(); + if (name.find("nom_gamma_stat_channel0") == std::string::npos) + continue; + auto *rrv = static_cast(arg); + EXPECT_TRUE(std::isfinite(rrv->getVal())) << "Non-finite nominal gamma value for " << name; + foundFiniteNomGamma = true; + } + EXPECT_TRUE(foundFiniteNomGamma) << "No nominal gamma stat parameters were created"; + + // The gamma stat parameters themselves must be finite as well. + for (auto *arg : ws.allVars()) { + const std::string name = arg->GetName(); + if (name.rfind("gamma_stat_channel0", 0) != 0) + continue; + auto *rrv = static_cast(arg); + EXPECT_TRUE(std::isfinite(rrv->getVal())) << "Non-finite gamma value for " << name; + EXPECT_TRUE(std::isfinite(rrv->getMin())) << "Non-finite gamma min for " << name; + EXPECT_TRUE(std::isfinite(rrv->getMax())) << "Non-finite gamma max for " << name; + } +} + +// Snapshot export must keep all variables that any pdf depends on, even when +// the variable is not in the set of separately exported objects. Global +// observables of HistFactory constraint pdfs (the nominal "nom_*" parameters) +// are exactly such variables: the HistFactory exporter explicitly skips them +// when collecting parameters to export, but pdfs still depend on them. +TEST(RooFitHS3, SnapshotKeepsGlobalObservables) +{ + const std::string jsonStr = R"({ + "metadata": {"hs3_version": "0.1.90"}, + "distributions": [ + { + "name": "model_channel0", + "type": "histfactory_dist", + "axes": [ + {"name": "obs_channel0", "min": 0.0, "max": 2.0, "nbins": 2} + ], + "samples": [ + { + "name": "sig", + "data": {"contents": [10.0, 20.0], "errors": [1.0, 2.0]}, + "modifiers": [ + {"name": "mu", "type": "normfactor"}, + {"name": "mcstat", "type": "staterror"} + ] + } + ] + } + ], + "data": [ + { + "name": "obsData_channel0", + "type": "binned", + "axes": [ + {"name": "obs_channel0", "min": 0.0, "max": 2.0, "nbins": 2} + ], + "contents": [10.0, 20.0] + } + ] + })"; + + RooHelpers::LocalChangeMsgLevel changeMsgLvl(RooFit::WARNING); + + RooWorkspace ws1{"ws_snap"}; + ASSERT_TRUE(RooJSONFactoryWSTool{ws1}.importJSONfromString(jsonStr)); + + // Collect the "nom_*" global observables created on import. The constraint + // pdfs of the staterror modifier depend on them, but the HistFactory + // exporter does not list them as top-level exported objects. + RooArgSet globs; + for (auto *arg : ws1.allVars()) { + const std::string name = arg->GetName(); + if (name.rfind("nom_", 0) == 0) { + globs.add(*arg); + } + } + ASSERT_GT(globs.size(), 0u) << "No nominal global observables found in workspace"; + + // Save a snapshot containing only the global observables. With the old + // filter (require name in exportedObjectNames AND pdf dependence), this + // snapshot would be dropped on export. + const char *snapName = "globsSnap"; + ws1.saveSnapshot(snapName, globs, true); + + const std::string exported = RooJSONFactoryWSTool{ws1}.exportJSONtoString(); + + // The exported JSON should mention the snapshot name and at least one of + // the global observables. + EXPECT_NE(exported.find(snapName), std::string::npos) << "Snapshot name missing from exported JSON"; + EXPECT_NE(exported.find("nom_gamma"), std::string::npos) << "Global observable missing from exported snapshot block"; + + // Re-import and check that the snapshot survived the round-trip with all + // global observables included. + RooWorkspace ws2{"ws_snap_2"}; + ASSERT_TRUE(RooJSONFactoryWSTool{ws2}.importJSONfromString(exported)); + + const RooArgSet *snap = ws2.getSnapshot(snapName); + ASSERT_NE(snap, nullptr) << "Snapshot was not preserved through JSON round-trip"; + + for (auto *arg : globs) { + EXPECT_NE(snap->find(arg->GetName()), nullptr) << "Snapshot is missing pdf-dependent variable " << arg->GetName(); + } +}