Skip to content

Commit 69bea0c

Browse files
authored
Merge pull request #15 from stalbrec/add-scan-spec
Add support for pdf_scan and function_scan in fixture specs
2 parents 6ff8a1b + bf45fbd commit 69bea0c

7 files changed

Lines changed: 376 additions & 73 deletions

File tree

README.md

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,11 @@ There are several IDs with different meanings:
5959
- `test_id`: identifies one fixture, for example `rf101_basics`. It ties
6060
together the manifest entry and the files under `fixtures/rf101_basics/`.
6161
- check `id`: identifies one check inside `expected.json`, for example
62-
`static_integrity`, `structure_import`, or `twice_delta_nll_scan`.
62+
`static_integrity`, `structure_import`, `twice_delta_nll_scan`, `pdf_scan`, or
63+
`function_scan`.
6364
- check `kind`: tells the runner how to execute a check. The current kinds are
64-
`static_integrity`, `structure_import`, and `twice_delta_nll_scan`.
65+
`static_integrity`, `structure_import`, `twice_delta_nll_scan`, `pdf_scan`, and
66+
`function_scan`.
6567
- schema `$id`: JSON Schema identifier only; it is not a fixture or check ID.
6668

6769
Runner output uses `test_id::check_id`, for example:
@@ -126,15 +128,32 @@ from the original tutorial.
126128
"kind": "twice_delta_nll_scan",
127129
"target": { "pdf": "gauss", "data": "gaussData" },
128130
"reference_point": { "mean": 1.0, "sigma": 3.0, "x": 0.0 },
129-
"scan_parameter": "mean",
130-
"scan_points": [-1.0, 0.0, 1.0, 2.0, 3.0],
131+
"scan_parameters": ["mean"],
132+
"scan_points": [[-1.0], [0.0], [1.0], [2.0], [3.0]],
131133
"expected": [888.0456117195517, 224.26202740723056, 0.0, 213.06114567253644, 856.1426350606562],
132134
"tolerance": { "atol": 1e-7, "rtol": 1e-8 }
133135
}
134136
]
135137
}
136138
```
137139

140+
`scan_parameters` is a list and each entry of `scan_points` is a list of the same length, so a scan can vary several parameters at once.
141+
142+
A `pdf_scan` check has the same shape but needs no dataset, so it also works on an `hs3.json` with no `data` section at all:
143+
144+
```json
145+
{
146+
"id": "pdf_scan",
147+
"kind": "pdf_scan",
148+
"target": { "pdf": "gauss", "observables": ["x"] },
149+
"reference_point": { "mean": 0.0, "sigma": 1.0, "x": 0.0 },
150+
"scan_parameters": ["x"],
151+
"scan_points": [[-2.0], [-1.0], [0.0], [1.0], [2.0]],
152+
"expected": [0.05399096651318806, 0.24197072451914337, 0.3989422804014327, 0.24197072451914337, 0.05399096651318806],
153+
"tolerance": { "atol": 1e-12, "rtol": 1e-8 }
154+
}
155+
```
156+
138157
## Check Types
139158

140159
`static_integrity` parses `hs3.json` as JSON. This catches malformed files
@@ -156,6 +175,29 @@ means loading the file into a `RooWorkspace` using `RooJSONFactoryWSTool`.
156175
The suite prefers `2DeltaNLL` rather than raw NLL because raw NLL can include
157176
backend-dependent constants or offsets.
158177

178+
`pdf_scan` and `function_scan` are the quantitative checks for models that carry no data.
179+
Each:
180+
181+
1. Imports the HS3 model.
182+
2. Finds the target PDF or function.
183+
3. At each scan point, re-applies the `reference_point` and then overwrites the
184+
scanned names.
185+
4. Evaluates the target.
186+
5. Compares pointwise with the same rule as `twice_delta_nll_scan`.
187+
188+
They differ only in the target. `pdf_scan` takes
189+
`{ "pdf": ..., "observables": [...] }` and evaluates a probability density
190+
**normalised over the listed observables**. `function_scan` takes
191+
`{ "function": ... }` and evaluates a plain function value.
192+
193+
The `observables` list is required for `pdf_scan` for the same reason the suite
194+
prefers `2DeltaNLL` over raw NLL: an unnormalised pdf value carries an arbitrary
195+
backend-dependent constant. Declaring the observables in the fixture makes the normalisation domain
196+
part of the frozen contract rather than an implicit backend convention.
197+
198+
`function_scan` needs no normalisation set: a `RooAbsReal` has no normalisation to
199+
apply, so its raw value is already comparable across backends.
200+
159201
## Backend Imports
160202

161203
The word "import" in this suite usually means backend import of an HS3 model,

docs/reference-backend-contract.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,17 @@ adds both).
5959

6060
A non-zero exit code fails the `generate` job and nothing is committed.
6161

62+
**Which checks to emit:** driven by `metadata.json`. An `nll_scans` entry produces a
63+
`twice_delta_nll_scan` check; `pdf_scans` and `function_scans` entries produce `pdf_scan`
64+
and `function_scan` checks. A backend image is expected to support all three. The two
65+
data-less kinds matter in particular for fixtures whose `hs3.json` has no `data` section
66+
at all, where `twice_delta_nll_scan` cannot be built — without them such a fixture pins
67+
no numbers.
68+
69+
For `pdf_scan`, the value written to `expected` must be the density **normalised over the
70+
observables named in the entry**, not a raw expression value. Backends might disagree about what an unnormalised pdf value means, which is the same
71+
reason `twice_delta_nll_scan` freezes `2ΔNLL` rather than a raw NLL.
72+
6273
For the RooFit reference image, `generate-fixtures` is a thin wrapper that sources the
6374
ROOT environment and execs
6475
`python3 "$GITHUB_WORKSPACE/tools/build_manifest_and_expected.py" -e -f "$1"` — i.e. it

hs3suite/runner.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ def _run_fixture(root: Path, backend, fixture: dict[str, Any], expected: dict[st
9292
elif kind == "twice_delta_nll_scan":
9393
actual = backend.run_twice_delta_nll_scan(workspace, check, hs3_path)
9494
compare_vectors(actual, check["expected"], check["tolerance"])
95+
elif kind == "pdf_scan":
96+
actual = backend.run_pdf_scan(workspace, check, hs3_path)
97+
compare_vectors(actual, check["expected"], check["tolerance"])
98+
elif kind == "function_scan":
99+
actual = backend.run_function_scan(workspace, check, hs3_path)
100+
compare_vectors(actual, check["expected"], check["tolerance"])
95101
else:
96102
raise AssertionError(f"unsupported check kind {kind!r}")
97103
except Exception as exc:

0 commit comments

Comments
 (0)