Skip to content

[RF][HS3] category_conditional_dist and RooMultiPdf export (HS3 #147; depends on #24005) - #24079

Draft
cburgard wants to merge 19 commits into
root-project:masterfrom
cburgard:hs3-category-conditional
Draft

cburgard wants to merge 19 commits into
root-project:masterfrom
cburgard:hs3-category-conditional

Conversation

@cburgard

@cburgard cburgard commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Important

Depends on #24005 (which depends on #24001). This branch is stacked on it: please review only the last 2 commits. It stays in draft until HS3 PR #147 is merged, and will be rebased when the PRs below it are merged.

Rebased onto master after 36821748060 ("Import and export through a typed representation of HS3 documents"). Where the commits conflicted with it, they were re-implemented on the typed HS3Document (parser, validator, writer) instead of the removed code; the messages of these commits say how ("Ported onto the typed HS3 document: …").

The first commit was part of #24005. HS3 moved category_conditional_dist and the categorical function from #119 to #147, so the ROOT side is split the same way.

This Pull request:

Reads and writes the HS3 category_conditional_dist of HS3 PR #147. It selects one of several sub-distributions by the value of categorical parameters and evaluates it conditioned on that value. This is the construction used for discrete profiling over alternative shape hypotheses.

It is read as a RooSimultaneous. RooSimultaneous and existing RooMultiPdf objects are written as category_conditional_dist. The penalty term of RooMultiPdf is dropped with a warning; how to represent it is the main open question, see below.

Changes or fixes:

1. category_conditional_dist ↔ RooSimultaneous

RooFit has this distribution as the "switch mode" of RooSimultaneous (since #23142). When its index category is not an observable, it evaluates to the component selected by the current state, and its expected number of events is the one of that component.

Direction Change
Import A category_conditional_dist becomes a RooSimultaneous over its categorical parameters, which must be declared by categorical axes of a domain.
Export RooFit doesn't tell the two uses of a RooSimultaneous apart by the object, only by the normalization set. A combined distribution is the top-level pdf of a model, while a conditional one (e.g. a choice between alternative background shapes) is a building block of a model. So a RooSimultaneous that another pdf uses is written as category_conditional_dist, with categorical_parameter instead of columns. One that no other pdf uses is written as combined_dist, as before.
Import A category_conditional_dist that no other distribution uses is read with a warning that it will be written back as a combined_dist. The pairing checks of a combined_dist with combined data in a likelihood use the type in the document.
Export The value of a RooCategory in a snapshot is written to the parameter point as its label (categorical parameters), and the category is declared in the default domain. The columns of combined distributions are not parameters and are left out.

For the check of distinct variates of #24005 (section 8 there), a category_conditional_dist has the variates of its alternatives, which may share them: only one is selected. A RooSimultaneous that no other pdf uses is written as combined_dist, so if its alternatives share an observable, the export now fails with the shared-variate error instead of writing a combined_dist with the wrong meaning; the import warning for an unused category_conditional_dist says so.

The distinction by clients is a heuristic. A RooSimultaneous used in a product with constraint terms, for example, would be taken as conditional. It is the best criterion available without an explicit marker, and we would prefer a more reliable one in the future (see question 2).

Test: CategoryConditionalDist covers:

  • import, with the category_conditional_dist used in a mixture_dist;
  • the value for both states;
  • the export, with the parameter in the domain and in the parameter point;
  • a stable document;
  • a RooSimultaneous built in RooFit can't be written as a combined_dist at the top level (its alternatives share the observable), and is a category_conditional_dist once a RooAddPdf uses it;
  • in a likelihood, the alternatives' observable is a variate of the term that uses the category_conditional_dist;
  • the warning for a category_conditional_dist that is not used;
  • undeclared categorical parameters are an error.

2. RooMultiPdf → category_conditional_dist

Exporting a RooMultiPdf failed ("no export keys available"). It is now written as a category_conditional_dist:

  • its category is the categorical parameter;
  • the key of the pdf at list position i is the label of the state with index i, since RooMultiPdf selects by index;
  • a pdf without a state of its index is an error.

The penalty term that RooMultiPdf adds to the likelihood (a correction for the number of free parameters of the selected pdf) has no HS3 representation yet. It is dropped with a warning, and its constants are not written. The category_conditional_dist is read back as a RooSimultaneous that evaluates to the same pdf, so the class doesn't survive a round trip.

Test: MultiPdfExport checks:

  • the warning;
  • the keys and components;
  • no penalty constants in the document;
  • the value of the read-back RooSimultaneous and of a mixture using it, for both states.

Questions for the RooFit maintainers

#23454 (comment) describes the plan to deprecate RooMultiPdf in favour of RooSimultaneous, and to pass the penalty term of discrete profiling as an argument at likelihood creation (createNLL()/fitTo()). This PR follows that direction: the import target is RooSimultaneous, and RooMultiPdf is only written. Two questions remain.

  1. The penalty term. This is the central open question. If the penalty becomes an argument of the likelihood creation, then on the HS3 side it belongs to the likelihood or the analysis, not to the distribution. The note in HS3 #147 instead suggests writing it as a categorical function used by a distribution in aux_distributions. Which form fits the planned interface? Until this is settled, the export drops the penalty with a warning, and we would bring the answer to HS3.
  2. Telling the conditional use apart. Is the criterion "used by another pdf" acceptable for now? Will the harmonized RooSimultaneous know about its conditional use more explicitly, so that the export can rely on it?

The same questions for RooMultiReal and the HS3 categorical function are collected in #24006.

Not covered, deferred

  • The HS3 categorical function (→ RooMultiReal): #24006.
  • The penalty term of RooMultiPdf (question 1).
  • HS3 #147 requires that the categorical parameters never take a combination of values without an entry in components. The import doesn't check that a parameter's domain only contains labels with a component.

Behaviour changes users may notice

  • Values of categorical parameters (RooCategorys in snapshots that are not columns of a combined distribution) appear in parameter points as strings.
  • A RooSimultaneous that another pdf uses is written as category_conditional_dist instead of combined_dist.
  • A RooMultiPdf can be exported, without its penalty term (warning), and comes back as a RooSimultaneous.

Checklist:

  • tested changes locally
  • updated the docs (if necessary)

Tests run (Linux, Ubuntu 26.04, GCC 15.2, RelWithDebInfo, -Droofit=ON -Dtesting=ON): testRooFitHS3 135 → 137 tests (2 new), testHistFactory 60, all passing. Each commit of this PR and of the rebased #24005 was built and tested on its own. clang-format clean.

AI assistance: Claude Code implemented the changes, the tests and this description under review of the author: claude-fable-5-1 the original category_conditional_dist commit; claude-opus-5-5 the split out of #24005, the structural distinction, and the RooMultiPdf export. The commits carry the corresponding Assisted-by trailers. The port onto the typed HS3 document of 3682174 was done by claude-opus-5-5 as well.

🤖 Generated with Claude Code

cburgard and others added 19 commits November 13, 2026 12:01
HS3 PR root-project#108 added categorical axes (`{"name": ..., "categories": [...]}`) to
domains and data sets. RooFit used to read a categorical domain axis as an
unbounded RooRealVar and could not read categorical axes of data.

* Domains: a categorical axis creates a RooCategory with one state per
  category, in the order of the document and with the position as index. An
  existing RooCategory of that name must have the same labels. The axis must
  not have `min`, `max`, `nbins` or `edges`, and the labels must be unique
  non-empty strings. A string value in a parameter point sets the state.
* Unbinned data: the entries of a categorical axis are labels of the
  category, which is declared by a domain.
* Binned data: a categorical axis is one bin per category. HS3 identifies
  categories by name, so the data may list them in another order than the
  domain. RooDataHist looks up the bin of a category by the order of the
  states of the category it is given, so the data's category takes over the
  states and their order from the workspace category, and the contents and
  errors are permuted from the order of the document accordingly.
* histfactory_dist: categorical axes are rejected with an explicit error.

The labels and the absence of a range or binning are checked when the
document is parsed (HS3Parser), and that all domains agree on a
categorical axis when it is validated (HS3Validate). The categories are
created and checked against the workspace in RooJSONFactoryWSTool.

Export is unchanged: datasets and distributions with categories are still
written with the ROOT-internal combined form until the HS3 representation of
combined data (HS3 PR root-project#119) is final.

Assisted-by: claude-code:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HS3 PR root-project#126 defines the storage order of multi-dimensional binned contents:
the last axis listed in "axes" varies fastest. This is RooDataHist's own
order, and RooFit writes the axes in the order of the variables of the
RooDataHist, so no change is needed for binned data. A new test pins it.

HistFactory channels write the axes from the first sample's histogram, and
the contents of every sample from its own histogram. If two histograms of a
channel had their observables in different orders, the contents would be
written along different axes. This is now an explicit error.

Assisted-by: claude-code:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HS3 PR root-project#126 lays out every per-bin array with the last axis varying fastest,
like RooDataHist. ParamHistFunc, however, orders its parameters like the bins
of a TH1, with the first variable varying fastest. For more than one
dimension, RooFit therefore wrote the parameters of ParamHistFunc ("step")
and of the staterror and shapesys/shapefactor modifiers of HistFactory in a
different order than the bin contents. On import, it paired the relative
uncertainty of bin k with the k-th gamma in TH1 order, so the gammas of
multi-dimensional channels got the constraints of other bins.

The parameters are now converted between the two orders at the HS3
boundary: they are written in bin order (via ParamHistFunc::getParameter),
paired with the per-bin values in bin order, and only then put into TH1
order for the ParamHistFunc. One-dimensional functions are unchanged.

Documents before HS3 0.3 with multi-dimensional ParamHistFuncs don't define
the order; none are known, and they are rejected with an error instead of
being guessed.

New tests check the order in the written file for binned data, RooHistFunc
and ParamHistFunc (each with both orders of the variables), and for a
two-dimensional HistFactory channel with staterror.

Assisted-by: claude-code:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HS3 root-project#126 was merged with a last change (root-project#137): the range of an
irregular binning is given by its "edges", and documents of HS3 0.3 or
later must not have "min" or "max" alongside them, in binned data and in
domains. Documents before 0.3 may have them, equal to the first and last
edge.

- Export: binned axes with edges (HS3Writer, and writeAxisBinning for
  histograms and generic formulas) are written without "min" and "max".
  Domain axes with edges are written without them if they agree with the
  edges, so that a range that differs from its binning is not lost.
- Import: edges without "min" and "max" were already read. In documents
  of HS3 0.3 or later, "min" or "max" alongside "edges" give a warning
  (HS3Upgrade); that they agree with the edges is checked as before.

Test: IrregularBinnedAxesHaveRange is replaced by
IrregularBinnedAxesWithoutRange (data and domain axes without range,
round trip, and the warning only for HS3 0.3 documents).

Assisted-by: claude-code:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A RooSimultaneous used to be exported only as ROOT-internal information
(misc.ROOT_internal.combined_distributions). HS3 PR root-project#119 defines the
"combined_dist": its components are selected by a key with one label per
column, and the columns are categorical axes of the domains.

Export: a RooSimultaneous becomes a "combined_dist". The columns are its
index category or, for a RooSuperCategory index, the input categories,
sorted by name so that a distribution and a data set agree on the order
without looking at each other. The columns are declared as categorical
axes of the default domain. The components are written in the order of
the columns and their states, which does not depend on the order of the
inputs of a RooSuperCategory, so the document is stable under round
trips. States without a pdf are not written. Category indices are not
part of HS3: after a round trip, the index is the position in the
domain's "categories".

Import: a "combined_dist" becomes a RooSimultaneous over the column
category, or over a RooSuperCategory of the columns in the order of the
document. The columns must be declared by categorical axes of a domain;
keys are validated against them. The ROOT-internal form of files written
by earlier ROOT versions is still read.

Tests: CombinedDist (structure, round trip, legacy form),
CombinedDistSeveralColumns, CombinedDistImportErrors.

Ported onto the typed HS3 document: the categories are recorded in the
export state and written as categorical axes of the "default_domain",
sorted by name together with the continuous axes.

Assisted-by: claude-code:claude-fable-5-1
Assisted-by: claude-code:claude-opus-5-5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A RooDataSet with a category observable used to be written as its
components plus ROOT-internal information on how to combine them
(misc.ROOT_internal.combined_datasets), and the likelihood of a
ModelConfig listed the components of the RooSimultaneous paired with
the components of the data. HS3 PR root-project#119 defines the "combined" data
set: sub-data sets selected by a key, with the columns declared as
categorical axes of the domains.

Export: a data set whose observables include RooCategories becomes a
"combined" entry with the categories as columns, sorted by name, and
one sub-data set per key, named after the data set and the labels. The
categories are declared in the default domain. A key is written also
without entries, since RooFit treats a channel without entries as
present and empty. If the workspace has a RooSimultaneous with the same
columns, each sub-data set carries only the observables of its
component, as before, and keys without entries for which it has no
component are left out; otherwise all observables are written. The
likelihood of a ModelConfig now pairs the RooSimultaneous with the
combined data set, and an analysis is written for every combined data
set with the same columns. A RooSimultaneous without matching data gets
an analysis named after the ModelConfig, like any other pdf, instead of
one named after the pdf.

Import: a "combined" data set becomes a RooDataSet with one category
column per entry of "columns", filled from the components, which are
then not imported on their own. One code path for one and for several
columns. The ModelConfig observables then include the index category.
The ROOT-internal form of files written by earlier ROOT versions is
still read; its likelihoods list the components, so it is handled after
the analyses as before.

An empty weighted data set over a variable without bins was detected as
binned data with zero bins, which made the document of a present but
empty sub-data set unstable under round trips. The detection now
requires a binning.

Tests: CombinedData (structure, round trip, data without pdf, legacy
form), CombinedDataSeveralColumns, CombinedDataImportErrors.

Ported onto the typed HS3 document: a combined data set is a
CombinedData in HS3::Data. The parser checks its structure, the
validation checks the columns against the categorical axes of the
domains, the labels of the keys, and that each sub-data set is a
data set of the document that belongs to one combined data set.
The likelihood of a ModelConfig names the RooSimultaneous, and
the constraint terms of its HistFactory channels are still listed
in "aux_distributions".

Assisted-by: claude-code:claude-fable-5-1
Assisted-by: claude-code:claude-opus-5-5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HS3 root-project#119 treats a key of a combined distribution without a sub-data set
in the paired combined data set as an unobserved component, which is
marginalised, and a present but empty sub-data set as observed with zero
events. RooFit has no notion of an unobserved component: a combined
RooDataSet without entries for a channel is a present and empty channel,
and both evaluation backends then add the expected number of events of
an extended component to the likelihood (see ROOT issue root-project#24004).

When a likelihood pairs a combined_dist with a combined data set, the
import now checks the keys. A missing sub-data set of an extended
component is an error, since the fit would differ from what the document
means. A missing sub-data set of a non-extended component changes
nothing and is a warning, as is a sub-data set for a key without a
component, which RooFit ignores. Differing columns are an error.

Test: CombinedDataPairing.

Ported onto the typed HS3 document: the keys of the combined data
set are taken from the document.

Assisted-by: claude-code:claude-fable-5-1
Assisted-by: claude-code:claude-opus-5-5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A HistFactory model is the standard case of a RooSimultaneous with
combined data sets. HS3ClosureLoop now also checks that it is written
as a combined_dist with combined data sets, without the ROOT_internal
combined_distributions and combined_datasets, and read back into a
RooSimultaneous over channelCat and data sets with the index category,
with the same number of entries and the same sum of weights. The
stability of the document and the fit results were already checked.

Assisted-by: claude-code:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The names of a RooSimultaneous and of a combined data set used to live
in misc.ROOT_internal, so a document without "misc" lost them. Now they
are part of the combined_dist and combined entries. The test exports a
workspace with a RooSimultaneous (not named "simPdf", the historical
default), removes "misc", imports the document and checks that the pdf,
its components, the index category and the data set keep their names,
that the ModelConfig finds its pdf through the likelihood, and that the
re-export describes the same objects.

Assisted-by: claude-code:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ervable

HS3 PR root-project#119 requires that the components of a combined distribution share
no variate, and that the sub-data sets of a combined data set have distinct
axis names: a quantity measured in several components is a separate random
variable in each of them, e.g. "mass_SR" and "mass_CR", since it follows a
different distribution there.

A RooSimultaneous whose components use the same observable can therefore
not be written together with its data set. Exporting such a data set, split
by the components of the RooSimultaneous, is now an error that asks to give
the observable a separate name in each component. A document whose
sub-data sets share an axis is still read, with a warning.

The test of a data set with several columns now uses a separate observable
per component.

Assisted-by: claude-code:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a combined distribution

A combined data set is paired with a combined distribution, and HS3 PR root-project#119
requires its sub-data sets to have distinct axis names. A data set with a
category observable but without a matching RooSimultaneous was split by
the category anyway, so all sub-data sets carried the same observables.

Without a matching RooSimultaneous, the category is an ordinary
observable: such a data set is now written as a single data set with a
categorical axis (HS3 PR root-project#108), binned or unbinned, which RooFit reads back
since the axis handling PR. The categories are declared by a categorical
axis of the default domain, and the entries of an unbinned categorical axis
are the labels.

Ported onto the typed HS3 document: the categorical axes are written
through the HS3::CategoricalAxis of binned and unbinned data, with
the position of the label as the entry of unbinned data.

Assisted-by: claude-code:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… key

HS3 PR root-project#119 requires that the components of a combined distribution share
no variate. Workspaces whose RooSimultaneous components share an observable
(common in older workspaces and in CMS Combine) can therefore not be
exported. The new option RooJSONFactoryWSTool::config()
.renameCombinedObservables (off by default) renames the observables of
every combined data set per key on export: "obs_<key>" (the labels of the
key joined by "_"), or "obs_<key>_<observable>" if a component has several
observables.

The components of the matching RooSimultaneous are written with the
renamed observables: they are rebuilt with RooCustomizer, which clones
every node that depends on a renamed observable with the suffix "_<key>",
so nodes shared by several components get one copy per component.
Observables that already have their new name are left alone, so that a
second export of an imported document gives the same document. The
likelihood is unchanged.

Ported onto the typed HS3 document. The data set of the test is called
"data" instead of "obs": its sub-data sets "obs_SR" and "obs_CR" had
the names of the renamed observables, which the validation of HS3
0.3 documents now rejects.

Assisted-by: claude-code:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HS3 PR root-project#119 now states that a combined_dist must not have the component
"extended": whether it is extended follows from its components. The
importer ignored such a component; it now raises an error, whatever its
value. RooFit never writes it.

Assisted-by: claude-code:claude-opus-5-5
HS3 requires the components of a combined distribution to have distinct
variates, and the terms of a likelihood as well. A variate in two of
them is one quantity modelled twice, e.g. a constraint that is both a
term of the likelihood and a factor of a channel, or two quantities with
one name, e.g. the observable "mass" of two channels, which need their
own names ("mass_SR", "mass_CR").

All cases are now checked by one function, sharedVariates(), on the
document, after the export and before the import:
- the terms of each likelihood: its distributions (the components of a
  combined_dist, or of a RooSimultaneous in ROOT_internal of older
  documents, are terms of their own), its "aux_distributions", and the
  constraint terms named by the modifiers of its histfactory_dist terms;
- the components of the combined distributions and of the combined data
  sets that no likelihood lists.
The variates of a distribution are the "x" of the fundamental
distributions and the axes of distributions with axes, through products,
mixtures and combined distributions; those of a data set are its axes.
This replaces the two checks on the sub-data sets of combined data sets:
the export error (only with a data set) and the import warning.

- Export: a constraint that is both a factor of a channel's product and
  a term of the likelihood (a HistFactory constraint, or an external
  constraint of the ModelConfig) is removed from the product, so that it
  is written once; RooFit counts it once. Only products that no other
  distribution uses are changed. Remaining shared variates are an error,
  now also for a RooSimultaneous without data set. The error mentions
  config().renameCombinedObservables, and the new
  config().allowExportSharedVariates, which writes the document anyway,
  with a warning for every shared variate. It is not valid HS3, and has
  to be fixed, e.g. by hand in the JSON, before it can be read back.
- Import: shared variates are an error for HS3 0.3, and a warning for
  documents of older versions, which RooFit wrote that way.

Tests:
- CombinedComponentsMustNotShareObservables: the export error, the
  override with warning, the rejection of the written document, and of
  a combined data set with a shared axis (read with a warning as 0.2).
- SharedVariatesInLikelihood: two channels with the observable "x".
- ConstraintInModelAndExternal: a constraint in the model and in the
  external constraints is written once, reads back with the same NLL.
- SimultaneousGaussians, CombinedDist, CombinedDistSeveralColumns,
  CombinedDistImportErrors and CombinedDataImportErrors give the
  components their own observables.

Ported onto the typed HS3 document: the check works on the JSON
tree as before. On export, it runs after writeDocument(), since the
likelihoods and the data sets are only in the JSON tree from then
on; on import, it runs on the upgraded document.

Assisted-by: claude-code:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nction

A RooHistFunc or RooHistPdf is evaluated with its own observables,
which are mapped by position onto the variables of its histogram and
need not have the same names. The exporters took the names of the axes
from the variables of the histogram. With
RooJSONFactoryWSTool::config().renameCombinedObservables, where a
RooCustomizer replaces the observables of the components of a
RooSimultaneous, the "histfactory_dist" of a channel therefore kept the
original observable "x" as axis while the data had "obs_SR" (reported in
the review of this PR, with a reproducer). The same happened for "step"
and "histogram_dist", and for any RooHistFunc constructed with other
observables than the variables of its histogram.

The axes are now named after the observables onto which the variables
of the histogram are mapped (histogramVariablesNamedAfterObservables()
in JSONIOUtils). The HistFactory exporter keeps these variables for the
channel, so the observables are also left out of the parameters of the
channel. RooHistPdf gets getHistObsList(), like RooHistFunc.

Test: HistogramAxesNamedAfterObservables (a RooHistFunc with observables
of other names, a HistFactory channel and a RooHistPdf on the same
observable with renamed observables: axes of the distributions and of
the data, likelihood after the import, stable document).

Ported onto the typed HS3 document: the observables of the exported
channel (ExportedChannel::observables) are the renamed variables.

Assisted-by: claude-code:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… per bin

A one-dimensional weighted RooDataSet is written as binned data if its
entries are the bins in their order, the form in which combined data
sets of HistFactory models are often stored. The check stopped at the
first entry that was not in the next bin and only compared the number
of entries before it to the number of bins: entries in the bins 0, 1, 1
counted as binned data, and the export wrote one content per entry,
reading past the end of the bin contents. This happened on the second
export of the reproducer in the review of this PR, where a HistFactory
channel is paired with unbinned data. The data set is now binned data
only if it has exactly one entry per bin.

Test: WeightedDataSetAsBinnedData.

Assisted-by: claude-code:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lumns

RooAbsData::split() keeps a column of a data set that no channel of the
RooSimultaneous uses (e.g. an event number) in every sub-data set, so
all components of the combined data set have it as an axis. HS3
requires distinct axes, and such a data set could not be exported
unless a likelihood listed it, since only then were the variates of the
distributions checked instead of the axes of the data.

Such a column has no statistical meaning. On export, an axis that
several components of a combined data set share is declared as a
bookkeeping column in misc.ROOT_internal.bookkeeping_columns
({"<data set>": ["eventNumber"]}) if its variable is used by nothing
else in the workspace: it has no clients and is in no set of a
ModelConfig. Bookkeeping columns are exempt from the requirement of
distinct axes; all other shared axes are an error. The axes of combined
data sets are now checked whether or not a likelihood lists the data
set. On import, a declared column is accepted with a warning, and it is
not added to the observables of the ModelConfig, so the document is
stable. A declared column that is a variate of a distribution is no
bookkeeping column.

Test: CombinedDataColumnWithoutDistribution (with and without
likelihood: the declaration, import with warning, stable document, the
same document without declaration rejected; a column in the
observables of the ModelConfig and a declared column that is a variate
are errors).

Ported onto the typed HS3 document: misc.ROOT_internal.bookkeeping_columns
is a field of RooFitInternal, which the parser reads and the writer
writes. The check works on the JSON tree like the one of distinct
variates, and the importer of an analysis gets the bookkeeping
columns from the import of the document.

Assisted-by: claude-code:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HS3 root-project#147 (split out of root-project#119) defines the "category_conditional_dist":
one of several sub-distributions, selected by the value of categorical
parameters and conditional on it. RooFit has this as the "switch mode" of
RooSimultaneous: with an index category that is not an observable, it
evaluates to the selected component, and its expected number of events
is the one of that component.

Import: a category_conditional_dist becomes a RooSimultaneous over its
categorical parameters, which must be declared by categorical axes of a
domain like the columns of a combined_dist.

Export: RooFit doesn't distinguish the two uses of a RooSimultaneous by
the object, only by the normalization set. A combined distribution is
the top-level pdf of a model, while a conditional one (e.g. a choice
between alternative background shapes) is a building block of a model.
A RooSimultaneous that is used by another pdf is therefore written as
category_conditional_dist, with "categorical_parameter" instead of
"columns", and one that no other pdf uses as combined_dist. This is a
heuristic until RooFit has a more explicit way to mark the conditional
use. A category_conditional_dist that no other distribution uses is read
with a warning that it will be written back as a combined_dist. The
pairing checks of combined_dist in a likelihood use the type in the
document.

The value of a RooCategory in a snapshot is written to the parameter
point as the label, and the category to the default domain; the columns
of combined distributions are not parameters and are left out.

For the check of distinct variates (see "Distinct variates of likelihood
terms and combined components"), a category_conditional_dist has the
variates of its alternatives, which may share them. A RooSimultaneous
that no other pdf uses is written as combined_dist, so if its
alternatives share an observable the export fails; the import warning
for an unused category_conditional_dist says so.

Test: CategoryConditionalDist.

Ported onto the typed HS3 document: the value of a categorical parameter
is a label of the HS3::ParameterValue, the columns of combined
distributions are kept in the export state, and the pairing check and
the import warning look up the type of the generic distribution in the
document.

Assisted-by: claude-code:claude-fable-5-1
Assisted-by: claude-code:claude-opus-5-5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A RooMultiPdf (discrete profiling, e.g. in CMS Combine) couldn't be
exported ("no export keys available"). It is now written as a
category_conditional_dist: its category is the categorical parameter,
and the key of the pdf at list position i is the label of the state
with index i, since a RooMultiPdf selects by index. A pdf without a
state of its index is an error.

The penalty term that a RooMultiPdf adds to the likelihood (a correction
for the number of free parameters of the selected pdf) has no HS3
representation yet. It is dropped with a warning, and its constants are
not written. The category_conditional_dist is read back as a
RooSimultaneous, which evaluates to the same pdf.

Test: MultiPdfExport.

Ported onto the typed HS3 document: the category is declared through
recordCategory(), and the distribution is recorded as a generic object
of the exported document.

Assisted-by: claude-code:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cburgard
cburgard force-pushed the hs3-category-conditional branch from 46e5b61 to cbc32e6 Compare November 13, 2026 11:21

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants