From ac464a9e8e79b44451ff95f9f47633a72d903f57 Mon Sep 17 00:00:00 2001 From: Carsten Burgard Date: Thu, 5 Nov 2026 21:19:52 +0100 Subject: [PATCH] [RF][HS3] Bring the HS3 import and export closer to the HS3 standard Fix several deviations of RooFit's HS3 layer from the HS3 standard (hep-statistics-serialization-standard master at cb3e9eb). None of the changes depend on pending HS3 PRs; the ones that do follow in #23840. Stricter checks only apply to documents that declare a recent enough hs3_version, so files written by older ROOT versions remain readable. Metadata and names: * hs3_version must be a string. Its MAJOR.MINOR is parsed, and a warning is printed if it can't be parsed or is newer than what RooFit writes. Importers can query it with RooJSONFactoryWSTool::hs3VersionInput(). * Duplicate names of top-level objects are an error from HS3 0.3 on, and only a warning for older documents. * On export, likelihoods are named "_likelihood", analyses are named after their ModelConfig, and transformed helper functions that several pdfs share are written only once. Import: * The "default_domain" is optional. Without it, variables get the range of the first domain that lists them. * Parameters that only appear in a parameter point are created. They are constant if "const" is true or if no domain lists them. A string value sets the label of a category. Index categories of combined distributions are now created before the parameter points. * Observables of unbinned data without a domain range take their range from the "min"/"max" of the data axes. Infinite bounds (null) are supported. * "init" of an analysis is restored as the ModelConfig snapshot under the same name. A missing parameter point is reported. * An explicit "extended" flag of mixture_dist is checked against the number of coefficients. * Malformed input is rejected instead of being read out of bounds: covariance and correlation matrices must be square, "errors" must have as many entries as "contents", and "edges" must contain at least two strictly ascending values. * Errors of zero in binned data stay zero. Before, they could be read as squared weight = content. Export: * Axes of unbinned data only carry "name", "min" and "max". * The ModelConfig snapshot is written as "init" of the analysis, which no longer has the side effect of loading the snapshot values. * Ternaries in formulas are written as ifelse(c, a, b), and ifelse() is translated back to a ternary on import. Type keys (the old keys are still imported, with a deprecation warning): * ARGUS_dist -> argus_dist * binsampling -> binsampling_dist. Before, RooFit could not read back its own export. * rate_extended_dist uses the fields "dist" and "rate" instead of "pdf" and "norm". HistFactory: * "LogNormal" is accepted as a constraint type, alongside "Lognormal". Log-normal constraints of gamma parameters are rejected instead of silently becoming Gaussian. * The parameter of a normfactor is named after "parameter". "name" is only the label of the modifier. * normsys/histosys modifiers without constraint are unconstrained from HS3 0.2 on, with one warning per channel. Older documents, and objects imported with importJSONElement(), keep the implicit unit Gaussian. On export, unconstrained modifiers are allowed. A normsys with a function as parameter is refused. * The staterror constraint type is read from the modifier ("constraint", or "constraint_type" as written by ROOT 6.38 and 6.40.00), with the channel-level setting as fallback. It is written in both places, so Gaussian stat errors are no longer lost in a round trip. * A modifier without interpolation and without "default_interpolation" is an error from HS3 0.3 on. Older documents keep code 4, with a deprecation warning. * Fix the export of normsys modifiers recognized from formulas like 1+0.1*alpha: hi/lo were written as 0.1/-0.1 instead of the absolute factors 1.1/0.9, and the constraint was dropped. Other: * Warnings of RooJSONFactoryWSTool are logged on the ObjectHandling topic. Before, they went to the IO topic, which no default stream shows. * Add ModelConfig::GetSnapshotName(). * Update the RooFitHS3 developer documentation. * Make the constraints and interpolation of the rf515_hfJSON tutorial explicit. The fit result is unchanged. Files written with this version use the new type keys and ifelse(), which ROOT 6.40 and older cannot read. Assisted-by: claude-code:claude-opus-5-5 (cherry picked from commit d61fecf4e576da73db996f825ca5467eddb6ebd0) --- roofit/doc/developers/roofit_hs3.md | 69 +- .../hs3/inc/RooFitHS3/RooJSONFactoryWSTool.h | 4 + roofit/hs3/src/Domains.cxx | 29 +- roofit/hs3/src/JSONFactories_HistFactory.cxx | 194 ++- roofit/hs3/src/JSONFactories_RooFitCore.cxx | 295 ++++- roofit/hs3/src/JSONIOUtils.cxx | 67 + roofit/hs3/src/JSONIOUtils.h | 5 + roofit/hs3/src/RooFitHS3_wsexportkeys.cxx | 2 +- .../src/RooFitHS3_wsfactoryexpressions.cxx | 9 + roofit/hs3/src/RooJSONFactoryWSTool.cxx | 214 ++- roofit/hs3/test/testRooFitHS3.cxx | 1153 ++++++++++++++++- roofit/roofitcore/inc/RooFit/ModelConfig.h | 3 + tutorials/roofit/roofit/rf515_hfJSON.json | 310 +++-- 13 files changed, 2108 insertions(+), 246 deletions(-) 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" + } }