diff --git a/roofit/doc/developers/roofit_hs3.md b/roofit/doc/developers/roofit_hs3.md index 5f718110a40fd..696ea79ceb335 100644 --- a/roofit/doc/developers/roofit_hs3.md +++ b/roofit/doc/developers/roofit_hs3.md @@ -47,12 +47,16 @@ implementation(s) take precedence. ### Writing your own importers and exporters: Proxy-based Proxy-based implementations can be added very easily and without -actually writing any `C++` code -- you only need to add a short item -to a list in a `JSON` file, namely the -[export keys](https://github.com/root-project/root/blob/master/etc/RooFitHS3_wsexportkeys.json) +actually writing any `C++` logic -- you only need to add a short item +to a `JSON` catalog, namely the +[export keys](https://github.com/root-project/root/blob/master/roofit/hs3/src/RooFitHS3_wsexportkeys.cxx) for an exporter, or the -[factory expressions](https://github.com/root-project/root/blob/master/etc/RooFitHS3_wsfactoryexpressions.json) -for an importer. +[factory expressions](https://github.com/root-project/root/blob/master/roofit/hs3/src/RooFitHS3_wsfactoryexpressions.cxx) +for an importer. The built-in catalogs are `JSON` documents embedded as +raw string literals in these two source files. They are compiled into +the `RooFitHS3` library and loaded automatically when the library is +loaded, so there are no separate `JSON` files to install or find at +runtime. This works in the following way: Every `RooFit` class performs dependency tracking via proxies, which have names. This can be @@ -67,7 +71,7 @@ interface. Hence: expression`. For the importer, an entry in the -[factory expressions](https://github.com/root-project/root/blob/master/etc/RooFitHS3_wsfactoryexpressions.json) +[factory expressions](https://github.com/root-project/root/blob/master/roofit/hs3/src/RooFitHS3_wsfactoryexpressions.cxx) needs to be added as follows: ``` {.json} @@ -82,7 +86,7 @@ needs to be added as follows: ``` Similarly, for the exporter, an entry in the -[export keys](https://github.com/root-project/root/blob/master/etc/RooFitHS3_wsexportkeys.json) +[export keys](https://github.com/root-project/root/blob/master/roofit/hs3/src/RooFitHS3_wsexportkeys.cxx) needs to be added as follows: ``` {.json} @@ -97,12 +101,18 @@ needs to be added as follows: ``` -If you don't want to edit the central `json` files containing the -factory expressions or export keys, you can also put your custom -export keys or factory expressions into a different json file and load -that using `RooFit::JSONIO::loadExportKeys(const std::string +To contribute a catalog entry to `ROOT`, edit the embedded catalog in +`roofit/hs3/src/RooFitHS3_wsexportkeys.cxx` or +`roofit/hs3/src/RooFitHS3_wsfactoryexpressions.cxx` and rebuild `ROOT`. +If you don't want to change `ROOT` itself, you can put your custom +export keys or factory expressions into your own `JSON` file (with the +same format as the embedded catalogs) and add them to the built-in ones +at runtime using `RooFit::JSONIO::loadExportKeys(const std::string &fname)` and `RooFit::JSONIO::loadFactoryExpressions(const -std::string &fname)`. +std::string &fname)`. Both functions also have an overload that reads +from a `std::istream`. You can inspect the currently known entries with +`RooFit::JSONIO::printExportKeys()` and +`RooFit::JSONIO::printFactoryExpressions()`. If either the importer or the exporter cannot be created with factory expressions and export keys, you can instead write a custom `C++` @@ -121,9 +131,11 @@ in `ROOT`. Any importer should take the following form: ``` {.cpp} + using RooFit::Detail::JSONNode; + class MyClassFactory : public RooFit::JSONIO::Importer { public: - bool importFunction(RooJSONFactoryWSTool *tool, const JSONNode &p) const override + bool importArg(RooJSONFactoryWSTool *tool, const JSONNode &p) const override { std::string name(RooJSONFactoryWSTool::name(p)); @@ -145,14 +157,24 @@ Any importer should take the following form: }; ``` -If the class you are trying to import inherits from `RooAbsPdf` rather -than from `RooAbsReal`, you should define `importPdf` instead of -`importFunction`, with the same signature. +The same `importArg` function is used for functions (inheriting from +`RooAbsReal`) and pdfs (inheriting from `RooAbsPdf`). If the importer +can't handle a given `JSON` node, it should return `false` so that the +next importer is tried. The functions `importFunction` and `importPdf` +of older `ROOT` versions are still called for backwards compatibility, +so existing importers that override them keep working, but new code +should override `importArg`. Once your importer implementation exists, you need to register it with the tool using a line like the following: ``` {.cpp} - RooFit::JSONIO::registerImporter("", new MyClassFactory(), true); + RooFit::JSONIO::registerImporter("", true); +``` + +This is a shorthand for passing an instance owned by a `std::unique_ptr`: + +``` {.cpp} + RooFit::JSONIO::registerImporter("", std::make_unique(), true); ``` As there can be several importers for the same `json` key, the last @@ -189,9 +211,20 @@ The implementation of an exporter works in a very similar fashion: Also this needs to be registered with the tool ``` {.cpp} - RooFit::JSONIO::registerExporter(MyClass::Class(), new MyClassStreamer(), true); + RooFit::JSONIO::registerExporter(MyClass::Class(), true); ``` +or equivalently + +``` {.cpp} + RooFit::JSONIO::registerExporter(MyClass::Class(), std::make_unique(), true); +``` + +If the `TClass` of your class is not available where you register the +exporter, you can also pass the class name as a string instead of +`MyClass::Class()`. If `exportObject` returns `false`, the next +exporter is tried, and finally the export keys. + For more complicated cases where members are lists of elements, the methods `is_seq()`, `set_seq()`, `is_map()`, `set_map()` and ranged-based for-loops via `children()` might come in handy. diff --git a/roofit/hs3/inc/RooFitHS3/RooJSONFactoryWSTool.h b/roofit/hs3/inc/RooFitHS3/RooJSONFactoryWSTool.h index 23f84b127e4cf..c711a16b355cf 100644 --- a/roofit/hs3/inc/RooFitHS3/RooJSONFactoryWSTool.h +++ b/roofit/hs3/inc/RooFitHS3/RooJSONFactoryWSTool.h @@ -206,6 +206,9 @@ class RooJSONFactoryWSTool { std::string getStringAttribute(const std::string &obj, const std::string &attrib); void setStringAttribute(const std::string &obj, const std::string &attrib, const std::string &value); + /// The "hs3_version" of the document that is currently being imported, or an empty string outside of an import. + std::string const &hs3VersionInput() const { return _hs3VersionInput; } + private: template T *requestImpl(const std::string &objname); @@ -251,6 +254,7 @@ class RooJSONFactoryWSTool { const RooFit::Detail::JSONNode *_attributesNode = nullptr; RooFit::Detail::JSONNode *_rootnodeOutput = nullptr; RooFit::Detail::JSONNode *_varsNode = nullptr; + std::string _hs3VersionInput; RooWorkspace &_workspace; // objects to represent intermediate information diff --git a/roofit/hs3/src/Domains.cxx b/roofit/hs3/src/Domains.cxx index 218cf62cdf674..a68513335d094 100644 --- a/roofit/hs3/src/Domains.cxx +++ b/roofit/hs3/src/Domains.cxx @@ -11,6 +11,7 @@ */ #include "Domains.h" +#include "JSONIOUtils.h" #include #include @@ -29,15 +30,6 @@ constexpr static auto defaultDomainName = "default_domain"; namespace { -double readBound(RooFit::Detail::JSONNode const &node, const char *key, double defaultValue) -{ - if (!node.has_child(key)) { - return defaultValue; - } - auto const &bound = node[key]; - return bound.is_null() ? defaultValue : bound.val_double(); -} - void writeBound(RooFit::Detail::JSONNode &node, double value) { if (RooNumber::isInfinite(value)) { @@ -54,6 +46,12 @@ void Domains::populate(RooWorkspace &ws) const auto default_domain = _map.find(defaultDomainName); if (default_domain != _map.end()) { default_domain->second.populate(ws); + } else { + // Without the RooFit-specific "default_domain", the variables are created with the range of the domain that + // mentions them. + for (const auto &domain : _map) { + domain.second.populate(ws); + } } for (const auto &domain : _map) { if (domain.first == defaultDomainName) @@ -78,15 +76,20 @@ void Domains::writeVariable(RooRealVar &var) const auto default_domain = _map.find(defaultDomainName); if (default_domain != _map.end()) { default_domain->second.writeVariable(var); + return; + } + // Without the RooFit-specific "default_domain", use the first domain that mentions the variable. + for (const auto &domain : _map) { + if (domain.second.hasVariable(var.GetName())) { + domain.second.writeVariable(var); + return; + } } } void Domains::readJSON(RooFit::Detail::JSONNode const &node) { - auto default_domain_element = RooJSONFactoryWSTool::findNamedChild(node, defaultDomainName); - if (!default_domain_element) { - RooJSONFactoryWSTool::error("\"domains\" do not contain \"" + std::string{defaultDomainName} + "\""); - } + // The "default_domain" is written by RooFit, but it is not required by the HS3 standard. for (auto &domain : node.children()) { if (!domain.has_child("name")) { RooJSONFactoryWSTool::error("encountered domain without \"name\""); diff --git a/roofit/hs3/src/JSONFactories_HistFactory.cxx b/roofit/hs3/src/JSONFactories_HistFactory.cxx index bb62724c95cb4..c5f34761b65f7 100644 --- a/roofit/hs3/src/JSONFactories_HistFactory.cxx +++ b/roofit/hs3/src/JSONFactories_HistFactory.cxx @@ -336,8 +336,16 @@ std::vector readInterpolations(const JSONNode &object, std::size_t nParamet return codes; } +// The fallback for modifiers without interpolation in documents that predate HS3 v0.3, see interpolationCode(). Before +// that, RooFit did not write the interpolation of modifiers that used its implicit default. +struct LegacyInterpolationDefault { + bool allowed = false; + bool warned = false; +}; + int interpolationCode(const JSONNode &modifier, const std::optional &defaultInterpolation, - InterpolationClass interpolationClass, const std::string &context) + InterpolationClass interpolationClass, const std::string &context, + LegacyInterpolationDefault &legacyDefault) { const auto toCode = [&](const Interpolation &interpolation) { return codeFromInterpolation(interpolation, interpolationClass, context); @@ -355,9 +363,20 @@ int interpolationCode(const JSONNode &modifier, const std::optional(&constraint.getMean()); @@ -620,7 +669,9 @@ double constraintRelError(RooAbsPdf const &constraint, RooAbsArg const &gamma) bool importHistSample(RooJSONFactoryWSTool &tool, RooDataHist &dh, RooArgSet const &varlist, RooAbsArg const *mcStatObject, const std::string &fprefix, const JSONNode &p, - const std::optional &defaultInterpolation, RooArgSet &constraints) + const std::optional &defaultInterpolation, + LegacyInterpolationDefault &legacyInterpolationDefault, bool &warnedUnconstrained, + RooArgSet &constraints) { RooWorkspace &ws = *tool.workspace(); @@ -668,23 +719,25 @@ bool importHistSample(RooJSONFactoryWSTool &tool, RooDataHist &dh, RooArgSet con if (modtype == "staterror") { // this is dealt with at a different place, ignore it for now } else if (modtype == "normfactor") { - RooRealVar &constrParam = getOrCreate(ws, sysname, 1., -3, 5); + // The "name" is only a label of the modifier, the parameter is given by "parameter" if present. + auto *parameter = mod.find("parameter"); + RooRealVar &constrParam = getOrCreate(ws, parameter ? parameter->val() : sysname, 1., -3, 5); constrParam.setError(0.0); normElems.add(constrParam); - if (mod.has_child("constraint") || mod.has_child("constraint_name") || mod.has_child("constraint_type")) { + if (hasConstraint(mod)) { // for norm factors, constraints are optional constraints.add(getOrCreateConstraint(tool, mod, constrParam, sampleName)); } } else if (modtype == "normsys") { auto *parameter = mod.find("parameter"); std::string parname(parameter ? parameter->val() : "alpha_" + sysname); - createNominal(ws, parname, 0.0, -10, 10); auto &par = getOrCreate(ws, parname, 0., -5, 5); overall_nps.add(par); auto &data = mod["data"]; const std::string context = "interpolation for normsys modifier '" + sysname + "' in sample '" + sampleName + "' of channel '" + channelName + "'"; - const int interp = interpolationCode(mod, defaultInterpolation, InterpolationClass::Flexible, context); + const int interp = interpolationCode(mod, defaultInterpolation, InterpolationClass::Flexible, context, + legacyInterpolationDefault); double low = data["lo"].val_double(); double high = data["hi"].val_double(); @@ -701,11 +754,10 @@ bool importHistSample(RooJSONFactoryWSTool &tool, RooDataHist &dh, RooArgSet con overall_high.push_back(high); overall_interp.push_back(interp); - constraints.add(getOrCreateConstraint(tool, mod, par, sampleName)); + addSysConstraint(tool, mod, par, sysname, sampleName, warnedUnconstrained, constraints); } else if (modtype == "histosys") { auto *parameter = mod.find("parameter"); std::string parname(parameter ? parameter->val() : "alpha_" + sysname); - createNominal(ws, parname, 0.0, -10, 10); auto &par = getOrCreate(ws, parname, 0., -5, 5); histNps.add(par); auto &data = mod["data"]; @@ -717,8 +769,9 @@ bool importHistSample(RooJSONFactoryWSTool &tool, RooDataHist &dh, RooArgSet con RooJSONFactoryWSTool::readBinnedData(data["hi"], sysname + "High_" + prefixedName, varlist))); const std::string context = "interpolation for histosys modifier '" + sysname + "' in sample '" + sampleName + "' of channel '" + channelName + "'"; - histoInterp.push_back(interpolationCode(mod, defaultInterpolation, InterpolationClass::Piecewise, context)); - constraints.add(getOrCreateConstraint(tool, mod, par, sampleName)); + histoInterp.push_back(interpolationCode(mod, defaultInterpolation, InterpolationClass::Piecewise, context, + legacyInterpolationDefault)); + addSysConstraint(tool, mod, par, sysname, sampleName, warnedUnconstrained, constraints); } else if (modtype == "shapesys" || modtype == "shapefactor") { std::string funcName = channelName + "_" + sysname + "_ShapeSys"; // funcName should be "__ShapeSys" @@ -847,6 +900,7 @@ class HistFactoryImporter : public RooFit::JSONIO::Importer { std::vector sumW; std::vector sumW2; std::vector gammaParnames; + std::string modifierStatErrType; RooArgSet observables = RooJSONFactoryWSTool::readAxes(p); std::string fprefix = name; @@ -873,10 +927,30 @@ class HistFactoryImporter : public RooFit::JSONIO::Importer { } } } + // The constraint type given on the staterror modifier takes precedence over the channel-level setting. + if (modifierStatErrType.empty()) { + // ROOT 6.38 and 6.40.00 wrote "constraint_type". + auto constraint = staterror->find("constraint"); + if (!constraint) { + constraint = staterror->find("constraint_type"); + } + if (constraint) { + modifierStatErrType = constraint->val(); + if (modifierStatErrType != "Poisson" && modifierStatErrType != "Gauss") { + RooJSONFactoryWSTool::error("unsupported constraint '" + modifierStatErrType + + "' of the staterror modifier in channel '" + name + + "', supported are \"Poisson\" and \"Gauss\""); + } + } + } } data.emplace_back(std::move(dh)); } + if (!modifierStatErrType.empty()) { + statErrType = modifierStatErrType; + } + RooAbsArg *mcStatObject = nullptr; RooArgSet constraints; if (!sumW.empty()) { @@ -899,12 +973,16 @@ class HistFactoryImporter : public RooFit::JSONIO::Importer { observables, statErrType, defaultGammaMin, defaultStatErrorGammaMax, statErrThresh); } + LegacyInterpolationDefault legacyInterpolationDefault; + legacyInterpolationDefault.allowed = isHS3VersionBefore(tool->hs3VersionInput(), 0, 3); + bool warnedUnconstrained = false; + int idx = 0; RooArgList funcs; RooArgList coefs; for (const auto &comp : p["samples"].children()) { importHistSample(*tool, *data[idx], observables, mcStatObject, fprefix, comp, defaultInterpolation, - constraints); + legacyInterpolationDefault, warnedUnconstrained, constraints); ++idx; std::string const &compName = RooJSONFactoryWSTool::name(comp); @@ -1182,25 +1260,28 @@ NormSys parseOverallModifierFormula(const std::string &s, RooFormulaVar *formula if (constr2 && !p3) { sys.name = p2->GetName(); sys.param = p2; - sys.high = sign * toDouble(token3); - sys.low = -sign * toDouble(token3); + sys.high = 1.0 + sign * toDouble(token3); + sys.low = 1.0 - sign * toDouble(token3); } else if (!p2 && constr3) { sys.name = p3->GetName(); sys.param = p3; - sys.high = sign * toDouble(token2); - sys.low = -sign * toDouble(token2); + sys.high = 1.0 + sign * toDouble(token2); + sys.low = 1.0 - sign * toDouble(token2); } else if (constr2 && p3 && !constr3) { sys.name = v2->GetName(); sys.param = v2; - sys.high = sign * p3->getVal(); - sys.low = -sign * p3->getVal(); + sys.high = 1.0 + sign * p3->getVal(); + sys.low = 1.0 - sign * p3->getVal(); } else if (p2 && !constr2 && constr3) { sys.name = v3->GetName(); sys.param = v3; - sys.high = sign * p2->getVal(); - sys.low = -sign * p2->getVal(); + sys.high = 1.0 + sign * p2->getVal(); + sys.low = 1.0 - sign * p2->getVal(); } + // A recognized formula has exactly one constrained parameter. + sys.constraint = constr2 ? constr2 : constr3; + // Preserve the legacy export behaviour for recognized explicit formulae. sys.interpolation = multiplicativePiecewiseExponential; @@ -1267,6 +1348,7 @@ struct Channel { std::map tot_yield; std::map tot_yield2; std::map rel_errors; + std::string statErrorConstraintType; RooArgSet const *varSet = nullptr; long unsigned int nBins = 0; }; @@ -1329,17 +1411,14 @@ Channel readChannel(RooJSONFactoryWSTool *tool, const std::string &pdfname, cons RooAbsReal *var = static_cast(fip->variables().at(i)); std::string sysname(var->GetName()); erasePrefix(sysname, "alpha_"); + // A parameter without constraint is unconstrained, see checkUnconstrainedModifiers(). const auto *constraint = findConstraint(var); - if (!constraint && !var->isConstant()) { - RooJSONFactoryWSTool::error("cannot find constraint for " + std::string(var->GetName())); - } else { - const std::string context = "normsys modifier '" + sysname + "' in sample '" + sample.name + - "' of channel '" + channel.name + "'"; - sample.normsys.emplace_back( - sysname, var, fip->high()[i], fip->low()[i], - interpolationFromCode(fip->interpolationCodes()[i], InterpolationClass::Flexible, context), - constraint); - } + const std::string context = "normsys modifier '" + sysname + "' in sample '" + sample.name + + "' of channel '" + channel.name + "'"; + sample.normsys.emplace_back( + sysname, var, fip->high()[i], fip->low()[i], + interpolationFromCode(fip->interpolationCodes()[i], InterpolationClass::Flexible, context), + constraint); } } else if (!pip && (pip = dynamic_cast(e))) { // nothing to do here, already assigned @@ -1412,17 +1491,14 @@ Channel readChannel(RooJSONFactoryWSTool *tool, const std::string &pdfname, cons erasePrefix(sysname, "alpha_"); if (auto lo = dynamic_cast(pip->lowList().at(i))) { if (auto hi = dynamic_cast(pip->highList().at(i))) { + // A parameter without constraint is unconstrained, see checkUnconstrainedModifiers(). const auto *constraint = findConstraint(var); - if (!constraint && !var->isConstant()) { - RooJSONFactoryWSTool::error("cannot find constraint for " + std::string(var->GetName())); - } else { - const std::string context = "histosys modifier '" + sysname + "' in sample '" + sample.name + - "' of channel '" + channel.name + "'"; - sample.histosys.emplace_back( - sysname, var, lo, hi, - interpolationFromCode(pip->interpolationCodes()[i], InterpolationClass::Piecewise, context), - constraint); - } + const std::string context = "histosys modifier '" + sysname + "' in sample '" + sample.name + + "' of channel '" + channel.name + "'"; + sample.histosys.emplace_back( + sysname, var, lo, hi, + interpolationFromCode(pip->interpolationCodes()[i], InterpolationClass::Piecewise, context), + constraint); } } } @@ -1440,6 +1516,7 @@ Channel readChannel(RooJSONFactoryWSTool *tool, const std::string &pdfname, cons channel.tot_yield2[idx] += (sample.hist[idx - 1] * sample.hist[idx - 1]); if (constraint) { channel.rel_errors[idx] = constraintRelError(*constraint, *g); + channel.statErrorConstraintType = dynamic_cast(constraint) ? "Poisson" : "Gauss"; } } sample.useBarlowBeestonLight = true; @@ -1692,6 +1769,10 @@ bool exportChannel(RooJSONFactoryWSTool *tool, const Channel &channel, JSONNode }; elem["type"] << "histfactory_dist"; + // The constraint type of the staterror is also written on the channel level, where older RooFit versions read it. + if (!channel.statErrorConstraintType.empty()) { + elem[::Literals::staterror].set_map()["constraint_type"] << channel.statErrorConstraintType; + } const auto channelDefaultInterpolation = defaultInterpolation(channel); if (channelDefaultInterpolation) { writeInterpolation(elem["default_interpolation"], *channelDefaultInterpolation); @@ -1773,6 +1854,9 @@ bool exportChannel(RooJSONFactoryWSTool *tool, const Channel &channel, JSONNode if (sample.useBarlowBeestonLight) { auto &mod = addModifier(modifiers, ::Literals::staterror, ::Literals::staterror); exportGammaParameters(mod, sample.staterrorParameters); + if (!channel.statErrorConstraintType.empty()) { + mod["constraint"] << channel.statErrorConstraintType; + } } if (!observablesWritten) { @@ -1845,6 +1929,33 @@ std::vector findLostConstraints(const Channel &channel, const std:: return lostConstraints; } +// A normsys or histosys modifier without a recognized constraint is exported as unconstrained. That is only correct if +// its parameter is a variable, and none of the other constraint terms of the channel depends on it. +void checkUnconstrainedModifiers(const Channel &channel, const std::vector &constraints) +{ + auto check = [&](auto const &modifiers) { + for (auto const &sys : modifiers) { + if (sys.constraint || sys.param->isConstant()) { + continue; + } + if (!dynamic_cast(sys.param)) { + RooJSONFactoryWSTool::error("cannot find constraint for " + std::string(sys.param->GetName()) + + ": functions are not supported as parameters of this modifier"); + } + for (RooAbsPdf const *pdf : constraints) { + if (pdf->dependsOn(*sys.param)) { + RooJSONFactoryWSTool::error("cannot find constraint for " + std::string(sys.param->GetName()) + + ": the constraint term '" + pdf->GetName() + "' is not supported"); + } + } + } + }; + for (const auto &sample : channel.samples) { + check(sample.normsys); + check(sample.histosys); + } +} + bool tryExportHistFactory(RooJSONFactoryWSTool *tool, const std::string &pdfname, const RooRealSumPdf *sumpdf, std::vector constraints, JSONNode &elem) { @@ -1871,6 +1982,7 @@ bool tryExportHistFactory(RooJSONFactoryWSTool *tool, const std::string &pdfname } canonicalizeModifiers(channel); + checkUnconstrainedModifiers(channel, constraints); // stat error handling configureStatError(channel); diff --git a/roofit/hs3/src/JSONFactories_RooFitCore.cxx b/roofit/hs3/src/JSONFactories_RooFitCore.cxx index f809bbe07fcc1..ce7102885bc66 100644 --- a/roofit/hs3/src/JSONFactories_RooFitCore.cxx +++ b/roofit/hs3/src/JSONFactories_RooFitCore.cxx @@ -161,10 +161,102 @@ void replaceIdentifier(TString &expr, std::string_view identifier, std::string_v expr = out.c_str(); } +bool isIdentifierChar(char c) +{ + return std::isalnum(static_cast(c)) || c == '_'; +} + +std::string trimmed(std::string const &str) +{ + const auto begin = str.find_first_not_of(" \t\n"); + if (begin == std::string::npos) { + return ""; + } + return str.substr(begin, str.find_last_not_of(" \t\n") - begin + 1); +} + +// Index of the bracket that closes the one at `open`, or std::string::npos if it is not closed. +std::size_t findClosingBracket(std::string const &str, std::size_t open) +{ + int depth = 0; + for (std::size_t i = open; i < str.size(); ++i) { + if (str[i] == '(' || str[i] == '[') { + ++depth; + } else if ((str[i] == ')' || str[i] == ']') && --depth == 0) { + return i; + } + } + return std::string::npos; +} + +// Split `str` at the commas that are not enclosed in brackets. +std::vector splitTopLevelCommas(std::string const &str) +{ + std::vector parts; + int depth = 0; + std::size_t start = 0; + for (std::size_t i = 0; i < str.size(); ++i) { + if (str[i] == '(' || str[i] == '[') { + ++depth; + } else if (str[i] == ')' || str[i] == ']') { + --depth; + } else if (str[i] == ',' && depth == 0) { + parts.push_back(str.substr(start, i - start)); + start = i + 1; + } + } + parts.push_back(str.substr(start)); + return parts; +} + +// Remove parentheses that enclose the whole string, e.g. "((a+b))" becomes "a+b". +std::string stripEnclosingParentheses(std::string str) +{ + str = trimmed(str); + while (str.size() >= 2 && str.front() == '(' && findClosingBracket(str, 0) == str.size() - 1) { + str = trimmed(str.substr(1, str.size() - 2)); + } + return str; +} + +// Rewrite the HS3 conditional "ifelse(c,a,b)" into the C-style ternary "((c)?(a):(b))" that TFormula understands. +// Malformed calls are left unchanged, so that TFormula reports them. +std::string ifElseToTernary(std::string const &expr) +{ + static const std::string keyword = "ifelse"; + std::string out; + std::size_t pos = 0; + while (pos < expr.size()) { + const std::size_t found = expr.find(keyword, pos); + if (found == std::string::npos) { + break; + } + const std::size_t open = expr.find_first_not_of(" \t", found + keyword.size()); + const bool isCall = + (found == 0 || !isIdentifierChar(expr[found - 1])) && open != std::string::npos && expr[open] == '('; + const std::size_t close = isCall ? findClosingBracket(expr, open) : std::string::npos; + const auto args = close != std::string::npos ? splitTopLevelCommas(expr.substr(open + 1, close - open - 1)) + : std::vector{}; + if (args.size() != 3) { + out += expr.substr(pos, found + keyword.size() - pos); + pos = found + keyword.size(); + continue; + } + out += expr.substr(pos, found - pos); + out += "((" + ifElseToTernary(trimmed(args[0])) + ")?(" + ifElseToTernary(trimmed(args[1])) + "):(" + + ifElseToTernary(trimmed(args[2])) + "))"; + pos = close + 1; + } + return out + expr.substr(pos); +} + void translateImportedExpression(TString &expr) { replaceIdentifier(expr, "PI", "TMath::Pi()"); replaceIdentifier(expr, "EULER", "TMath::E()"); + if (std::string(expr.Data()).find("ifelse") != std::string::npos) { + expr = ifElseToTernary(expr.Data()).c_str(); + } } int readPositiveInteger(const JSONNode &node, const std::string &context) @@ -320,6 +412,28 @@ bool importAddition(RooJSONFactoryWSTool *tool, const JSONNode &p) bool importAddPdf(RooJSONFactoryWSTool *tool, const JSONNode &p) { std::string name(RooJSONFactoryWSTool::name(p)); + if (p.has_child("extended")) { + // Check the explicit "extended" flag against the number of coefficients, which determines the extended mode of + // the RooAddPdf. + const bool extended = p["extended"].val_bool(); + const std::size_t nSummands = p.has_child("summands") ? p["summands"].num_children() : 0; + const std::size_t nCoefficients = p.has_child("coefficients") ? p["coefficients"].num_children() : 0; + const std::string counts = + std::to_string(nCoefficients) + " coefficients for " + std::to_string(nSummands) + " summands"; + if (extended && nCoefficients > 0 && nCoefficients + 1 == nSummands) { + RooJSONFactoryWSTool::error("mixture_dist '" + name + "' is declared as extended, but it has " + counts + + ", which defines a non-extended mixture"); + } + if (!extended && nCoefficients == 0 && nSummands > 0) { + RooJSONFactoryWSTool::error("mixture_dist '" + name + "' is declared as not extended, but it has " + counts + + ", which defines an extended mixture of extended distributions"); + } + if (!extended && nCoefficients == nSummands) { + RooJSONFactoryWSTool::warning("RooFitHS3: mixture_dist '" + name + "' is declared as not extended, but " + + "RooFit can use it as extended because it has one coefficient per summand. " + + "Don't use the extended likelihood term when fitting it."); + } + } if (!tool->requestArgList(p, "coefficients").empty()) { tool->wsEmplace(name, tool->requestArgList(p, "summands"), tool->requestArgList(p, "coefficients")); @@ -515,8 +629,9 @@ bool importFFTConvPdf(RooJSONFactoryWSTool *tool, const JSONNode &p) bool importExtendPdf(RooJSONFactoryWSTool *tool, const JSONNode &p) { std::string name(RooJSONFactoryWSTool::name(p)); - RooAbsPdf *pdf = tool->requestArg(p, "pdf"); - RooAbsReal *norm = tool->requestArg(p, "norm"); + // HS3 calls the fields "dist" and "rate". RooFit used to write "pdf" and "norm", which are still accepted. + RooAbsPdf *pdf = tool->requestArg(p, p.has_child("pdf") && !p.has_child("dist") ? "pdf" : "dist"); + RooAbsReal *norm = tool->requestArg(p, p.has_child("norm") && !p.has_child("rate") ? "norm" : "rate"); if (p.has_child("range")) { std::string rangeName = p["range"].val(); tool->wsEmplace(name, *pdf, *norm, rangeName.c_str()); @@ -602,8 +717,22 @@ bool importMultiVarGaussian(RooJSONFactoryWSTool *tool, const JSONNode &p) TMatrixDSym covmat; + // The matrix must be square, otherwise the entries would be written out of bounds. + auto checkSquare = [&](const char *key, std::size_t n) { + if (p[key].num_children() != n) { + RooJSONFactoryWSTool::error("\"" + std::string(key) + "\" in '" + name + "' must have " + std::to_string(n) + + " rows"); + } + for (const auto &row : p[key].children()) { + if (!row.is_seq() || row.num_children() != n) { + RooJSONFactoryWSTool::error("\"" + std::string(key) + "\" in '" + name + "' must be a square matrix"); + } + } + }; + if (has_cov) { int n = p["covariances"].num_children(); + checkSquare("covariances", n); int i = 0; covmat.ResizeTo(n, n); for (const auto &row : p["covariances"].children()) { @@ -617,6 +746,7 @@ bool importMultiVarGaussian(RooJSONFactoryWSTool *tool, const JSONNode &p) } else { std::vector variances; variances << p["standard_deviations"]; + checkSquare("correlations", variances.size()); covmat.ResizeTo(variances.size(), variances.size()); int i = 0; for (const auto &row : p["correlations"].children()) { @@ -715,6 +845,30 @@ bool importSpline(RooJSONFactoryWSTool *tool, const JSONNode &p) return true; } +// Importer that is registered with top priority for a deprecated type key. It warns once, and then lets the importers +// or the factory expression that are registered for the deprecated key do the actual work. +class DeprecatedKeyImporter : public RooFit::JSONIO::Importer { +public: + DeprecatedKeyImporter(std::string key, std::string replacement) + : _key{std::move(key)}, _replacement{std::move(replacement)} + { + } + bool importArg(RooJSONFactoryWSTool *, const JSONNode &) const override + { + if (!_warned) { + RooJSONFactoryWSTool::warning("RooFitHS3: the type \"" + _key + "\" is deprecated, please use \"" + + _replacement + "\" instead."); + _warned = true; + } + return false; + } + +private: + const std::string _key; + const std::string _replacement; + mutable bool _warned = false; +}; + /////////////////////////////////////////////////////////////////////////////////////////////////////// // specialized exporter implementations /////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -815,6 +969,123 @@ void cleanExpression(TString &expr) replaceIdentifier(expr, "TMath::E()", "EULER"); } +bool ternariesToIfElse(std::string const &in, std::string &out); + +// Rewrite one comma-free part of an expression, see ternariesToIfElse(). +bool ternaryPartToIfElse(std::string const &in, std::string &out) +{ + // Find the first '?' that is not enclosed in brackets. A ':' that is not part of "::" can only follow a '?'. + std::size_t question = std::string::npos; + int depth = 0; + for (std::size_t i = 0; i < in.size() && question == std::string::npos; ++i) { + const char c = in[i]; + if (c == '(' || c == '[') { + ++depth; + } else if (c == ')' || c == ']') { + --depth; + } else if (c == ':' && i + 1 < in.size() && in[i + 1] == ':') { + ++i; + } else if (depth == 0 && c == ':') { + return false; + } else if (depth == 0 && c == '?') { + question = i; + } else if (depth == 0 && c == ',') { + return false; + } + } + + if (question == std::string::npos) { + // No ternary on this level: copy the expression and rewrite the ternaries inside of brackets. + out.clear(); + for (std::size_t i = 0; i < in.size(); ++i) { + if (in[i] == ')' || in[i] == ']') { + return false; + } + if (in[i] != '(' && in[i] != '[') { + out += in[i]; + continue; + } + const std::size_t close = findClosingBracket(in, i); + if (close == std::string::npos) { + return false; + } + const std::string inner = in.substr(i + 1, close - i - 1); + std::string innerOut; + if (!ternariesToIfElse(inner, innerOut)) { + return false; + } + // Parentheses that only group a ternary are redundant around the ifelse() function call. + const std::string before = trimmed(in.substr(0, i)); + const bool isGrouping = in[i] == '(' && (before.empty() || !isIdentifierChar(before.back())); + if (isGrouping && innerOut != trimmed(inner) && startsWith(innerOut, "ifelse(") && + findClosingBracket(innerOut, std::string("ifelse").size()) == innerOut.size() - 1) { + out += innerOut; + } else { + out += in[i] + innerOut + in[close]; + } + i = close; + } + return true; + } + + // Find the ':' that belongs to this '?', taking nested ternaries into account. + std::size_t colon = std::string::npos; + int nested = 0; + depth = 0; + for (std::size_t i = question + 1; i < in.size() && colon == std::string::npos; ++i) { + const char c = in[i]; + if (c == '(' || c == '[') { + ++depth; + } else if (c == ')' || c == ']') { + --depth; + } else if (c == ':' && i + 1 < in.size() && in[i + 1] == ':') { + ++i; + } else if (depth == 0 && c == '?') { + ++nested; + } else if (depth == 0 && c == ':') { + if (nested == 0) { + colon = i; + } else { + --nested; + } + } + } + if (colon == std::string::npos) { + return false; + } + + std::string condition; + std::string ifTrue; + std::string ifFalse; + if (!ternaryPartToIfElse(stripEnclosingParentheses(in.substr(0, question)), condition) || + !ternaryPartToIfElse(stripEnclosingParentheses(in.substr(question + 1, colon - question - 1)), ifTrue) || + !ternaryPartToIfElse(stripEnclosingParentheses(in.substr(colon + 1)), ifFalse)) { + return false; + } + if (condition.empty() || ifTrue.empty() || ifFalse.empty()) { + return false; + } + out = "ifelse(" + condition + "," + ifTrue + "," + ifFalse + ")"; + return true; +} + +// Rewrite the C-style ternaries "c ? a : b" in a TFormula expression into the HS3 conditional "ifelse(c,a,b)". Returns +// false if this is not safely possible, e.g. because of unbalanced brackets. +bool ternariesToIfElse(std::string const &in, std::string &out) +{ + out.clear(); + bool first = true; + for (std::string const &part : splitTopLevelCommas(in)) { + std::string partOut; + if (!ternaryPartToIfElse(part, partOut)) { + return false; + } + out += (first ? "" : ",") + partOut; + first = false; + } + return true; +} + template bool exportFormulaArg(RooJSONFactoryWSTool *, const RooAbsArg *func, JSONNode &elem, std::string const &key) { @@ -833,6 +1104,16 @@ bool exportFormulaArg(RooJSONFactoryWSTool *, const RooAbsArg *func, JSONNode &e expression.ReplaceAll(("x[" + std::to_string(idx) + "]").c_str(), par->GetName()); expression.ReplaceAll(("@" + std::to_string(idx)).c_str(), par->GetName()); } + if (expression.Contains("?")) { + std::string rewritten; + if (ternariesToIfElse(expression.Data(), rewritten)) { + expression = rewritten.c_str(); + } else { + RooJSONFactoryWSTool::warning(std::string("RooFitHS3: could not translate the ternary operators in the " + "expression of '") + + func->GetName() + "' to ifelse(), exporting the expression unchanged."); + } + } elem["expression"] << expression.Data(); for (const RooAbsArg *dependent : pdf->dependents()) { @@ -1038,8 +1319,8 @@ bool exportExtendPdf(RooJSONFactoryWSTool *, const RooAbsArg *func, JSONNode &el if (auto rangeName = pdf->getRangeName()) { elem["range"] << rangeName->GetName(); } - elem["pdf"] << pdf->pdf().GetName(); - elem["norm"] << pdf->getN().GetName(); + elem["dist"] << pdf->pdf().GetName(); + elem["rate"] << pdf->getN().GetName(); return true; } @@ -1178,12 +1459,16 @@ void registerExporter(TClass const *cl, std::string key, bool topPriority = true STATIC_EXECUTE([]() { registerImporter("density_function_dist"); registerImporter("rate_extended_dist"); + RooFit::JSONIO::registerImporter("ARGUS_dist", std::make_unique("ARGUS_dist", "argus_dist")); + RooFit::JSONIO::registerImporter("binsampling", + std::make_unique("binsampling", "binsampling_dist")); registerImporter("product", false); registerImporter("product_dist", false); registerImporter("sum", false); registerImporter("mixture_dist", false); registerImporter("mixture_resolution_model", false); registerImporter("binsampling_dist", false); + registerImporter("binsampling", false); registerImporter>("binvolume", false); registerImporter>("inverse_binvolume", false); registerImporter>("legacy_exp_poly_dist", false); @@ -1214,7 +1499,7 @@ STATIC_EXECUTE([]() { registerExporter(RooWrapperPdf::Class(), "density_function_dist"); registerExporter>(RooAddPdf::Class(), "mixture_dist", false); registerExporter>(RooAddModel::Class(), "mixture_resolution_model", false); - registerExporter(RooBinSamplingPdf::Class(), "binsampling", false); + registerExporter(RooBinSamplingPdf::Class(), "binsampling_dist", false); registerExporter(RooBinWidthFunction::Class(), "binvolume", false); registerExporter>(RooLegacyExpPoly::Class(), "legacy_exp_poly_dist", false); registerExporter(RooExponential::Class(), "exponential_dist", false); diff --git a/roofit/hs3/src/JSONIOUtils.cxx b/roofit/hs3/src/JSONIOUtils.cxx index 04e4494712130..dd634f469444e 100644 --- a/roofit/hs3/src/JSONIOUtils.cxx +++ b/roofit/hs3/src/JSONIOUtils.cxx @@ -1,8 +1,11 @@ #include "JSONIOUtils.h" #include +#include +#include #include +#include using RooFit::Detail::JSONNode; using RooFit::Detail::JSONTree; @@ -100,3 +103,67 @@ void writeAxisBinning(JSONNode &node, const RooAbsBinning &binning) edges.append_child() << binning.binHigh(i); } } + +// Read a bound of an axis. Infinite bounds are written as null. +double readBound(JSONNode const &node, const char *key, double defaultValue) +{ + if (!node.has_child(key)) { + return defaultValue; + } + auto const &bound = node[key]; + return bound.is_null() ? defaultValue : bound.val_double(); +} + +// Parse the leading "MAJOR.MINOR" of an HS3 version string like "0.2", "0.2.9" or "0.3". +bool parseHS3Version(std::string const &version, int &major, int &minor) +{ + std::size_t pos = 0; + auto readNumber = [&](int &out) { + const std::size_t begin = pos; + while (pos < version.size() && std::isdigit(static_cast(version[pos]))) { + ++pos; + } + // Reject empty and absurdly long numbers, which std::stoi could not represent. + if (pos == begin || pos - begin > 9) { + return false; + } + out = std::stoi(version.substr(begin, pos - begin)); + return true; + }; + if (!readNumber(major) || pos >= version.size() || version[pos] != '.') { + return false; + } + ++pos; + return readNumber(minor); +} + +// Whether an HS3 version predates `major.minor`. Versions that can't be parsed count as old. That includes the empty +// version of objects that are imported without a document. +bool isHS3VersionBefore(std::string const &version, int major, int minor) +{ + int versionMajor = 0; + int versionMinor = 0; + if (!parseHS3Version(version, versionMajor, versionMinor)) { + return true; + } + return std::make_pair(versionMajor, versionMinor) < std::make_pair(major, minor); +} + +// Read the bin edges of an axis, which must be at least two strictly ascending numbers. +std::vector readEdges(const JSONNode &node, std::string const &context) +{ + if (!node.is_seq()) { + RooJSONFactoryWSTool::error("\"edges\" of " + context + " must be a list"); + } + std::vector edges; + for (auto const &edge : node.children()) { + edges.push_back(edge.val_double()); + if (edges.size() > 1 && !(edges.back() > edges[edges.size() - 2])) { + RooJSONFactoryWSTool::error("\"edges\" of " + context + " must be strictly ascending"); + } + } + if (edges.size() < 2) { + RooJSONFactoryWSTool::error("\"edges\" of " + context + " must contain at least two values"); + } + return edges; +} diff --git a/roofit/hs3/src/JSONIOUtils.h b/roofit/hs3/src/JSONIOUtils.h index e38c821a92556..2ac14662be7dc 100644 --- a/roofit/hs3/src/JSONIOUtils.h +++ b/roofit/hs3/src/JSONIOUtils.h @@ -2,6 +2,7 @@ #define JSONIOUtils_h #include +#include #include class RooAbsBinning; @@ -11,5 +12,9 @@ bool endsWith(std::string_view str, std::string_view suffix); std::string removeSuffix(std::string_view str, std::string_view suffix); std::unique_ptr varJSONString(const RooFit::Detail::JSONNode &treeRoot); void writeAxisBinning(RooFit::Detail::JSONNode &node, const RooAbsBinning &binning); +double readBound(RooFit::Detail::JSONNode const &node, const char *key, double defaultValue); +bool parseHS3Version(std::string const &version, int &major, int &minor); +bool isHS3VersionBefore(std::string const &version, int major, int minor); +std::vector readEdges(const RooFit::Detail::JSONNode &node, std::string const &context); #endif diff --git a/roofit/hs3/src/RooFitHS3_wsexportkeys.cxx b/roofit/hs3/src/RooFitHS3_wsexportkeys.cxx index 20423610811ac..fec22e4350490 100644 --- a/roofit/hs3/src/RooFitHS3_wsexportkeys.cxx +++ b/roofit/hs3/src/RooFitHS3_wsexportkeys.cxx @@ -8,7 +8,7 @@ auto RooFitHS3_wsexportkeys = R"({ } }, "RooArgusBG": { - "type": "ARGUS_dist", + "type": "argus_dist", "proxies": { "m": "mass", "m0": "resonance", diff --git a/roofit/hs3/src/RooFitHS3_wsfactoryexpressions.cxx b/roofit/hs3/src/RooFitHS3_wsfactoryexpressions.cxx index 6076465bcb8ea..a938956e564c2 100644 --- a/roofit/hs3/src/RooFitHS3_wsfactoryexpressions.cxx +++ b/roofit/hs3/src/RooFitHS3_wsfactoryexpressions.cxx @@ -1,6 +1,15 @@ namespace { auto RooFitHS3_wsfactoryexpressions = R"({ + "argus_dist": { + "class": "RooArgusBG", + "arguments": [ + "mass", + "resonance", + "slope", + "power" + ] + }, "ARGUS_dist": { "class": "RooArgusBG", "arguments": [ diff --git a/roofit/hs3/src/RooJSONFactoryWSTool.cxx b/roofit/hs3/src/RooJSONFactoryWSTool.cxx index 1e5a245fcbb82..9f372161b8e7c 100644 --- a/roofit/hs3/src/RooJSONFactoryWSTool.cxx +++ b/roofit/hs3/src/RooJSONFactoryWSTool.cxx @@ -14,6 +14,7 @@ #include #include +#include #include #include #include @@ -442,8 +443,36 @@ void getObservables(RooWorkspace const &ws, const JSONNode &node, RooAbsCollecti } } +// Make the ranges of the observables of unbinned data consistent with the "min" and "max" of the axes. Variables that +// didn't get their range from a domain take it from the axis. If `domains` is null, all ranges are considered explicit. +void applyUnbinnedAxisRanges(const JSONNode &p, RooWorkspace &workspace, RooFit::JSONIO::Detail::Domains const *domains) +{ + for (const auto &axis : p["axes"].children()) { + if (!axis.has_child("min") || !axis.has_child("max")) { + continue; + } + const std::string name = RooJSONFactoryWSTool::name(axis); + RooRealVar *var = workspace.var(name); + if (!var) { + continue; // reported by getObservables() + } + const double min = readBound(axis, "min", -RooNumber::infinity()); + const double max = readBound(axis, "max", RooNumber::infinity()); + if (domains && !domains->hasVariable(name.c_str())) { + var->setRange(min, max); + } else if (var->getMin() != min || var->getMax() != max) { + std::stringstream ss; + ss << "RooFitHS3: the range [" << min << ", " << max << "] of the axis '" << name << "' of the dataset '" + << RooJSONFactoryWSTool::name(p) << "' differs from the range [" << var->getMin() << ", " << var->getMax() + << "] of the variable."; + RooJSONFactoryWSTool::warning(ss.str()); + } + } +} + // Create a RooAbsData (binned or unbinned) from the JSON node `p`. -std::unique_ptr loadData(const JSONNode &p, RooWorkspace &workspace) +std::unique_ptr +loadData(const JSONNode &p, RooWorkspace &workspace, RooFit::JSONIO::Detail::Domains const *domains) { std::string name(RooJSONFactoryWSTool::name(p)); @@ -457,6 +486,7 @@ std::unique_ptr loadData(const JSONNode &p, RooWorkspace &workspace) // unbinned RooArgList varlist; getObservables(workspace, p, varlist); + applyUnbinnedAxisRanges(p, workspace, domains); RooArgSet vars(varlist); auto data = std::make_unique(name, name, vars, RooFit::WeightVar()); auto &coords = p["entries"]; @@ -631,8 +661,28 @@ void importAnalysis(const JSONNode &rootnode, const JSONNode &analysisNode, cons } } - if (analysisNode.has_child("init") && workspace.getSnapshot(analysisNode["init"].val().c_str())) { - mc->SetSnapshot(*workspace.getSnapshot(analysisNode["init"].val().c_str())); + if (analysisNode.has_child("init")) { + const std::string initName = analysisNode["init"].val(); + RooArgSet const *init = workspace.getSnapshot(initName.c_str()); + if (!init) { + RooJSONFactoryWSTool::warning("RooFitHS3: the parameter point '" + initName + + "' used as \"init\" of analysis '" + analysisName + "' does not exist."); + return; + } + // ModelConfig::SetSnapshot() saves the snapshot as "__snapshot". If "init" already follows + // this pattern, which is the case for files written by RooFit, pick the set name such that it is preserved. + std::string setName = initName; + const std::string prefix = mcname + "_"; + const std::string suffix = "_snapshot"; + if (initName.size() >= prefix.size() + suffix.size() && startsWith(initName, prefix) && + endsWith(initName, suffix)) { + setName = initName.substr(prefix.size(), initName.size() - prefix.size() - suffix.size()); + } + // Take an owning copy: SetSnapshot() might replace the workspace snapshot that `init` points to. + RooArgSet initSet; + init->snapshot(initSet); + initSet.setName(setName.c_str()); + mc->SetSnapshot(initSet); } } @@ -755,6 +805,62 @@ RooAbsCategory *findCategoryObservable(RooAbsData const &data) return cat; } +/// Warn if the HS3 version of an imported document is not understood or newer than what RooFit writes. +void checkHS3Version(std::string const &version) +{ + int major = 0; + int minor = 0; + int supportedMajor = 0; + int supportedMinor = 0; + parseHS3Version(hs3VersionTag, supportedMajor, supportedMinor); + if (!parseHS3Version(version, major, minor)) { + RooJSONFactoryWSTool::warning("RooFitHS3: cannot parse hs3_version '" + version + + "', expected a string of the form \"MAJOR.MINOR\"."); + return; + } + if (std::make_pair(major, minor) > std::make_pair(supportedMajor, supportedMinor)) { + RooJSONFactoryWSTool::warning("RooFitHS3: the document has hs3_version '" + version + + "', which is newer than the version supported by this RooFit ('" + hs3VersionTag + + "'). Some content might not be imported correctly."); + } +} + +/// HS3 requires the names of all top-level objects to be unique within a document. Throw if a name is used twice, or +/// only warn if not `strict`: RooFit wrote such documents before HS3 v0.3, e.g. for a range and a pdf of one name. +void checkUniqueNames(JSONNode const &rootnode, bool strict) +{ + std::map sectionOfName; + for (const char *section : + {"distributions", "functions", "data", "domains", "parameter_points", "likelihoods", "analyses"}) { + auto seq = rootnode.find(section); + if (!seq || !seq->is_seq()) { + continue; + } + for (JSONNode const &child : seq->children()) { + if (!child.is_map() || !child.has_child("name")) { + continue; // malformed entries are reported when they are imported + } + std::string const name = child["name"].val(); + auto inserted = sectionOfName.emplace(name, section); + if (inserted.second) { + continue; + } + std::string const &firstSection = inserted.first->second; + // RooFit used to give analyses and likelihoods the name of the pdf or the analysis. Accept this for backwards + // compatibility: analyses are not referenced by name, and likelihoods only from analyses, so there is no + // ambiguity. + auto isLegacyNamed = [](std::string const &s) { return s == "likelihoods" || s == "analyses"; }; + if (!strict || isLegacyNamed(firstSection) || isLegacyNamed(section)) { + RooJSONFactoryWSTool::warning("RooFitHS3: the name '" + name + "' is used in \"" + firstSection + + "\" and in \"" + section + "\", which is not allowed by the HS3 standard."); + continue; + } + RooJSONFactoryWSTool::error("duplicate name '" + name + "': it is defined in \"" + firstSection + + "\" and in \"" + section + "\", but names must be unique within the document"); + } + } +} + } // namespace RooJSONFactoryWSTool::RooJSONFactoryWSTool(RooWorkspace &ws) : _workspace{ws} {} @@ -960,6 +1066,10 @@ std::string RooJSONFactoryWSTool::exportTransformed(const RooAbsReal *original, const std::string &formula) { std::string newname = std::string(original->GetName()) + suffix; + // Another object that shares the parameter might have exported the helper already. + if (findNamedChild((*_rootnodeOutput)["functions"], newname)) { + return newname; + } RooFit::Detail::JSONNode &trafo_node = appendNamedChild((*_rootnodeOutput)["functions"], newname); trafo_node["type"] << "generic"; trafo_node["expression"] << TString::Format(formula.c_str(), original->GetName()).Data(); @@ -1305,7 +1415,7 @@ std::string makeValidNameOrError(std::string const &in) } std::string out = RooFit::Detail::makeValidVarName(in); if (out != in) { - oocoutW(nullptr, IO) << "RooFitHS3: changed '" << in << "' to '" << out << "' to become a valid name"; + oocoutW(nullptr, ObjectHandling) << "RooFitHS3: changed '" << in << "' to '" << out << "' to become a valid name"; } return out; } @@ -1443,7 +1553,12 @@ void RooJSONFactoryWSTool::exportData(RooAbsData const &data) auto &observablesNode = output["axes"].set_seq(); for (auto *var : static_range_cast(variables)) { _domains->readVariable(*var); - exportAxis(observablesNode.append_child().set_map(), *var); + // HS3 forbids binning information on the axes of unbinned data. + auto &axis = observablesNode.append_child().set_map(); + testValidName(var->GetName(), false); + axis["name"] << var->GetName(); + axis["min"] << var->getMin(); + axis["max"] << var->getMax(); } auto &coords = output["entries"].set_seq(); std::vector weightVals; @@ -1477,10 +1592,7 @@ RooArgSet RooJSONFactoryWSTool::readAxes(const JSONNode &topNode) for (JSONNode const &node : topNode["axes"].children()) { if (node.has_child("edges")) { - std::vector edges; - for (auto const &bound : node["edges"].children()) { - edges.push_back(bound.val_double()); - } + std::vector edges = readEdges(node["edges"], "axis '" + node["name"].val() + "'"); auto obs = std::make_unique(node["name"].val().c_str(), node["name"].val().c_str(), edges[0], edges[edges.size() - 1]); RooBinning bins(obs->getMin(), obs->getMax()); @@ -1527,6 +1639,11 @@ RooJSONFactoryWSTool::readBinnedData(const JSONNode &n, const std::string &name, errors = &n["errors"]; if (!errors->is_seq()) RooJSONFactoryWSTool::error("errors are not in list form"); + if (errors->num_children() != contents.num_children()) { + RooJSONFactoryWSTool::error("inconsistent number of errors and contents in '" + name + + "': errors=" + std::to_string(errors->num_children()) + + ", contents=" + std::to_string(contents.num_children())); + } } auto bins = generateBinIndices(vars); @@ -1547,6 +1664,11 @@ RooJSONFactoryWSTool::readBinnedData(const JSONNode &n, const std::string &name, errorVals.push_back(err.val_double()); } } + // RooDataHist only starts to store squared weights when it sees a positive error, and then initializes them from + // the weights so far. Trigger that before setting any weight, so that errors of zero stay zero. + if (!errorVals.empty()) { + dh->set(0, 0., 1.); + } for (size_t ibin = 0; ibin < bins.size(); ++ibin) { const double err = errors ? errorVals[ibin] : -1; dh->set(ibin, contentVals[ibin], err); @@ -1611,7 +1733,7 @@ void RooJSONFactoryWSTool::exportModelConfig(JSONNode &rootnode, RooFit::ModelCo if (hasdata && !matches(combDataSets.at(i), simpdf)) continue; - std::string analysisName(simpdf->GetName()); + std::string analysisName(mc.GetName()); if (hasdata) analysisName += "_" + combDataSets[i].name; @@ -1624,12 +1746,12 @@ void RooJSONFactoryWSTool::exportModelConfig(JSONNode &rootnode, RooFit::ModelCo if (observables.equals(*(data->get()))) { std::map mapping; mapping[pdf->GetName()] = data->GetName(); - exportSingleModelConfig(rootnode, mc, std::string(pdf->GetName()) + "_" + data->GetName(), &mapping); + exportSingleModelConfig(rootnode, mc, std::string(mc.GetName()) + "_" + data->GetName(), &mapping); ++founddata; } } if (founddata == 0) { - exportSingleModelConfig(rootnode, mc, pdf->GetName(), nullptr); + exportSingleModelConfig(rootnode, mc, mc.GetName(), nullptr); } } } @@ -1644,9 +1766,11 @@ void RooJSONFactoryWSTool::exportSingleModelConfig(JSONNode &rootnode, RooFit::M auto &domains = analysisNode["domains"].set_seq(); - analysisNode["likelihood"] << analysisName; + // HS3 names must be unique across all top-level components, so the likelihood can't have the analysis name. + const std::string likelihoodName = analysisName + "_likelihood"; + analysisNode["likelihood"] << likelihoodName; - auto &nllNode = appendNamedChild(rootnode["likelihoods"], analysisName); + auto &nllNode = appendNamedChild(rootnode["likelihoods"], likelihoodName); nllNode["distributions"].set_seq(); nllNode["data"].set_seq(); @@ -1709,6 +1833,11 @@ void RooJSONFactoryWSTool::exportSingleModelConfig(JSONNode &rootnode, RooFit::M writeProductDomain("_global_observables", mc.GetGlobalObservables()); writeProductDomain("_parameters_of_interest", mc.GetParametersOfInterest()); + // The snapshots are exported as parameter points. Reference the one of the ModelConfig as the initial values. + if (_workspace.getSnapshot(mc.GetSnapshotName().c_str())) { + analysisNode["init"] << mc.GetSnapshotName(); + } + auto &modelConfigAux = getRooFitInternal(rootnode, "ModelConfigs", analysisName); modelConfigAux.set_map(); modelConfigAux["pdfName"] << pdf->GetName(); @@ -1939,12 +2068,11 @@ void RooJSONFactoryWSTool::setStringAttribute(const std::string &obj, const std: void RooJSONFactoryWSTool::importAllNodes(const JSONNode &n) { // Per HS3 standard, the hs3_version in the metadata is required. So we - // error out if it is missing. TODO: now we are only checking if the - // hs3_version tag exists, but in the future when the HS3 specification - // versions are actually frozen, we should also check if the hs3_version is - // one that RooFit can actually read. + // error out if it is missing or not a string, and warn if the document is + // newer than what this version of RooFit was written for. auto metadata = n.find("metadata"); - if (!metadata || !metadata->find("hs3_version")) { + auto versionNode = metadata ? metadata->find("hs3_version") : nullptr; + if (!versionNode) { std::stringstream ss; ss << "The HS3 version is missing in the JSON!\n" << "Please include the HS3 version in the metadata field, e.g.:\n" @@ -1954,6 +2082,12 @@ void RooJSONFactoryWSTool::importAllNodes(const JSONNode &n) << " }"; error(ss.str()); } + if (versionNode->is_container() || versionNode->is_null() || versionNode->is_number()) { + error(std::string("The HS3 version in the metadata must be a string, e.g. \"") + hs3VersionTag + "\""); + } + _hs3VersionInput = versionNode->val(); + checkHS3Version(_hs3VersionInput); + checkUniqueNames(n, !isHS3VersionBefore(_hs3VersionInput, 0, 3)); _rootnodeInput = &n; @@ -1986,6 +2120,9 @@ void RooJSONFactoryWSTool::importAllNodes(const JSONNode &n) this->importDependants(n); + // This creates the index categories, which the parameter points can refer to. + combinePdfs(*_rootnodeInput, _workspace); + if (auto paramPointsNode = n.find("parameter_points")) { for (const auto &snsh : paramPointsNode->children()) { std::string name = RooJSONFactoryWSTool::name(snsh); @@ -1993,8 +2130,36 @@ void RooJSONFactoryWSTool::importAllNodes(const JSONNode &n) RooArgSet vars; for (const auto &var : snsh["parameters"].children()) { - if (RooRealVar *rrv = _workspace.var(RooJSONFactoryWSTool::name(var))) { + const std::string varName = RooJSONFactoryWSTool::name(var); + JSONNode const *valueNode = var.find("value"); + const bool hasStringValue = valueNode && !valueNode->is_number(); + if (RooCategory *cat = _workspace.cat(varName)) { + if (hasStringValue) { + cat->setLabel(valueNode->val().c_str()); + } else if (valueNode) { + cat->setIndex(valueNode->val_int()); + } + vars.add(*cat); + continue; + } + if (hasStringValue) { + warning("RooFitHS3: ignoring the string value of parameter '" + varName + "' in parameter point '" + + name + "', because there is no category with that name."); + continue; + } + RooRealVar *rrv = _workspace.var(varName); + if (!rrv && !_workspace.arg(varName)) { + // The parameter is not used by any other component: create it. It is constant if "const" is true or + // if no domain lists it, since there is no range to vary it in. "const": false is the same as no + // "const" at all. + testValidName(varName, true); + rrv = &wsEmplace(varName, 1.); + configureVariable(*_domains, var, *rrv); + rrv->setConstant(rrv->isConstant() || !_domains->hasVariable(varName.c_str())); + } else if (rrv) { configureVariable(*_domains, var, *rrv); + } + if (rrv) { vars.add(*rrv); } } @@ -2002,8 +2167,6 @@ void RooJSONFactoryWSTool::importAllNodes(const JSONNode &n) } } - combinePdfs(*_rootnodeInput, _workspace); - // Import attributes if (_attributesNode) { for (const auto &elem : _attributesNode->children()) { @@ -2021,7 +2184,7 @@ void RooJSONFactoryWSTool::importAllNodes(const JSONNode &n) std::vector> datasets; if (auto dataNode = n.find("data")) { for (const auto &p : dataNode->children()) { - datasets.push_back(loadData(p, _workspace)); + datasets.push_back(loadData(p, _workspace, _domains.get())); } } @@ -2047,6 +2210,7 @@ void RooJSONFactoryWSTool::importAllNodes(const JSONNode &n) } _rootnodeInput = nullptr; + _hs3VersionInput.clear(); _domains.reset(); _functionsByName.clear(); _distributionsByName.clear(); @@ -2123,7 +2287,7 @@ void RooJSONFactoryWSTool::importJSONElement(const std::string &name, const std: if (isVariable) { this->importVariableElement(n); } else if (isData) { - auto absData = loadData(n, _workspace); + auto absData = loadData(n, _workspace, nullptr); _workspace.import(*absData); } else { this->importFunction(n, false); @@ -2172,7 +2336,7 @@ void RooJSONFactoryWSTool::importVariableElement(const JSONNode &elementNode) */ std::ostream &RooJSONFactoryWSTool::warning(std::string const &str) { - return RooMsgService::instance().log(nullptr, RooFit::MsgLevel::WARNING, RooFit::IO) << str << std::endl; + return RooMsgService::instance().log(nullptr, RooFit::MsgLevel::WARNING, RooFit::ObjectHandling) << str << std::endl; } /** diff --git a/roofit/hs3/test/testRooFitHS3.cxx b/roofit/hs3/test/testRooFitHS3.cxx index a972fa14cae3c..560aea148a51b 100644 --- a/roofit/hs3/test/testRooFitHS3.cxx +++ b/roofit/hs3/test/testRooFitHS3.cxx @@ -10,8 +10,11 @@ #include #include #include +#include #include +#include #include +#include #include #include #include @@ -24,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -234,12 +238,14 @@ std::size_t countOccurrences(std::string_view haystack, std::string_view needle) return result; } -std::string makeHistFactoryJSON(std::string const &modifiers, std::string const &defaultInterpolation = "") +std::string makeHistFactoryJSON(std::string const &modifiers, std::string const &defaultInterpolation = "", + std::string const &version = "0.2") { const std::string defaultNode = defaultInterpolation.empty() ? "" : "\"default_interpolation\":" + defaultInterpolation + ","; return R"({ - "metadata": {"hs3_version": "0.2"}, + "metadata": {"hs3_version": ")" + + version + R"("}, "distributions": [ { "name": "model_channel0", @@ -2320,8 +2326,8 @@ TEST(RooFitHS3, HistFactoryDuplicateModifiersAreCombined) ASSERT_TRUE(RooJSONFactoryWSTool{ws1}.importJSONfromString(jsonStr)); std::string exported; - const std::string warnings = - captureMessages(RooFit::WARNING, RooFit::IO, [&] { exported = RooJSONFactoryWSTool{ws1}.exportJSONtoString(); }); + const std::string warnings = captureMessages(RooFit::WARNING, RooFit::ObjectHandling, + [&] { exported = RooJSONFactoryWSTool{ws1}.exportJSONtoString(); }); EXPECT_NE(warnings.find("combined 2 duplicate modifiers named 'norm' of type 'normsys'"), std::string::npos) << warnings; @@ -2399,8 +2405,9 @@ TEST(RooFitHS3, HistFactoryDuplicateModifiersAreCombined) } // The operation must be logged as a warning, not as an error. - const std::string sentinelErrors = captureMessages( - RooFit::ERROR, RooFit::IO, [] { RooJSONFactoryWSTool::warning("duplicate-modifier warning-level sentinel"); }); + const std::string sentinelErrors = captureMessages(RooFit::ERROR, RooFit::ObjectHandling, [] { + RooJSONFactoryWSTool::warning("duplicate-modifier warning-level sentinel"); + }); EXPECT_TRUE(sentinelErrors.empty()) << sentinelErrors; } @@ -2498,11 +2505,11 @@ INSTANTIATE_TEST_SUITE_P( IncompatibleModifierCase{"Constraint", R"( { - "name": "input_1", "parameter": "alpha_dup", "type": "normsys", + "name": "input_1", "parameter": "alpha_dup", "type": "normsys", "constraint": "Gauss", "interpolation": 1, "data": {"lo": 0.8, "hi": 1.2} }, { - "name": "input_2", "parameter": "dup", "type": "normsys", + "name": "input_2", "parameter": "dup", "type": "normsys", "constraint": "Gauss", "interpolation": 1, "data": {"lo": 0.9, "hi": 1.1} })", "", "constraint metadata differs"}, @@ -2892,3 +2899,1133 @@ TEST(RooFitHS3, GenericTypeNames) EXPECT_NE(json.find("\"name\":\"c_exponential_inverted\",\"type\":\"generic\""), std::string::npos) << json; EXPECT_EQ(json.find("\"type\":\"generic_function\""), std::string::npos) << json; } + +// The hs3_version must be a string. Documents that are newer than what RooFit +// writes are imported with a warning. +TEST(RooFitHS3, VersionStringHandling) +{ + auto importWithVersion = [](std::string const &version) { + RooWorkspace ws; + return RooJSONFactoryWSTool{ws}.importJSONfromString(R"({"metadata": {"hs3_version": )" + version + "}}"); + }; + + expectImportThrowsWithError(R"({"metadata": {}})", "The HS3 version is missing"); + expectImportThrowsWithError(R"({"metadata": {"hs3_version": 0.2}})", "must be a string"); + + std::string warnings = + captureMessages(RooFit::WARNING, RooFit::ObjectHandling, [&] { EXPECT_TRUE(importWithVersion("\"0.2\"")); }); + EXPECT_EQ(warnings.find("hs3_version"), std::string::npos) << warnings; + + warnings = + captureMessages(RooFit::WARNING, RooFit::ObjectHandling, [&] { EXPECT_TRUE(importWithVersion("\"99.1.4\"")); }); + EXPECT_NE(warnings.find("'99.1.4', which is newer"), std::string::npos) << warnings; + + warnings = + captureMessages(RooFit::WARNING, RooFit::ObjectHandling, [&] { EXPECT_TRUE(importWithVersion("\"latest\"")); }); + EXPECT_NE(warnings.find("cannot parse hs3_version 'latest'"), std::string::npos) << warnings; +} + +// Names of top-level objects must be unique across the whole HS3 document. +TEST(RooFitHS3, DuplicateNamesAreRejected) +{ + // The same name used for a distribution and a function. + expectImportThrowsWithError(R"({ + "metadata": {"hs3_version": "0.3"}, + "distributions": [{"name": "dup", "type": "gaussian_dist", "x": "x", "mean": 0.0, "sigma": 1.0}], + "functions": [{"name": "dup", "type": "sum", "summands": ["x"]}] + })", + "duplicate name 'dup'"); + + // The same name used twice within one section. + expectImportThrowsWithError(R"({ + "metadata": {"hs3_version": "0.3"}, + "parameter_points": [{"name": "pp", "parameters": []}, {"name": "pp", "parameters": []}] + })", + "duplicate name 'pp'"); + + // Exported likelihoods don't share their name with the analysis anymore. + RooWorkspace ws; + ws.factory("Gaussian::pdf(x[0, -5, 5], mu[0, -5, 5], 1.0)"); + RooFit::ModelConfig mc{"ModelConfig", &ws}; + mc.SetPdf("pdf"); + mc.SetParametersOfInterest("mu"); + mc.SetObservables("x"); + ws.import(mc); + const std::string json = RooJSONFactoryWSTool{ws}.exportJSONtoString(); + EXPECT_NE(json.find(R"("likelihood":"ModelConfig_likelihood")"), std::string::npos) << json; + RooWorkspace ws2; + const std::string warnings = captureMessages(RooFit::WARNING, RooFit::ObjectHandling, [&] { + EXPECT_TRUE(RooJSONFactoryWSTool{ws2}.importJSONfromString(json)); + }); + EXPECT_EQ(warnings.find("not allowed by the HS3 standard"), std::string::npos) << warnings; + + // Files written by older ROOT versions named the likelihood (and the analysis) after the pdf. They can still be + // read. + std::string legacyJson = json; + const std::string likelihoodName = "ModelConfig_likelihood"; + for (std::size_t pos = 0; (pos = legacyJson.find(likelihoodName, pos)) != std::string::npos;) { + legacyJson.replace(pos, likelihoodName.size(), "pdf"); + } + RooWorkspace ws3; + const std::string legacyWarnings = captureMessages(RooFit::WARNING, RooFit::ObjectHandling, [&] { + EXPECT_TRUE(RooJSONFactoryWSTool{ws3}.importJSONfromString(legacyJson)); + }); + EXPECT_NE(legacyWarnings.find(R"(the name 'pdf' is used in "distributions" and in "likelihoods")"), + std::string::npos) + << legacyWarnings << legacyJson; + EXPECT_NE(ws3.obj("ModelConfig"), nullptr); +} + +// The RooFit-specific "default_domain" is optional on import. Variables then +// take the range of the domain that mentions them. +TEST(RooFitHS3, DefaultDomainIsOptional) +{ + const std::string json = R"({ + "metadata": {"hs3_version": "0.2"}, + "domains": [ + {"name": "my_domain", "type": "product_domain", "axes": [{"name": "mu", "min": -3.0, "max": 4.0}]} + ], + "parameter_points": [{"name": "default_values", "parameters": [{"name": "mu", "value": 0.5}]}], + "distributions": [{"name": "pdf", "type": "gaussian_dist", "x": "mu", "mean": 0.0, "sigma": 1.0}] + })"; + + RooWorkspace ws; + ASSERT_TRUE(RooJSONFactoryWSTool{ws}.importJSONfromString(json)); + RooRealVar *mu = ws.var("mu"); + ASSERT_NE(mu, nullptr); + EXPECT_DOUBLE_EQ(mu->getMin(), -3.0); + EXPECT_DOUBLE_EQ(mu->getMax(), 4.0); + EXPECT_DOUBLE_EQ(mu->getVal(), 0.5); + EXPECT_FALSE(mu->isConstant()); + + // On export, the "default_domain" is still written. + const std::string exported = RooJSONFactoryWSTool{ws}.exportJSONtoString(); + EXPECT_NE(exported.find(R"("name":"default_domain")"), std::string::npos) << exported; +} + +// Parameters that only appear in a parameter point are created on import, and +// categorical parameters can be set with a string value. +TEST(RooFitHS3, ParametersOnlyInParameterPoints) +{ + const std::string json = R"({ + "metadata": {"hs3_version": "0.2"}, + "domains": [ + {"name": "default_domain", "type": "product_domain", "axes": [ + {"name": "free", "min": -1.0, "max": 1.0}, {"name": "freeButConst", "min": -1.0, "max": 1.0}]} + ], + "parameter_points": [ + { + "name": "point", + "parameters": [ + {"name": "fixed", "value": 2.5}, + {"name": "fixedExplicitFalse", "value": 1.5, "const": false}, + {"name": "free", "value": 0.25}, + {"name": "freeButConst", "value": 0.5, "const": true}, + {"name": "channel", "value": "CR"} + ] + } + ] + })"; + + RooWorkspace ws; + RooCategory channel{"channel", "channel", {{"SR", 0}, {"CR", 1}}}; + ws.import(channel); + ASSERT_TRUE(RooJSONFactoryWSTool{ws}.importJSONfromString(json)); + + RooRealVar *fixed = ws.var("fixed"); + ASSERT_NE(fixed, nullptr); + EXPECT_DOUBLE_EQ(fixed->getVal(), 2.5); + EXPECT_TRUE(fixed->isConstant()); + + RooRealVar *free = ws.var("free"); + ASSERT_NE(free, nullptr); + EXPECT_DOUBLE_EQ(free->getVal(), 0.25); + EXPECT_FALSE(free->isConstant()); + + // "const": false is the same as no "const": without a domain, the parameter stays constant. + RooRealVar *fixedExplicitFalse = ws.var("fixedExplicitFalse"); + ASSERT_NE(fixedExplicitFalse, nullptr); + EXPECT_TRUE(fixedExplicitFalse->isConstant()); + + // "const": true makes a parameter constant also if a domain lists it. + RooRealVar *freeButConst = ws.var("freeButConst"); + ASSERT_NE(freeButConst, nullptr); + EXPECT_TRUE(freeButConst->isConstant()); + + const RooArgSet *snapshot = ws.getSnapshot("point"); + ASSERT_NE(snapshot, nullptr); + auto *channelInSnapshot = dynamic_cast(snapshot->find("channel")); + ASSERT_NE(channelInSnapshot, nullptr); + EXPECT_EQ(channelInSnapshot->getCurrentLabel(), std::string("CR")); +} + +// HS3 forbids binning information on the axes of unbinned data, regardless of +// the binning of the observable in RooFit. +TEST(RooFitHS3, UnbinnedDatasetAxesHaveNoBinning) +{ + RooWorkspace ws; + RooRealVar x{"x", "x", 0.0, 10.0}; + RooRealVar y{"y", "y", 0.0, 3.0}; + x.setBins(5); + std::vector edges{0.0, 1.0, 3.0}; + y.setBinning(RooBinning{2, edges.data()}); + RooDataSet ds{"ds", "ds", RooArgSet{x, y}}; + for (double val : {1.0, 2.0, 2.5}) { + x.setVal(val); + y.setVal(val); + ds.add(RooArgSet{x, y}); + } + ws.import(ds, RooFit::Silence()); + + const std::string json = RooJSONFactoryWSTool{ws}.exportJSONtoString(); + auto tree = RooFit::Detail::JSONTree::create(json); + const auto *dataNode = RooJSONFactoryWSTool::findNamedChild(tree->rootnode()["data"], "ds"); + ASSERT_NE(dataNode, nullptr) << json; + EXPECT_EQ((*dataNode)["type"].val(), "unbinned"); + ASSERT_EQ((*dataNode)["axes"].num_children(), 2u); + for (const auto &axis : (*dataNode)["axes"].children()) { + EXPECT_TRUE(axis.has_child("name")); + EXPECT_TRUE(axis.has_child("min")); + EXPECT_TRUE(axis.has_child("max")); + EXPECT_FALSE(axis.has_child("nbins")) << json; + EXPECT_FALSE(axis.has_child("edges")) << json; + } +} + +// The snapshot of a ModelConfig is exported as the "init" parameter point of +// the analysis, and restored on import. +TEST(RooFitHS3, AnalysisInitRoundTrip) +{ + RooWorkspace ws1; + ws1.factory("Gaussian::pdf(x[0, -5, 5], mu[0, -5, 5], 1.0)"); + RooFit::ModelConfig mc1{"ModelConfig", &ws1}; + mc1.SetPdf("pdf"); + mc1.SetParametersOfInterest("mu"); + mc1.SetObservables("x"); + ws1.var("mu")->setVal(1.5); + RooArgSet initSet{*ws1.var("mu")}; + initSet.setName("POI"); + mc1.SetSnapshot(initSet); + ws1.var("mu")->setVal(0.0); + ws1.import(mc1); + + const std::string json1 = RooJSONFactoryWSTool{ws1}.exportJSONtoString(); + EXPECT_NE(json1.find(R"("init":"ModelConfig_POI_snapshot")"), std::string::npos) << json1; + + RooWorkspace ws2; + ASSERT_TRUE(RooJSONFactoryWSTool{ws2}.importJSONfromString(json1)); + auto *mc2 = dynamic_cast(ws2.obj("ModelConfig")); + ASSERT_NE(mc2, nullptr); + std::unique_ptr snapshot{mc2->GetSnapshot()}; + ASSERT_NE(snapshot, nullptr); + auto *mu = dynamic_cast(snapshot->find("mu")); + ASSERT_NE(mu, nullptr); + EXPECT_DOUBLE_EQ(mu->getVal(), 1.5); + + // The snapshot name must be stable under repeated round trips. + EXPECT_EQ(RooJSONFactoryWSTool{ws2}.exportJSONtoString(), json1); + + // An "init" that refers to a missing parameter point is reported. + std::string missingInit = json1; + missingInit.replace(missingInit.find(R"("init":"ModelConfig_POI_snapshot")"), + std::string(R"("init":"ModelConfig_POI_snapshot")").size(), R"("init":"does_not_exist")"); + RooWorkspace ws3; + const std::string warnings = captureMessages(RooFit::WARNING, RooFit::ObjectHandling, [&] { + EXPECT_TRUE(RooJSONFactoryWSTool{ws3}.importJSONfromString(missingInit)); + }); + EXPECT_NE(warnings.find("'does_not_exist' used as \"init\""), std::string::npos) << warnings; + + // The snapshot of an unnamed set is called "__snapshot", which must be stable as well. + RooWorkspace ws4; + ws4.factory("Gaussian::pdf(x[0, -5, 5], mu[0, -5, 5], 1.0)"); + RooFit::ModelConfig mc4{"ModelConfig", &ws4}; + mc4.SetPdf("pdf"); + mc4.SetParametersOfInterest("mu"); + mc4.SetObservables("x"); + mc4.SetSnapshot(RooArgSet{*ws4.var("mu")}); + ws4.import(mc4); + const std::string json4 = RooJSONFactoryWSTool{ws4}.exportJSONtoString(); + EXPECT_NE(json4.find(R"("init":"ModelConfig__snapshot")"), std::string::npos) << json4; + RooWorkspace ws5; + ASSERT_TRUE(RooJSONFactoryWSTool{ws5}.importJSONfromString(json4)); + EXPECT_EQ(RooJSONFactoryWSTool{ws5}.exportJSONtoString(), json4); +} + +// Type keys follow the HS3 catalog. The old RooFit-specific keys are still accepted on import. +TEST(RooFitHS3, TypeKeysMatchTheCatalog) +{ + RooHelpers::LocalChangeMsgLevel changeMsgLvl(RooFit::WARNING); + + RooRealVar x{"x", "x", 0.0, 10.0}; + x.setBins(10); + RooRealVar mean{"mean", "mean", 5.0, 0.0, 10.0}; + RooRealVar sigma{"sigma", "sigma", 1.0, 0.1, 5.0}; + + // Import a single distribution from an HS3 document. + auto importPdf = [](std::string const &distribution) { + auto ws = std::make_unique(); + RooJSONFactoryWSTool{*ws}.importJSONfromString(R"({ + "metadata": {"hs3_version": "0.2"}, + "domains": [{"name": "default_domain", "type": "product_domain", "axes": [{"name": "m", "min": 0, "max": 10}]}], + "distributions": [)" + distribution + + R"(] + })"); + return ws; + }; + + // argus_dist is the HS3 key, ARGUS_dist is deprecated. + { + RooWorkspace ws; + ws.factory("ArgusBG::argus(x[0, 10], 9.0, -1.0, 0.5)"); + const std::string json = RooJSONFactoryWSTool{ws}.exportJSONtoString(); + EXPECT_NE(json.find(R"("type":"argus_dist")"), std::string::npos) << json; + } + std::unique_ptr argus; + const std::string argusWarnings = captureMessages(RooFit::WARNING, RooFit::ObjectHandling, [&] { + argus = importPdf( + R"({"name": "argus", "type": "ARGUS_dist", "mass": "m", "resonance": 9.0, "slope": -1.0, "power": 0.5})"); + }); + EXPECT_NE(argus->pdf("argus"), nullptr); + EXPECT_NE(argusWarnings.find(R"(the type "ARGUS_dist" is deprecated)"), std::string::npos) << argusWarnings; + + // RooBinSamplingPdf is exported and imported with the same key. + RooGaussian gauss{"gauss", "", x, mean, sigma}; + RooBinSamplingPdf binSampling{"binSampling", "", x, gauss, 1e-3}; + EXPECT_EQ(validate(binSampling), 0); + + // rate_extended_dist uses the HS3 field names "rate" and "dist". + RooRealVar nEvents{"nEvents", "nEvents", 100.0, 0.0, 1000.0}; + RooExtendPdf extended{"extended", "", gauss, nEvents}; + EXPECT_EQ(validate(extended), 0); + { + RooWorkspace ws; + ws.import(extended, RooFit::Silence()); + const std::string json = RooJSONFactoryWSTool{ws}.exportJSONtoString(); + EXPECT_NE(json.find(R"("dist":"gauss")"), std::string::npos) << json; + EXPECT_NE(json.find(R"("rate":"nEvents")"), std::string::npos) << json; + EXPECT_EQ(json.find(R"("range")"), std::string::npos) << json; + } + auto legacyExtended = importPdf(R"({"name": "ext", "type": "rate_extended_dist", "pdf": "g", "norm": 10.0}, + {"name": "g", "type": "gaussian_dist", "x": "m", "mean": 5.0, "sigma": 1.0})"); + EXPECT_NE(dynamic_cast(legacyExtended->pdf("ext")), nullptr); +} + +// TFormula ternaries are exported as the HS3 conditional ifelse(), and ifelse() +// is translated back on import. +TEST(RooFitHS3, TernaryIsExportedAsIfElse) +{ + RooRealVar x{"x", "x", 0.5, -2.0, 2.0}; + RooRealVar y{"y", "y", -0.5, -2.0, 2.0}; + RooFormulaVar formula{"formula", "2 * (x > 0 ? (y > 0 ? x * y : -x) : 3 * x) + 1", RooArgList{x, y}}; + + RooWorkspace ws1; + ws1.import(formula, RooFit::Silence()); + const std::string json1 = RooJSONFactoryWSTool{ws1}.exportJSONtoString(); + EXPECT_NE(json1.find(R"("expression":"2 * ifelse(x > 0,ifelse(y > 0,x * y,-x),3 * x) + 1")"), std::string::npos) + << json1; + EXPECT_EQ(json1.find('?'), std::string::npos) << json1; + + RooWorkspace ws2; + ASSERT_TRUE(RooJSONFactoryWSTool{ws2}.importJSONfromString(json1)); + EXPECT_EQ(RooJSONFactoryWSTool{ws2}.exportJSONtoString(), json1); + for (double xVal : {-1.0, 0.5}) { + for (double yVal : {-1.0, 0.5}) { + ws1.var("x")->setVal(xVal); + ws1.var("y")->setVal(yVal); + ws2.var("x")->setVal(xVal); + ws2.var("y")->setVal(yVal); + EXPECT_DOUBLE_EQ(ws2.function("formula")->getVal(), ws1.function("formula")->getVal()) + << "x=" << xVal << ", y=" << yVal; + } + } + + // Expressions without ternaries are not changed. + RooFormulaVar plain{"plain", "(x + y) * (x - y)", RooArgList{x, y}}; + RooWorkspace ws3; + ws3.import(plain, RooFit::Silence()); + const std::string json3 = RooJSONFactoryWSTool{ws3}.exportJSONtoString(); + EXPECT_NE(json3.find(R"json("expression":"(x + y) * (x - y)")json"), std::string::npos) << json3; +} + +// The explicit "extended" flag of a mixture_dist has to be consistent with the number of coefficients. +TEST(RooFitHS3, MixtureDistExtendedFlag) +{ + auto makeJSON = [](std::string const &coefficients, std::string const &extended) { + return R"({ + "metadata": {"hs3_version": "0.2"}, + "domains": [{"name": "default_domain", "type": "product_domain", "axes": [{"name": "x", "min": -5, "max": 5}]}], + "parameter_points": [{"name": "default_values", "parameters": [{"name": "f", "value": 0.3}, + {"name": "n1", "value": 30}, + {"name": "n2", "value": 70}]}], + "distributions": [ + {"name": "g1", "type": "gaussian_dist", "x": "x", "mean": -1.0, "sigma": 1.0}, + {"name": "g2", "type": "gaussian_dist", "x": "x", "mean": 1.0, "sigma": 1.0}, + {"name": "mix", "type": "mixture_dist", "summands": ["g1", "g2"], "coefficients": )" + + coefficients + R"(, "extended": )" + extended + R"(} + ] + })"; + }; + + RooHelpers::LocalChangeMsgLevel changeMsgLvl(RooFit::WARNING); + + { + RooWorkspace ws; + ASSERT_TRUE(RooJSONFactoryWSTool{ws}.importJSONfromString(makeJSON(R"(["f"])", "false"))); + EXPECT_EQ(ws.pdf("mix")->extendMode(), RooAbsPdf::CanNotBeExtended); + } + { + RooWorkspace ws; + ASSERT_TRUE(RooJSONFactoryWSTool{ws}.importJSONfromString(makeJSON(R"(["n1", "n2"])", "true"))); + EXPECT_NE(ws.pdf("mix")->extendMode(), RooAbsPdf::CanNotBeExtended); + } + expectImportThrowsWithError(makeJSON(R"(["f"])", "true"), "is declared as extended, but it has 1 coefficients"); + + RooWorkspace ws; + const std::string warnings = captureMessages(RooFit::WARNING, RooFit::ObjectHandling, [&] { + EXPECT_TRUE(RooJSONFactoryWSTool{ws}.importJSONfromString(makeJSON(R"(["n1", "n2"])", "false"))); + }); + EXPECT_NE(warnings.find("is declared as not extended"), std::string::npos) << warnings; +} + +// The parameter of a normfactor is given by "parameter", the "name" is only a label. +// The HS3 spelling of the log-normal constraint type is recognized. +TEST(RooFitHS3, HistFactoryNormFactorParameterAndLogNormal) +{ + RooHelpers::LocalChangeMsgLevel changeMsgLvl(RooFit::WARNING); + + RooWorkspace ws; + ASSERT_TRUE(RooJSONFactoryWSTool{ws}.importJSONfromString( + makeHistFactoryJSON(R"({"name": "lumi_modifier", "parameter": "lumi", "type": "normfactor"})"))); + EXPECT_NE(ws.var("lumi"), nullptr); + EXPECT_EQ(ws.var("lumi_modifier"), nullptr); + + expectImportThrowsWithError( + makeHistFactoryJSON( + R"({"name": "sys", "parameter": "alpha_sys", "type": "normsys", "constraint": "LogNormal", "data": {"lo": 0.9, "hi": 1.1}})"), + "legacy constraint value 'LogNormal' for modifier 'sys' is a known constraint type"); +} + +// A normsys or histosys modifier without "constraint" is unconstrained, and +// it is exported without constraint. +TEST(RooFitHS3, HistFactoryModifierWithoutConstraintIsUnconstrained) +{ + RooHelpers::LocalChangeMsgLevel changeMsgLvl(RooFit::WARNING); + + const std::string modifiers = R"( + {"name": "norm", "parameter": "alpha_norm", "type": "normsys", "data": {"lo": 0.8, "hi": 1.2}}, + {"name": "shape", "parameter": "alpha_shape", "type": "histosys", + "data": {"lo": {"contents": [8.0, 18.0]}, "hi": {"contents": [12.0, 22.0]}}}, + {"name": "constrained", "parameter": "alpha_constrained", "type": "normsys", "constraint": "Gauss", + "data": {"lo": 0.9, "hi": 1.1}})"; + const std::string defaultInterpolation = R"({"type": "mult", "in": "poly6", "out": "exp"})"; + + RooWorkspace ws1; + ASSERT_TRUE(RooJSONFactoryWSTool{ws1}.importJSONfromString(makeHistFactoryJSON(modifiers, defaultInterpolation))); + EXPECT_EQ(ws1.pdf("alpha_normConstraint"), nullptr); + EXPECT_EQ(ws1.pdf("alpha_shapeConstraint"), nullptr); + EXPECT_NE(ws1.pdf("alpha_constrainedConstraint"), nullptr); + EXPECT_EQ(ws1.var("nom_alpha_norm"), nullptr); + + const std::string json1 = RooJSONFactoryWSTool{ws1}.exportJSONtoString(); + auto tree = RooFit::Detail::JSONTree::create(json1); + const auto *channel = RooJSONFactoryWSTool::findNamedChild(tree->rootnode()["distributions"], "model_channel0"); + ASSERT_NE(channel, nullptr) << json1; + EXPECT_FALSE(findModifier(*channel, "norm")->has_child("constraint")) << json1; + EXPECT_FALSE(findModifier(*channel, "shape")->has_child("constraint")) << json1; + EXPECT_TRUE(findModifier(*channel, "constrained")->has_child("constraint")) << json1; + + RooWorkspace ws2; + ASSERT_TRUE(RooJSONFactoryWSTool{ws2}.importJSONfromString(json1)); + EXPECT_EQ(ws2.pdf("alpha_normConstraint"), nullptr); + EXPECT_EQ(RooJSONFactoryWSTool{ws2}.exportJSONtoString(), json1); +} + +// The constraint type of the staterror is read from the modifier, with the +// channel-level setting as fallback, and written to both places. +TEST(RooFitHS3, HistFactoryStatErrorConstraint) +{ + RooHelpers::LocalChangeMsgLevel changeMsgLvl(RooFit::WARNING); + + auto makeJSON = [](std::string const &modifierConstraint, std::string const &channelConstraint) { + return R"({ + "metadata": {"hs3_version": "0.2"}, + "distributions": [ + { + "name": "model_channel0", + "type": "histfactory_dist", + "axes": [{"name": "x", "min": 0.0, "max": 2.0, "nbins": 2}],)" + + (channelConstraint.empty() ? "" : R"("staterror": {"constraint_type": ")" + channelConstraint + R"("},)") + + R"( + "samples": [ + { + "name": "sig", + "data": {"contents": [10.0, 20.0], "errors": [1.0, 2.0]}, + "modifiers": [{"name": "mcstat", "type": "staterror")" + + (modifierConstraint.empty() ? "" : R"(, "constraint": ")" + modifierConstraint + R"(")") + R"(}] + } + ] + } + ] + })"; + }; + auto hasGaussianStatConstraints = [](RooWorkspace &ws) { + bool foundGauss = false; + bool foundPoisson = false; + for (RooAbsArg *pdf : ws.allPdfs()) { + if (std::string(pdf->GetName()).find("gamma_stat_channel0") == std::string::npos) + continue; + foundGauss |= dynamic_cast(pdf) != nullptr; + foundPoisson |= dynamic_cast(pdf) != nullptr; + } + EXPECT_NE(foundGauss, foundPoisson); + return foundGauss; + }; + + { + RooWorkspace ws; + ASSERT_TRUE(RooJSONFactoryWSTool{ws}.importJSONfromString(makeJSON("Gauss", "Poisson"))); + EXPECT_TRUE(hasGaussianStatConstraints(ws)); + + const std::string json = RooJSONFactoryWSTool{ws}.exportJSONtoString(); + auto tree = RooFit::Detail::JSONTree::create(json); + const auto *channel = RooJSONFactoryWSTool::findNamedChild(tree->rootnode()["distributions"], "model_channel0"); + ASSERT_NE(channel, nullptr) << json; + EXPECT_EQ((*channel)["staterror"]["constraint_type"].val(), "Gauss") << json; + const auto *modifier = findModifier(*channel, "staterror"); + ASSERT_NE(modifier, nullptr) << json; + EXPECT_EQ((*modifier)["constraint"].val(), "Gauss") << json; + + RooWorkspace ws2; + ASSERT_TRUE(RooJSONFactoryWSTool{ws2}.importJSONfromString(json)); + EXPECT_TRUE(hasGaussianStatConstraints(ws2)); + EXPECT_EQ(RooJSONFactoryWSTool{ws2}.exportJSONtoString(), json); + } + { + RooWorkspace ws; + ASSERT_TRUE(RooJSONFactoryWSTool{ws}.importJSONfromString(makeJSON("", "Gauss"))); + EXPECT_TRUE(hasGaussianStatConstraints(ws)); + } + { + RooWorkspace ws; + ASSERT_TRUE(RooJSONFactoryWSTool{ws}.importJSONfromString(makeJSON("", ""))); + EXPECT_FALSE(hasGaussianStatConstraints(ws)); + } + expectImportThrowsWithError(makeJSON("LogNormal", ""), "unsupported constraint 'LogNormal' of the staterror"); +} + +// HS3 forbids implementation-specific default interpolations. Documents from +// before HS3 v0.3 still get the implicit default of older RooFit versions. +TEST(RooFitHS3, HistFactoryMissingInterpolation) +{ + RooHelpers::LocalChangeMsgLevel changeMsgLvl(RooFit::WARNING); + + const std::string modifiers = R"( + {"name": "norm", "parameter": "alpha_norm", "type": "normsys", "data": {"lo": 0.8, "hi": 1.2}}, + {"name": "norm2", "parameter": "alpha_norm2", "type": "normsys", "data": {"lo": 0.9, "hi": 1.1}})"; + + std::string json = makeHistFactoryJSON(modifiers); + const std::string legacyVersion = R"("hs3_version": "0.2")"; + std::string currentJson = json; + currentJson.replace(currentJson.find(legacyVersion), legacyVersion.size(), R"("hs3_version": "0.3")"); + expectImportThrowsWithError(currentJson, + "interpolation for normsys modifier 'norm' in sample 'sig' of channel " + "'channel0' is not given, and the channel has no \"default_interpolation\""); + + RooWorkspace ws; + const std::string warnings = captureMessages(RooFit::WARNING, RooFit::ObjectHandling, [&] { + ASSERT_TRUE(RooJSONFactoryWSTool{ws}.importJSONfromString(json)); + }); + EXPECT_EQ(countOccurrences(warnings, "implicit default of older RooFit versions"), 1u) << warnings; + auto *norm = dynamic_cast(ws.function("sig_channel0_epsilon")); + ASSERT_NE(norm, nullptr); + EXPECT_EQ(norm->interpolationCodes()[0], 4); + + // RooFit always writes a "default_interpolation" if a modifier relies on it. + const std::string exported = RooJSONFactoryWSTool{ws}.exportJSONtoString(); + EXPECT_NE(exported.find("default_interpolation"), std::string::npos) << exported; + RooWorkspace ws2; + std::string exportedCurrent = exported; + const std::string exportedVersion = R"("hs3_version":"0.2")"; + ASSERT_NE(exportedCurrent.find(exportedVersion), std::string::npos) << exported; + exportedCurrent.replace(exportedCurrent.find(exportedVersion), exportedVersion.size(), R"("hs3_version":"0.3")"); + EXPECT_TRUE(RooJSONFactoryWSTool{ws2}.importJSONfromString(exportedCurrent)); +} + +// Malformed matrices, error arrays and bin edges are rejected instead of being read out of bounds. +TEST(RooFitHS3, MalformedArraysAreRejected) +{ + auto binnedData = [](std::string const &axis, std::string const &extra) { + return R"({ + "metadata": {"hs3_version": "0.2"}, + "data": [{"name": "data", "type": "binned", "axes": [)" + + axis + R"(], "contents": [1.0, 2.0])" + extra + R"(}] + })"; + }; + const std::string goodAxis = R"({"name": "x", "min": 0.0, "max": 2.0, "nbins": 2})"; + + expectImportThrowsWithError(binnedData(goodAxis, R"(, "errors": [1.0])"), + "inconsistent number of errors and contents in 'data'"); + expectImportThrowsWithError(binnedData(R"({"name": "x", "edges": [0.0, 2.0, 1.0]})", ""), + "\"edges\" of axis 'x' must be strictly ascending"); + expectImportThrowsWithError(binnedData(R"({"name": "x", "edges": []})", ""), + "\"edges\" of axis 'x' must contain at least two values"); + + expectImportThrowsWithError(R"({ + "metadata": {"hs3_version": "0.2"}, + "distributions": [{"name": "mvn", "type": "multivariate_normal_dist", "x": ["x1", "x2"], "mean": [0.0, 0.0], + "covariances": [[1.0, 0.0, 0.0], [0.0, 1.0]]}] + })", + "\"covariances\" in 'mvn' must be a square matrix"); +} + +// The observables of unbinned data take their range from the axes, unless a domain defines it. +TEST(RooFitHS3, UnbinnedAxisRangesOnImport) +{ + auto makeJSON = [](std::string const &domainAxes) { + return R"({ + "metadata": {"hs3_version": "0.2"}, + "domains": [{"name": "default_domain", "type": "product_domain", "axes": [)" + + domainAxes + R"(]}], + "parameter_points": [{"name": "default_values", "parameters": [{"name": "x", "value": 0.0}]}], + "distributions": [{"name": "pdf", "type": "gaussian_dist", "x": "x", "mean": 0.0, "sigma": 1.0}], + "data": [{"name": "data", "type": "unbinned", "axes": [{"name": "x", "min": -3.0, "max": 8.0}], + "entries": [[-2.5], [7.5]]}] + })"; + }; + + RooHelpers::LocalChangeMsgLevel changeMsgLvl(RooFit::WARNING); + { + // "x" is not in any domain, so it gets the range of the axis and no entry is clipped. + RooWorkspace ws; + ScopedNoDomainConstVarImportFlag noConstVars{false}; + ASSERT_TRUE(RooJSONFactoryWSTool{ws}.importJSONfromString(makeJSON(R"({"name": "other", "min": 0, "max": 1})"))); + EXPECT_DOUBLE_EQ(ws.var("x")->getMin(), -3.0); + EXPECT_DOUBLE_EQ(ws.var("x")->getMax(), 8.0); + const RooArgSet *row = ws.data("data")->get(1); + EXPECT_DOUBLE_EQ(static_cast(row->find("x"))->getVal(), 7.5); + } + { + // A disagreeing domain range is kept, with a warning. + RooWorkspace ws; + const std::string warnings = captureMessages(RooFit::WARNING, RooFit::ObjectHandling, [&] { + EXPECT_TRUE( + RooJSONFactoryWSTool{ws}.importJSONfromString(makeJSON(R"({"name": "x", "min": -10, "max": 10})"))); + }); + EXPECT_DOUBLE_EQ(ws.var("x")->getMin(), -10.0); + EXPECT_NE(warnings.find("of the axis 'x' of the dataset 'data' differs"), std::string::npos) << warnings; + } +} + +// Errors of zero in binned data stay zero, also before the first positive error. +TEST(RooFitHS3, BinnedDataZeroErrors) +{ + RooHelpers::LocalChangeMsgLevel changeMsgLvl(RooFit::WARNING); + + RooWorkspace ws; + ASSERT_TRUE(RooJSONFactoryWSTool{ws}.importJSONfromString(R"({ + "metadata": {"hs3_version": "0.3"}, + "data": [{"name": "d", "type": "binned", "axes": [{"name": "x", "min": 0.0, "max": 3.0, "nbins": 3}], + "contents": [10.0, 20.0, 30.0], "errors": [0.0, 2.0, 0.0]}] + })")); + auto *data = dynamic_cast(ws.data("d")); + ASSERT_NE(data, nullptr); + EXPECT_DOUBLE_EQ(data->weightSquared(0), 0.0); + EXPECT_DOUBLE_EQ(data->weightSquared(1), 4.0); + EXPECT_DOUBLE_EQ(data->weightSquared(2), 0.0); +} + +// A RooRealVar that is constant in all parameter points must stay a RooRealVar, and a RooConstVar must stay a +// RooConstVar. In the parameter points both are written the same way (a value and "const": true). The only +// difference is that the RooRealVar is listed in a domain and the RooConstVar is not: a parameter without domain is +// constant (it has no range to vary in), and RooFit reads it back as a RooConstVar. +TEST(RooFitHS3, ConstRooRealVarStaysDistinctFromRooConstVar) +{ + ScopedNoDomainConstVarImportFlag flagGuard{true}; + RooHelpers::LocalChangeMsgLevel changeMsgLvl(RooFit::WARNING); + + RooRealVar x{"x", "x", 0.0, -10.0, 10.0}; + RooConstVar meanConst{"mean_const", "mean_const", 1.0}; + RooRealVar sigmaFixed{"sigma_fixed", "sigma_fixed", 1.0, 0.1, 10.0}; + sigmaFixed.setConstant(true); + RooGaussian gauss{"gauss", "gauss", x, meanConst, sigmaFixed}; + + RooWorkspace ws; + ws.import(gauss, RooFit::Silence()); + // The RooRealVar is constant in every snapshot, with different values. + RooRealVar &sigmaInWs = *ws.var("sigma_fixed"); + ws.saveSnapshot("snapshot_a", RooArgSet{sigmaInWs}); + sigmaInWs.setVal(2.0); + ws.saveSnapshot("snapshot_b", RooArgSet{sigmaInWs}); + sigmaInWs.setVal(1.0); + + const std::string json = RooJSONFactoryWSTool{ws}.exportJSONtoString(); + auto tree = RooFit::Detail::JSONTree::create(json); + auto &root = tree->rootnode(); + + // In every parameter point, the two are written the same way. + std::size_t nSigmaEntries = 0; + for (auto const &point : root["parameter_points"].children()) { + for (auto const ¶m : point["parameters"].children()) { + const std::string name = param["name"].val(); + if (name != "sigma_fixed" && name != "mean_const") { + continue; + } + EXPECT_TRUE(param.has_child("const") && param["const"].val_bool()) + << name << " in " << point["name"].val() << ": " << json; + EXPECT_FALSE(param.has_child("min") || param.has_child("max")) << name << ": " << json; + nSigmaEntries += name == "sigma_fixed"; + } + } + EXPECT_EQ(nSigmaEntries, 3u) << json; // default_values, snapshot_a, snapshot_b + + // Only the domains tell them apart. + auto inAnyDomain = [&](std::string const &name) { + for (auto const &domain : root["domains"].children()) { + if (RooJSONFactoryWSTool::findNamedChild(domain["axes"], name)) { + return true; + } + } + return false; + }; + EXPECT_TRUE(inAnyDomain("sigma_fixed")) << json; + EXPECT_FALSE(inAnyDomain("mean_const")) << json; + + // On import, the types and the constness are restored, also from the snapshots. + RooWorkspace imported; + ASSERT_TRUE(RooJSONFactoryWSTool{imported}.importJSONfromString(json)); + EXPECT_NE(dynamic_cast(imported.obj("mean_const")), nullptr); + EXPECT_EQ(imported.var("mean_const"), nullptr); + RooRealVar *sigmaImported = imported.var("sigma_fixed"); + ASSERT_NE(sigmaImported, nullptr); + EXPECT_TRUE(sigmaImported->isConstant()); + ASSERT_TRUE(imported.loadSnapshot("snapshot_b")); + EXPECT_DOUBLE_EQ(sigmaImported->getVal(), 2.0); + EXPECT_TRUE(sigmaImported->isConstant()); + ASSERT_TRUE(imported.loadSnapshot("snapshot_a")); // back to the value of the default parameter point + + // The round trip is stable. + EXPECT_EQ(RooJSONFactoryWSTool{imported}.exportJSONtoString(), json); +} + +// Pdfs that share a parameter also share the helper functions that are exported for it. +TEST(RooFitHS3, SharedTransformedParameters) +{ + RooHelpers::LocalChangeMsgLevel changeMsgLvl(RooFit::WARNING); + + RooWorkspace wsExponential; + wsExponential.factory("Exponential::exponential_1(x[0, 10], c[-0.1, -5, 0])"); + wsExponential.factory("Exponential::exponential_2(y[0, 10], c)"); + const std::string jsonExponential = RooJSONFactoryWSTool{wsExponential}.exportJSONtoString(); + EXPECT_EQ(countOccurrences(jsonExponential, R"("name":"c_exponential_inverted")"), 1u) << jsonExponential; + EXPECT_EQ(validate(wsExponential, "exponential_1"), 0); + + RooWorkspace wsLognormal; + wsLognormal.factory("Lognormal::lognormal_1(x[2, 1.1, 10], m0[2, 1.1, 10], k[2, 1.1, 5])"); + wsLognormal.factory("Lognormal::lognormal_2(y[2, 1.1, 10], m0, k)"); + const std::string jsonLognormal = RooJSONFactoryWSTool{wsLognormal}.exportJSONtoString(); + EXPECT_EQ(countOccurrences(jsonLognormal, R"("name":"m0_lognormal_log")"), 1u) << jsonLognormal; + EXPECT_EQ(countOccurrences(jsonLognormal, R"("name":"k_lognormal_log")"), 1u) << jsonLognormal; + EXPECT_EQ(validate(wsLognormal, "lognormal_1"), 0); +} + +// ModelConfigs that share the pdf and the dataset, like an S+B and B-only pair, are exported as separate analyses. +TEST(RooFitHS3, ModelConfigsSharingPdfAndData) +{ + RooHelpers::LocalChangeMsgLevel changeMsgLvl(RooFit::WARNING); + + auto roundTrip = [](RooWorkspace &ws, const char *pdf, const char *observables) { + for (const char *name : {"ModelConfig", "ModelConfig_with_poi_0"}) { + RooFit::ModelConfig mc{name, &ws}; + mc.SetPdf(pdf); + mc.SetParametersOfInterest("mu"); + mc.SetNuisanceParameters("sigma"); + mc.SetObservables(observables); + ws.import(mc); + } + RooWorkspace imported; + ASSERT_TRUE(RooJSONFactoryWSTool{imported}.importJSONfromString(RooJSONFactoryWSTool{ws}.exportJSONtoString())); + EXPECT_NE(imported.obj("ModelConfig"), nullptr); + EXPECT_NE(imported.obj("ModelConfig_with_poi_0"), nullptr); + }; + + { + RooWorkspace ws; + ws.factory("Gaussian::pdf(x[0, -5, 5], mu[0, -5, 5], sigma[1, 0.1, 5])"); + RooDataSet data{"data", "data", *ws.var("x")}; + data.add(*ws.var("x")); + ws.import(data, RooFit::Silence()); + roundTrip(ws, "pdf", "x"); + } + { + RooWorkspace ws; + ws.factory("Gaussian::pdf_a(x[0, -5, 5], mu[0, -5, 5], sigma[1, 0.1, 5])"); + ws.factory("Gaussian::pdf_b(x, mu, sigma)"); + ws.factory("SIMUL::sim_pdf(channel[a=0, b=1], a=pdf_a, b=pdf_b)"); + RooArgSet observables{*ws.var("x"), *ws.cat("channel")}; + RooDataSet data{"data", "data", observables}; + for (int index : {0, 1}) { + ws.cat("channel")->setIndex(index); + data.add(observables); + } + ws.import(data, RooFit::Silence()); + roundTrip(ws, "sim_pdf", "x,channel"); + } +} + +// Before HS3 v0.3, name clashes only give a warning. RooFit still writes such documents. +TEST(RooFitHS3, NameClashesInOlderDocuments) +{ + RooHelpers::LocalChangeMsgLevel changeMsgLvl(RooFit::WARNING); + + auto expectImportWarns = [](std::string const &json, std::string const &name) { + RooWorkspace ws; + const std::string warnings = captureMessages(RooFit::WARNING, RooFit::ObjectHandling, [&] { + EXPECT_TRUE(RooJSONFactoryWSTool{ws}.importJSONfromString(json)); + }); + EXPECT_NE(warnings.find("the name '" + name + "' is used"), std::string::npos) << warnings << json; + }; + + expectImportWarns(R"({ + "metadata": {"hs3_version": "0.2"}, + "parameter_points": [{"name": "pp", "parameters": []}, {"name": "pp", "parameters": []}] + })", + "pp"); + + // A named range and a pdf. + { + RooWorkspace ws; + ws.factory("Gaussian::signal(x[0, -5, 5], mu[0, -5, 5], 1.0)"); + ws.var("x")->setRange("signal", -1, 1); + expectImportWarns(RooJSONFactoryWSTool{ws}.exportJSONtoString(), "signal"); + } + // A snapshot and a pdf. + { + RooWorkspace ws; + ws.factory("Gaussian::model(x[0, -5, 5], mu[0, -5, 5], 1.0)"); + ws.saveSnapshot("model", *ws.var("mu")); + expectImportWarns(RooJSONFactoryWSTool{ws}.exportJSONtoString(), "model"); + } + // A dataset and a pdf. + { + RooWorkspace ws; + ws.factory("Gaussian::model(x[0, -5, 5], mu[0, -5, 5], 1.0)"); + RooDataSet data{"model", "model", *ws.var("x")}; + data.add(*ws.var("x")); + ws.import(data, RooFit::Silence()); + expectImportWarns(RooJSONFactoryWSTool{ws}.exportJSONtoString(), "model"); + } +} + +// Infinite bounds of unbinned observables are written as null and have to be read back. +TEST(RooFitHS3, UnbinnedDataWithInfiniteBounds) +{ + RooHelpers::LocalChangeMsgLevel changeMsgLvl(RooFit::WARNING); + + RooRealVar x{"x", "x", 1.0, 0.0, RooNumber::infinity()}; + RooRealVar y{"y", "y", 1.0, -RooNumber::infinity(), RooNumber::infinity()}; + RooDataSet ds{"ds", "ds", RooArgSet{x, y}}; + ds.add(RooArgSet{x, y}); + + RooWorkspace ws1; + ws1.import(ds, RooFit::Silence()); + RooWorkspace ws2; + ASSERT_TRUE(RooJSONFactoryWSTool{ws2}.importJSONfromString(RooJSONFactoryWSTool{ws1}.exportJSONtoString())); + EXPECT_EQ(ws2.data("ds")->numEntries(), 1); + EXPECT_DOUBLE_EQ(ws2.var("x")->getMin(), 0.0); + EXPECT_TRUE(RooNumber::isInfinite(ws2.var("x")->getMax())); + EXPECT_TRUE(RooNumber::isInfinite(ws2.var("y")->getMin())); + EXPECT_TRUE(RooNumber::isInfinite(ws2.var("y")->getMax())); +} + +// A RooAddPdf of a single extended pdf has no coefficients, and it is still extended. +TEST(RooFitHS3, MixtureOfOneExtendedSummand) +{ + RooHelpers::LocalChangeMsgLevel changeMsgLvl(RooFit::WARNING); + + RooRealVar x{"x", "x", 0.0, 10.0}; + RooRealVar mean{"mean", "mean", 5.0, 0.0, 10.0}; + RooRealVar sigma{"sigma", "sigma", 1.0, 0.1, 5.0}; + RooRealVar nEvents{"nEvents", "nEvents", 100.0, 0.0, 1000.0}; + RooGaussian gauss{"gauss", "", x, mean, sigma}; + RooExtendPdf extended{"extended", "", gauss, nEvents}; + RooAddPdf mixture{"mixture", "", RooArgList{extended}}; + ASSERT_NE(mixture.extendMode(), RooAbsPdf::CanNotBeExtended); + EXPECT_EQ(validate(mixture), 0); +} + +namespace { + +// Imports a channel with a normsys that is encoded in an explicit formula and has a RooGaussian constraint. +void importFormulaNormSysChannel(RooWorkspace &ws) +{ + RooRealVar x{"x", "x", 0.0, 2.0}; + x.setBins(2); + RooRealVar alpha{"alpha_sys", "alpha_sys", 0.0, -5.0, 5.0}; + RooRealVar nominal{"nom_alpha_sys", "nom_alpha_sys", 0.0}; + RooRealVar sigma{"sigma_alpha_sys", "sigma_alpha_sys", 1.0}; + RooRealVar mu{"mu", "mu", 1.0, 0.0, 5.0}; + RooHistFunc hist{"hist_sig", "hist_sig", RooArgSet{x}, makeDataHist("hist_sig_data", x, {100.0, 200.0})}; + RooBinWidthFunction binWidth{"sig_binWidth", "sig_binWidth", hist, true}; + RooProduct shapes{"model_ch_sig_shapes", "", RooArgList{hist, binWidth}}; + RooFormulaVar norm{"sig_norm", "(1+alpha_sys*0.1)*mu", RooArgList{alpha, mu}}; + RooRealSumPdf sum{"ch_model", "ch_model", RooArgList{shapes}, RooArgList{norm}, true}; + RooGaussian constraint{"alpha_sysConstraint", "", alpha, nominal, sigma}; + RooProdPdf prod{"model_ch", "model_ch", RooArgSet{constraint}, RooFit::Conditional(sum, x)}; + ws.import(prod, RooFit::Silence()); +} + +} // namespace + +TEST(RooFitHS3, FormulaNormSysConstraint) +{ + RooHelpers::LocalChangeMsgLevel changeMsgLvl(RooFit::WARNING); + + RooWorkspace ws1; + importFormulaNormSysChannel(ws1); + const std::string json = RooJSONFactoryWSTool{ws1}.exportJSONtoString(); + EXPECT_NE(json.find(R"("constraint":"alpha_sysConstraint")"), std::string::npos) << json; + + RooWorkspace ws2; + ASSERT_TRUE(RooJSONFactoryWSTool{ws2}.importJSONfromString(json)); + EXPECT_NE(ws2.pdf("alpha_sysConstraint"), nullptr); +} + +// The +1 and -1 sigma variations of a normsys that is encoded in a formula survive the round trip. +TEST(RooFitHS3, FormulaNormSysVariations) +{ + RooHelpers::LocalChangeMsgLevel changeMsgLvl(RooFit::WARNING); + + RooWorkspace ws1; + importFormulaNormSysChannel(ws1); + RooWorkspace ws2; + ASSERT_TRUE(RooJSONFactoryWSTool{ws2}.importJSONfromString(RooJSONFactoryWSTool{ws1}.exportJSONtoString())); + + RooArgSet nset1{*ws1.var("x")}; + RooArgSet nset2{*ws2.var("x")}; + for (double alpha : {-1.0, 0.0, 1.0}) { + ws1.var("alpha_sys")->setVal(alpha); + ws2.var("alpha_sys")->setVal(alpha); + EXPECT_NEAR(ws2.pdf("ch_model")->expectedEvents(nset2), ws1.pdf("ch_model")->expectedEvents(nset1), 1e-8) + << "alpha=" << alpha; + } +} + +// Exporting the snapshot of a ModelConfig doesn't change the variables in the workspace. +TEST(RooFitHS3, ExportKeepsVariableStateWithSnapshot) +{ + RooHelpers::LocalChangeMsgLevel changeMsgLvl(RooFit::WARNING); + + RooWorkspace ws; + ws.factory("Gaussian::pdf(x[0, -5, 5], mu[0, -5, 5], 1.0)"); + RooFit::ModelConfig mc{"ModelConfig", &ws}; + mc.SetPdf("pdf"); + mc.SetParametersOfInterest("mu"); + mc.SetObservables("x"); + RooRealVar &mu = *ws.var("mu"); + mu.setVal(1.5); + mc.SetSnapshot(RooArgSet{mu}); + ws.import(mc); + + mu.setVal(0.0); + mu.setError(0.25); + mu.setConstant(true); + + const std::string json = RooJSONFactoryWSTool{ws}.exportJSONtoString(); + EXPECT_DOUBLE_EQ(mu.getVal(), 0.0); + EXPECT_DOUBLE_EQ(mu.getError(), 0.25); + EXPECT_TRUE(mu.isConstant()); + EXPECT_NE(parameterStepWidthsNode(json).find(R"("step_width":0.25)"), std::string::npos) << json; + EXPECT_EQ(RooJSONFactoryWSTool{ws}.exportJSONtoString(), json); +} + +// The "init" of an analysis is the snapshot of its ModelConfig, whatever other snapshots exist. +TEST(RooFitHS3, AnalysisInitWithSeveralSnapshots) +{ + RooHelpers::LocalChangeMsgLevel changeMsgLvl(RooFit::WARNING); + + RooWorkspace ws; + ws.factory("Gaussian::pdf(x[0, -5, 5], mu[0, -5, 5], 1.0)"); + RooRealVar &mu = *ws.var("mu"); + RooFit::ModelConfig mc{"ModelConfig", &ws}; + // The name of the second ModelConfig starts with the name of the first one. + RooFit::ModelConfig mcBkgOnly{"ModelConfig_with_poi_0", &ws}; + for (RooFit::ModelConfig *config : {&mc, &mcBkgOnly}) { + config->SetPdf("pdf"); + config->SetParametersOfInterest("mu"); + config->SetObservables("x"); + } + + // Only the last call of SetSnapshot() counts. + mu.setVal(1.0); + RooArgSet firstSet{mu}; + firstSet.setName("first"); + mc.SetSnapshot(firstSet); + mu.setVal(1.5); + RooArgSet secondSet{mu}; + secondSet.setName("second"); + mc.SetSnapshot(secondSet); + mu.setVal(0.0); + mcBkgOnly.SetSnapshot(RooArgSet{mu}); + ws.import(mc); + ws.import(mcBkgOnly); + + RooWorkspace imported; + ASSERT_TRUE(RooJSONFactoryWSTool{imported}.importJSONfromString(RooJSONFactoryWSTool{ws}.exportJSONtoString())); + + auto expectSnapshotValue = [&](const char *name, double expected) { + std::unique_ptr snapshot{static_cast(imported.obj(name))->GetSnapshot()}; + ASSERT_NE(snapshot, nullptr) << name; + EXPECT_DOUBLE_EQ(static_cast(snapshot->find("mu"))->getVal(), expected) << name; + }; + expectSnapshotValue("ModelConfig", 1.5); + expectSnapshotValue("ModelConfig_with_poi_0", 0.0); +} + +// A normsys parameter that is a function, as created by HistFactory::Measurement::AddLogNormSyst(), can't be exported. +// It would come back as a free parameter. +TEST(RooFitHS3, NormSysWithFunctionAsParameter) +{ + RooHelpers::LocalChangeMsgLevel changeMsgLvl(RooFit::WARNING); + + RooRealVar x{"x", "x", 0.0, 2.0}; + x.setBins(2); + RooRealVar beta{"beta_sys", "beta_sys", 1.0, 0.0, 5.0}; + RooRealVar nominal{"nom_beta_sys", "nom_beta_sys", 1.0}; + RooRealVar sigma{"sigma_beta_sys", "sigma_beta_sys", 0.05}; + RooFormulaVar alphaOfBeta{"alphaOfBeta_sys", "(beta_sys-1)/0.05", RooArgList{beta}}; + RooStats::HistFactory::FlexibleInterpVar norm{"sig_norm", "", RooArgList{alphaOfBeta}, 1.0, {0.95}, {1.05}}; + RooHistFunc hist{"hist_sig", "hist_sig", RooArgSet{x}, makeDataHist("hist_sig_data", x, {100.0, 200.0})}; + RooBinWidthFunction binWidth{"sig_binWidth", "sig_binWidth", hist, true}; + RooProduct shapes{"model_ch_sig_shapes", "", RooArgList{hist, binWidth}}; + RooRealSumPdf sum{"ch_model", "ch_model", RooArgList{shapes}, RooArgList{norm}, true}; + RooGaussian constraint{"beta_sysConstraint", "", beta, nominal, sigma}; + RooProdPdf prod{"model_ch", "model_ch", RooArgSet{constraint}, RooFit::Conditional(sum, x)}; + + RooWorkspace ws; + ws.import(prod, RooFit::Silence()); + expectExportThrowsWithError(ws, "cannot find constraint for alphaOfBeta_sys"); +} + +// Documents that predate HS3 v0.2 imply a unit Gaussian constraint for normsys and histosys modifiers. +TEST(RooFitHS3, HistFactoryImplicitConstraintInOldDocuments) +{ + RooHelpers::LocalChangeMsgLevel changeMsgLvl(RooFit::WARNING); + + const std::string modifiers = R"( + {"name": "norm", "parameter": "alpha_norm", "type": "normsys", "data": {"lo": 0.8, "hi": 1.2}}, + {"name": "shape", "parameter": "alpha_shape", "type": "histosys", + "data": {"lo": {"contents": [8.0, 18.0]}, "hi": {"contents": [12.0, 22.0]}}})"; + const std::string defaultInterpolation = R"({"type": "mult", "in": "poly6", "out": "exp"})"; + + RooWorkspace ws; + ASSERT_TRUE( + RooJSONFactoryWSTool{ws}.importJSONfromString(makeHistFactoryJSON(modifiers, defaultInterpolation, "0.1.90"))); + EXPECT_NE(ws.pdf("alpha_normConstraint"), nullptr); + EXPECT_NE(ws.pdf("alpha_shapeConstraint"), nullptr); + EXPECT_NE(ws.var("nom_alpha_norm"), nullptr); +} + +// Parameters without constraint are reported once per channel, because older RooFit versions constrained them. +TEST(RooFitHS3, HistFactoryUnconstrainedModifierWarning) +{ + RooHelpers::LocalChangeMsgLevel changeMsgLvl(RooFit::WARNING); + + const std::string json = makeHistFactoryJSON(R"( + {"name": "norm", "parameter": "alpha_norm", "type": "normsys", "data": {"lo": 0.8, "hi": 1.2}}, + {"name": "norm2", "parameter": "alpha_norm2", "type": "normsys", "data": {"lo": 0.9, "hi": 1.1}})", + R"({"type": "mult", "in": "poly6", "out": "exp"})"); + RooWorkspace ws; + const std::string warnings = captureMessages(RooFit::WARNING, RooFit::ObjectHandling, [&] { + EXPECT_TRUE(RooJSONFactoryWSTool{ws}.importJSONfromString(json)); + }); + EXPECT_EQ(countOccurrences(warnings, "has no constraint"), 1u) << warnings; +} + +// Gamma parameters can't have log-normal constraints. Requesting one must not silently give a Gaussian. +TEST(RooFitHS3, HistFactoryLogNormalGammaConstraint) +{ + for (std::string type : {"LogNormal", "Lognormal"}) { + for (std::string key : {"constraint", "constraint_type"}) { + expectImportThrowsWithError(makeHistFactoryJSON(R"({"name": "shape", "type": "shapesys", ")" + key + + R"(": ")" + type + R"(", "data": {"vals": [0.1, 0.2]}})"), + "unsupported constraint '" + type + "'"); + } + } + + expectImportThrowsWithError(R"({ + "metadata": {"hs3_version": "0.2"}, + "distributions": [{ + "name": "model_channel0", "type": "histfactory_dist", + "axes": [{"name": "x", "min": 0.0, "max": 2.0, "nbins": 2}], + "staterror": {"constraint_type": "LogNormal"}, + "samples": [{"name": "sig", "data": {"contents": [10.0, 20.0]}, + "modifiers": [{"name": "mcstat", "type": "staterror"}]}] + }] + })", + "unsupported constraint 'LogNormal'"); +} + +// ROOT 6.38 and 6.40.00 wrote the constraint type of the staterror modifier as "constraint_type". +TEST(RooFitHS3, HistFactoryStatErrorConstraintType) +{ + RooHelpers::LocalChangeMsgLevel changeMsgLvl(RooFit::WARNING); + + RooWorkspace ws; + ASSERT_TRUE(RooJSONFactoryWSTool{ws}.importJSONfromString( + makeHistFactoryJSON(R"({"name": "staterror", "type": "staterror", "constraint_type": "Gauss"})"))); + EXPECT_NE(dynamic_cast(ws.pdf("gamma_stat_channel0_bin_0_constraint")), nullptr); +} + +// Errors of zero also stay zero if there is no positive error at all. +TEST(RooFitHS3, BinnedDataAllErrorsZero) +{ + RooHelpers::LocalChangeMsgLevel changeMsgLvl(RooFit::WARNING); + + RooWorkspace ws; + ASSERT_TRUE(RooJSONFactoryWSTool{ws}.importJSONfromString(R"({ + "metadata": {"hs3_version": "0.2"}, + "data": [{"name": "d", "type": "binned", "axes": [{"name": "x", "min": 0.0, "max": 2.0, "nbins": 2}], + "contents": [10.0, 20.0], "errors": [0.0, 0.0]}] + })")); + auto *data = dynamic_cast(ws.data("d")); + ASSERT_NE(data, nullptr); + EXPECT_DOUBLE_EQ(data->weight(1), 20.0); + EXPECT_DOUBLE_EQ(data->weightSquared(0), 0.0); + EXPECT_DOUBLE_EQ(data->weightSquared(1), 0.0); +} + +// Parameter points can set the index categories of combined distributions in the same document. +TEST(RooFitHS3, ParameterPointsWithDocumentCategories) +{ + RooHelpers::LocalChangeMsgLevel changeMsgLvl(RooFit::WARNING); + + for (std::string value : {R"("b")", "1"}) { + RooWorkspace ws; + ASSERT_TRUE(RooJSONFactoryWSTool{ws}.importJSONfromString(R"({ + "metadata": {"hs3_version": "0.2"}, + "domains": [{"name": "default_domain", "type": "product_domain", "axes": [{"name": "x", "min": -5, "max": 5}]}], + "distributions": [ + {"name": "pdf_a", "type": "gaussian_dist", "x": "x", "mean": 0.0, "sigma": 1.0}, + {"name": "pdf_b", "type": "gaussian_dist", "x": "x", "mean": 1.0, "sigma": 1.0} + ], + "parameter_points": [{"name": "point", "parameters": [{"name": "channel", "value": )" + + value + R"(}]}], + "misc": {"ROOT_internal": {"combined_distributions": {"sim_pdf": { + "distributions": ["pdf_a", "pdf_b"], "index_cat": "channel", "indices": [0, 1], "labels": ["a", "b"]}}}} + })")); + ASSERT_NE(ws.cat("channel"), nullptr) << value; + auto *channel = dynamic_cast(ws.getSnapshot("point")->find("channel")); + ASSERT_NE(channel, nullptr) << value; + EXPECT_EQ(channel->getCurrentLabel(), std::string("b")) << value; + } +} + +// The warnings of the RooJSONFactoryWSTool are shown with the default message streams. +TEST(RooFitHS3, WarningsAreShownByDefault) +{ + EXPECT_TRUE( + RooMsgService::instance().isActive(static_cast(nullptr), RooFit::ObjectHandling, RooFit::WARNING)); + const std::string warnings = + captureMessages(RooFit::WARNING, RooFit::ObjectHandling, [] { RooJSONFactoryWSTool::warning("test message"); }); + EXPECT_NE(warnings.find("test message"), std::string::npos) << warnings; +} diff --git a/roofit/roofitcore/inc/RooFit/ModelConfig.h b/roofit/roofitcore/inc/RooFit/ModelConfig.h index 3f3d9b302cba4..6a6d3018f3713 100644 --- a/roofit/roofitcore/inc/RooFit/ModelConfig.h +++ b/roofit/roofitcore/inc/RooFit/ModelConfig.h @@ -302,6 +302,9 @@ class ModelConfig final : public TNamed, public RooWorkspaceHandle { /// get RooArgSet for parameters for a particular hypothesis (return nullptr if not existing) const RooArgSet *GetSnapshot() const; + /// get the name of the workspace snapshot saved by SetSnapshot() (empty if there is none) + const std::string &GetSnapshotName() const { return fSnapshotName; } + void LoadSnapshot() const; RooWorkspace *GetWS() const override; diff --git a/tutorials/roofit/roofit/rf515_hfJSON.json b/tutorials/roofit/roofit/rf515_hfJSON.json index 852280232fd73..382b8f9c39c06 100644 --- a/tutorials/roofit/roofit/rf515_hfJSON.json +++ b/tutorials/roofit/roofit/rf515_hfJSON.json @@ -1,140 +1,180 @@ { - "misc": { - "ROOT_internal": { - "combined_datasets": { - "observed" : { - "index_cat": "channelCat", - "indices": [0], - "labels": ["channel1"] - } + "misc": { + "ROOT_internal": { + "combined_datasets": { + "observed": { + "index_cat": "channelCat", + "indices": [ + 0 + ], + "labels": [ + "channel1" + ] + } + }, + "combined_distributions": { + "main": { + "distributions": [ + "model_channel1" + ], + "index_cat": "channelCat", + "indices": [ + 0 + ], + "labels": [ + "channel1" + ] + } + }, + "ModelConfigs": { + "main": { + "combined_data_name": "observed", + "mcName": "ModelConfig", + "pdfName": "main" + } + } + } + }, + "analyses": [ + { + "name": "main", + "likelihood": "main_likelihood", + "parameters_of_interest": [ + "mu" + ] + } + ], + "likelihoods": [ + { + "name": "main_likelihood", + "distributions": [ + "model_channel1" + ], + "data": [ + "observed_channel1" + ] + } + ], + "distributions": [ + { + "name": "model_channel1", + "axes": [ + { + "name": "obs_x_channel1", + "max": 2.0, + "min": 1.0, + "nbins": 2 + } + ], + "default_interpolation": { + "type": "mult", + "in": "poly6", + "out": "exp" + }, + "samples": [ + { + "data": { + "contents": [ + 20, + 10 + ] + }, + "modifiers": [ + { + "data": { + "hi": 1.05, + "lo": 0.95 + }, + "constraint": "Gauss", + "name": "syst1", + "type": "normsys" + }, + { + "name": "mu", + "type": "normfactor" + } + ], + "name": "signal" }, - "combined_distributions": { - "main" : { - "distributions": [ - "model_channel1" - ], - "index_cat": "channelCat", - "indices": [0], - "labels": ["channel1"] - } + { + "data": { + "contents": [ + 100, + 0 + ], + "errors": [ + 5, + 0 + ] + }, + "modifiers": [ + { + "data": { + "hi": 1.05, + "lo": 0.95 + }, + "constraint": "Gauss", + "name": "syst2", + "type": "normsys" + }, + { + "name": "mcstat", + "type": "staterror" + } + ], + "name": "background1" }, - "ModelConfigs": { - "main": { - "combined_data_name": "observed", - "mcName": "ModelConfig", - "pdfName": "main" - } + { + "data": { + "contents": [ + 0, + 100 + ], + "errors": [ + 0, + 10 + ] + }, + "modifiers": [ + { + "data": { + "hi": 1.05, + "lo": 0.95 + }, + "constraint": "Gauss", + "name": "syst3", + "type": "normsys" + }, + { + "name": "mcstat", + "type": "staterror" + } + ], + "name": "background2" + } + ], + "type": "histfactory_dist" + } + ], + "data": [ + { + "name": "observed_channel1", + "axes": [ + { + "name": "obs_x_channel1", + "nbins": 2, + "min": 1, + "max": 2 } - } - }, - "analyses": [ - { - "name": "main", - "likelihood": "main_likelihood", - "parameters_of_interest": ["mu"] - } - ], - "likelihoods": [ - { - "name": "main_likelihood", - "distributions": ["model_channel1"], - "data": ["observed_channel1"] - } - ], - "distributions": [ - { - "name": "model_channel1", - "axes": [ - { - "name": "obs_x_channel1", - "max": 2.0, - "min": 1.0, - "nbins": 2 - } - ], - "samples": [ - { - "data": { - "contents": [20, 10] - }, - "modifiers": [ - { - "data": { - "hi": 1.05, - "lo": 0.95 - }, - "name": "syst1", - "type": "normsys" - }, - { - "name": "mu", - "type": "normfactor" - } - ], - "name": "signal" - }, - { - "data": { - "contents": [100, 0], - "errors": [5, 0] - }, - "modifiers": [ - { - "data": { - "hi": 1.05, - "lo": 0.95 - }, - "name": "syst2", - "type": "normsys" - }, - { - "name": "mcstat", - "type": "staterror" - } - ], - "name": "background1" - }, - { - "data": { - "contents": [0, 100], - "errors": [0, 10] - }, - "modifiers": [ - { - "data": { - "hi": 1.05, - "lo": 0.95 - }, - "name": "syst3", - "type": "normsys" - }, - { - "name": "mcstat", - "type": "staterror" - } - ], - "name": "background2" - } - ], - "type": "histfactory_dist" - } - ], - "data": [ - { - "name": "observed_channel1", - "axes": [ - { - "name": "obs_x_channel1", - "nbins": 2, - "min": 1, - "max": 2 - } - ], - "contents": [122, 112], - "type": "binned" - } - ], - "metadata": { - "hs3_version" : "0.1.90" - } + ], + "contents": [ + 122, + 112 + ], + "type": "binned" + } + ], + "metadata": { + "hs3_version": "0.1.90" + } }