From a4220140eb61a92d15a102ba9d2b8a135b5f3af0 Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Wed, 10 Jun 2026 19:20:14 -0400 Subject: [PATCH 01/18] =?UTF-8?q?HIST-1:=20hist.graphed=20=E2=80=94=20defe?= =?UTF-8?q?rred=20hist=20filling=20on=20graphed=20task=20graphs=20(freeze-?= =?UTF-8?q?HIST-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/hist/graphed mirrors src/hist/dask over graphed-histogram: Hist/NamedHist with QuickConstruct and named-axis fills recording into the graphed IR; compute() returns a real hist.Hist. Pinned: deferred == eager twins bit for bit over graphed-numpy, graphed-awkward (ragged fills flatten), and a real uproot TTree; weighted 2D NamedHist; multi-fill; ProcessExecutor plan == compute; the whole-dataset loader never runs. Full hist suite green alongside (200 passed). CI workflow 'graphed' runs the full suite + integration on the graphed-mvp branch. Co-Authored-By: Claude Fable 5 --- .github/workflows/graphed.yml | 48 +++++++++++ .graphed/attempts.md | 18 +++++ .graphed/state.json | 27 +++++++ src/hist/graphed/__init__.py | 15 ++++ src/hist/graphed/hist.py | 22 +++++ src/hist/graphed/namedhist.py | 18 +++++ tests/test_graphed.py | 147 ++++++++++++++++++++++++++++++++++ 7 files changed, 295 insertions(+) create mode 100644 .github/workflows/graphed.yml create mode 100644 .graphed/attempts.md create mode 100644 .graphed/state.json create mode 100644 src/hist/graphed/__init__.py create mode 100644 src/hist/graphed/hist.py create mode 100644 src/hist/graphed/namedhist.py create mode 100644 tests/test_graphed.py diff --git a/.github/workflows/graphed.yml b/.github/workflows/graphed.yml new file mode 100644 index 00000000..574582d7 --- /dev/null +++ b/.github/workflows/graphed.yml @@ -0,0 +1,48 @@ +name: graphed + +# The graphed-mvp integration (HIST-1): runs the FULL hist test suite together with the +# hist.graphed tests, so regressions from the graphed additions are caught — not just the graphed +# tests in isolation. Runs only on the graphed-mvp branch (hist's own CI runs on main / PRs). +on: + push: + branches: [graphed-mvp] + workflow_dispatch: + +concurrency: + group: graphed-${{ github.ref }} + cancel-in-progress: true + +env: + CORE: "graphed-core @ git+https://github.com/graphed-org/graphed-core-mvp@main" + DEBUG: "graphed-debug @ git+https://github.com/graphed-org/graphed-debug-mvp@main" + FRONTEND: "graphed @ git+https://github.com/graphed-org/graphed-mvp@main" + NUMPY: "graphed-numpy @ git+https://github.com/graphed-org/graphed-numpy-mvp@main" + AWKWARD: "graphed-awkward @ git+https://github.com/graphed-org/graphed-awkward-mvp@main" + EXEC: "graphed-exec-local @ git+https://github.com/graphed-org/graphed-exec-local-mvp@main" + HISTO: "graphed-histogram @ git+https://github.com/graphed-org/graphed-histogram@main" + UPROOT: "uproot @ git+https://github.com/graphed-org/uproot5-graphed-mvp@graphed-mvp" + +jobs: + tests: + name: hist + graphed tests ${{ matrix.python }} + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + python: ["3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - uses: dtolnay/rust-toolchain@stable + - name: Install graphed (mvp) siblings + hist (test extras) + run: | + python -m pip install --upgrade pip + python -m pip install "${{ env.CORE }}" "${{ env.DEBUG }}" "${{ env.FRONTEND }}" \ + "${{ env.NUMPY }}" "${{ env.AWKWARD }}" "${{ env.EXEC }}" "${{ env.HISTO }}" \ + "${{ env.UPROOT }}" + python -m pip install -e ".[test,plot]" scikit-hep-testdata + - name: Full hist suite + the graphed integration tests + run: python -m pytest tests -q diff --git a/.graphed/attempts.md b/.graphed/attempts.md new file mode 100644 index 00000000..a621c5f8 --- /dev/null +++ b/.graphed/attempts.md @@ -0,0 +1,18 @@ +# attempts — hist-graphed-mvp (branch graphed-mvp) + +## HIST-1 — hist.graphed: deferred hist filling on graphed task graphs — 2026-06-10 (freeze-HIST-1) + +P0.1 of the ADL-benchmarks port (user-confirmed plan). src/hist/graphed mirrors src/hist/dask: +Hist/NamedHist as the MRO sandwich (hist's QuickConstruct + named-axis handling over +graphed_histogram.boost.Histogram's deferred fill), with `_in_memory_type` so compute() returns +a REAL hist.Hist (named indexing works on results). + +- tests/test_graphed.py (5 tests, test-first against the integration surface): QuickConstruct + deferred == eager twins BIT FOR BIT (counts incl. flow) over graphed-numpy AND graphed-awkward + sources (ragged fills flatten completely) and a real uproot TTree (np.hypot of branches); + weighted 2D NamedHist (values + variances); multi-fill accumulation; ProcessExecutor plan == + compute; the efficiency witness (the source's whole-dataset loader never runs). +- Findings folded back into graphed-histogram iteration 1 (M23): the `_in_memory_type` wrapping + hook, and hist's name/label living in the axis `__dict__` (boost's metadata mechanism) — now + captured/restored by the canonical spec. +- Full hist suite green alongside: 200 passed, 8 skipped (mplhep + pytest-mpl are test deps). diff --git a/.graphed/state.json b/.graphed/state.json new file mode 100644 index 00000000..8d234e63 --- /dev/null +++ b/.graphed/state.json @@ -0,0 +1,27 @@ +{ + "milestones": { + "HIST-1": { + "dispute_count": 0, + "escalated": false, + "freeze_tag": "freeze-HIST-1", + "gates": { + "benchmark": null, + "coverage": true, + "determinism": true, + "frozen_tests": true, + "integrity_scan": true, + "lint": true, + "types": true + }, + "incident": null, + "l0_count": 0, + "metrics_history": [ + { + "benchmark_ok": null, + "pass_count": 205 + } + ] + } + }, + "updated_at": "2026-06-10T00:00:00Z" +} diff --git a/src/hist/graphed/__init__.py b/src/hist/graphed/__init__.py new file mode 100644 index 00000000..2de9ed3f --- /dev/null +++ b/src/hist/graphed/__init__.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import importlib.util + +if not importlib.util.find_spec("graphed_histogram"): + msg = """for hist.graphed, install the 'graphed_histogram' package with: + pip install graphed_histogram""" + raise ModuleNotFoundError(msg) + +from .hist import Hist +from .namedhist import NamedHist + +new = Hist.new + +__all__ = ["Hist", "NamedHist", "new"] diff --git a/src/hist/graphed/hist.py b/src/hist/graphed/hist.py new file mode 100644 index 00000000..15b72183 --- /dev/null +++ b/src/hist/graphed/hist.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from typing import Generic, TypeVar + +import boost_histogram as bh +import graphed_histogram.boost as ghb + +import hist + +from ..hist import Hist as HistInMemory + +S = TypeVar("S", bound=bh.storage.Storage) + + +class Hist(HistInMemory[S], ghb.Histogram, Generic[S], family=hist): # type: ignore[misc] + """A `hist.Hist` whose fills are DEFERRED graphed computations (the `hist.dask` analogue): + QuickConstruct (`Hist.new.Reg(...).Double()`) and named-axis fills record into the graphed + IR; `.compute()` returns a concrete in-memory `hist.Hist`.""" + + @property + def _in_memory_type(self) -> type[HistInMemory[S]]: + return HistInMemory diff --git a/src/hist/graphed/namedhist.py b/src/hist/graphed/namedhist.py new file mode 100644 index 00000000..31e5cc38 --- /dev/null +++ b/src/hist/graphed/namedhist.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from typing import Generic, TypeVar + +import boost_histogram as bh +import graphed_histogram.boost as ghb + +import hist + +from ..namedhist import NamedHist as NamedHistInMemory + +S = TypeVar("S", bound=bh.storage.Storage) + + +class NamedHist(NamedHistInMemory[S], ghb.Histogram, Generic[S], family=hist): # type: ignore[misc] + @property + def _in_memory_type(self) -> type[NamedHistInMemory[S]]: + return NamedHistInMemory diff --git a/tests/test_graphed.py b/tests/test_graphed.py new file mode 100644 index 00000000..5e03c35b --- /dev/null +++ b/tests/test_graphed.py @@ -0,0 +1,147 @@ +# Tests for hist.graphed — deferred hist filling on graphed task graphs (HIST-1; P0.1 of the +# ADL-benchmarks port). QuickConstruct-built deferred histograms must equal their eager hist.Hist +# twins BIT FOR BIT over graphed-numpy AND graphed-awkward sources and over a real uproot TTree, +# with named-axis fills, weights, NamedHist, and the partition-wise efficiency witness (the +# source's whole-dataset loader never runs). +from __future__ import annotations + +import numpy as np +import pytest + +import hist + +gh = pytest.importorskip("graphed_histogram") +graphed = pytest.importorskip("graphed") +hist_graphed = pytest.importorskip("hist.graphed") + +from dataclasses import dataclass, field # noqa: E402 + +from graphed import Session # noqa: E402 +from graphed_core import Partition # noqa: E402 + +RNG = np.random.default_rng(7) +DATA = RNG.normal(5.0, 2.0, 800) +WEIGHTS = RNG.uniform(0.5, 1.5, 800) + + +@dataclass +class ChunkedNumpySource: + data: np.ndarray + whole_calls: list = field(default_factory=list) + + def __call__(self) -> np.ndarray: + self.whole_calls.append(1) + return self.data + + def partitions(self, steps_per_file: int = 1) -> tuple[Partition, ...]: + return tuple(Partition.blind("toy://x", "", s, steps_per_file) for s in range(steps_per_file)) + + def read_partition(self, partition, columns, resources): # type: ignore[no-untyped-def] + part = partition.resolve(len(self.data)) + return self.data[part.entry_start : part.entry_stop] + + +def _numpy_source(): + from graphed_numpy import NumpyBackend + from graphed_numpy.forms import NumpyForm + + s = Session(NumpyBackend()) + src = ChunkedNumpySource(DATA) + return s.source("x", form=NumpyForm(DATA.dtype, shape=(None,)), data=src), src + + +def test_quickconstruct_matches_the_eager_twin_bit_for_bit(): + pytest.importorskip("graphed_numpy") + x, src = _numpy_source() + h = hist_graphed.Hist.new.Reg(40, 0, 10, name="met", label="$E_T$").Int64().fill(met=x) + out = h.compute(steps_per_file=4) + assert isinstance(out, hist.Hist) and not isinstance(out, hist_graphed.Hist) + eager = hist.Hist.new.Reg(40, 0, 10, name="met", label="$E_T$").Int64() + eager.fill(met=DATA) + assert np.array_equal(out.values(flow=True), eager.values(flow=True)) + assert out.axes[0].name == "met" and out.axes[0].label == "$E_T$" + assert out[{"met": sum}] == eager[{"met": sum}] + assert src.whole_calls == [] # partition-wise: the whole-dataset loader never ran + + +def test_weighted_2d_and_namedhist(): + pytest.importorskip("graphed_numpy") + x, _ = _numpy_source() + h = ( + hist_graphed.NamedHist.new.Reg(10, 0, 10, name="a").Reg(8, 0, 5, name="b").Weight() + .fill(a=x, b=x * 0.5, weight=np.sqrt(abs(x))) + ) + out = h.compute(steps_per_file=3) + eager = hist.NamedHist.new.Reg(10, 0, 10, name="a").Reg(8, 0, 5, name="b").Weight() + eager.fill(a=DATA, b=DATA * 0.5, weight=np.sqrt(np.abs(DATA))) + assert np.allclose(out.values(flow=True), eager.values(flow=True)) + assert np.allclose(out.variances(flow=True), eager.variances(flow=True)) + + +def test_awkward_ragged_fills_flatten(): + ak = pytest.importorskip("awkward") + pytest.importorskip("graphed_awkward") + from graphed_awkward import AwkwardBackend, AwkwardForm + + events = ak.Array({"Jet_pt": [[50.0, 30.0], [], [70.0, 20.0, 10.0]] * 50}) + + @dataclass + class ChunkedAkSource: + data: object + whole_calls: list = field(default_factory=list) + + def __call__(self): + self.whole_calls.append(1) + return self.data + + def partitions(self, steps_per_file: int = 1): + return tuple(Partition.blind("toy://e", "", s, steps_per_file) for s in range(steps_per_file)) + + def read_partition(self, partition, columns, resources): + part = partition.resolve(len(self.data)) + return self.data[part.entry_start : part.entry_stop] + + s = Session(AwkwardBackend()) + src = ChunkedAkSource(events) + tt = ak.Array(events.layout.to_typetracer(forget_length=True)) + g = s.source("events", form=AwkwardForm(tt), data=src) + + h = hist_graphed.Hist.new.Reg(20, 0, 100, name="pt").Int64().fill(pt=g.Jet_pt) + out = h.compute(steps_per_file=5) + eager = hist.Hist.new.Reg(20, 0, 100, name="pt").Int64() + eager.fill(pt=ak.flatten(events.Jet_pt, axis=None)) # ragged fills flatten completely + assert np.array_equal(out.values(flow=True), eager.values(flow=True)) + assert src.whole_calls == [] + + +def test_uproot_ttree_fill_end_to_end(): + uproot = pytest.importorskip("uproot") + pytest.importorskip("graphed_awkward") + skhep_testdata = pytest.importorskip("skhep_testdata") + + where = skhep_testdata.data_path("uproot-Zmumu.root") + ":events" + g = uproot.graphed(where, library="ak", filter_name=["px1", "py1"]) + h = hist_graphed.Hist.new.Reg(50, 0, 100, name="pt1").Double().fill( + pt1=np.hypot(g.px1, g.py1) + ) + out = h.compute(steps_per_file=3) + raw = uproot.open(where).arrays(["px1", "py1"]) + eager = hist.Hist.new.Reg(50, 0, 100, name="pt1").Double() + eager.fill(pt1=np.hypot(np.asarray(raw.px1), np.asarray(raw.py1))) + assert np.array_equal(out.values(flow=True), eager.values(flow=True)) + + +def test_multiple_fills_and_process_executor(): + pytest.importorskip("graphed_numpy") + pexec = pytest.importorskip("graphed_exec_local") + + x, _ = _numpy_source() + h = hist_graphed.Hist.new.Reg(16, 0, 10, name="v").Int64() + h.fill(v=x).fill(v=abs(x) * 0.5) + direct = h.compute(steps_per_file=3) + later = pexec.ProcessExecutor(max_workers=2).run(h.plan(steps_per_file=3)).value + eager = hist.Hist.new.Reg(16, 0, 10, name="v").Int64() + eager.fill(v=DATA) + eager.fill(v=np.abs(DATA) * 0.5) + assert np.array_equal(direct.values(flow=True), eager.values(flow=True)) + assert np.array_equal(np.asarray(later.values(flow=True)), eager.values(flow=True)) From 329f859a5c53bce550da5f28035bcf4450b332cf Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Wed, 10 Jun 2026 21:00:28 -0400 Subject: [PATCH 02/18] =?UTF-8?q?ci:=20hist's=20test=20deps=20are=20a=20PE?= =?UTF-8?q?P=20735=20dependency=20group=20=E2=80=94=20install=20with=20--g?= =?UTF-8?q?roup=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .github/workflows/graphed.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/graphed.yml b/.github/workflows/graphed.yml index 574582d7..db374e1b 100644 --- a/.github/workflows/graphed.yml +++ b/.github/workflows/graphed.yml @@ -43,6 +43,8 @@ jobs: python -m pip install "${{ env.CORE }}" "${{ env.DEBUG }}" "${{ env.FRONTEND }}" \ "${{ env.NUMPY }}" "${{ env.AWKWARD }}" "${{ env.EXEC }}" "${{ env.HISTO }}" \ "${{ env.UPROOT }}" - python -m pip install -e ".[test,plot]" scikit-hep-testdata + # hist's test deps are a PEP 735 dependency GROUP (not an extra) + python -m pip install -e . --group test + python -m pip install scikit-hep-testdata - name: Full hist suite + the graphed integration tests run: python -m pytest tests -q From 954e68ffebec219e766ac4a0dc707280e0be930e Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Thu, 11 Jun 2026 09:19:42 -0400 Subject: [PATCH 03/18] =?UTF-8?q?freeze-HIST-2:=20respin=20to=20graphed's?= =?UTF-8?q?=20evaluation=20idiom=20=E2=80=94=20no=20compute()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit USER-DIRECTED (graphed-histogram freeze-M23-1): plan() + an R7 executor aggregates; hist.Hist(value)/hist.NamedHist(value) wrap results back in-memory (names/labels survive via the spec's axis-__dict__ handling); the unused _in_memory_type property is gone. Same pins; full hist suite green. Co-Authored-By: Claude Fable 5 --- .graphed/attempts.md | 8 ++++++++ .graphed/state.json | 6 +++--- src/hist/graphed/hist.py | 12 +++++------- src/hist/graphed/namedhist.py | 4 +--- tests/test_graphed.py | 15 +++++++++------ 5 files changed, 26 insertions(+), 19 deletions(-) diff --git a/.graphed/attempts.md b/.graphed/attempts.md index a621c5f8..6e5a508d 100644 --- a/.graphed/attempts.md +++ b/.graphed/attempts.md @@ -16,3 +16,11 @@ a REAL hist.Hist (named indexing works on results). hook, and hist's name/label living in the axis `__dict__` (boost's metadata mechanism) — now captured/restored by the canonical spec. - Full hist suite green alongside: 200 passed, 8 skipped (mplhep + pytest-mpl are test deps). + +## freeze-HIST-2 — USER-DIRECTED respin: no compute() (graphed evaluation idiom) — 2026-06-11 + +- graphed-histogram removed compute() (freeze-M23-1): evaluation is plan() + an R7 executor, or + the reference session.materialize. hist.graphed classes drop the now-unused _in_memory_type + property (pure MRO sandwiches); results wrap back in-memory via hist.Hist(value) / + hist.NamedHist(value) — names/labels survive (the spec carries the axis __dict__). +- tests/test_graphed.py respun to the executor idiom; same pins. Full hist suite green. diff --git a/.graphed/state.json b/.graphed/state.json index 8d234e63..0229ce20 100644 --- a/.graphed/state.json +++ b/.graphed/state.json @@ -3,7 +3,7 @@ "HIST-1": { "dispute_count": 0, "escalated": false, - "freeze_tag": "freeze-HIST-1", + "freeze_tag": "freeze-HIST-2", "gates": { "benchmark": null, "coverage": true, @@ -23,5 +23,5 @@ ] } }, - "updated_at": "2026-06-10T00:00:00Z" -} + "updated_at": "2026-06-11T13:19:42Z" +} \ No newline at end of file diff --git a/src/hist/graphed/hist.py b/src/hist/graphed/hist.py index 15b72183..ceefa3a4 100644 --- a/src/hist/graphed/hist.py +++ b/src/hist/graphed/hist.py @@ -13,10 +13,8 @@ class Hist(HistInMemory[S], ghb.Histogram, Generic[S], family=hist): # type: ignore[misc] - """A `hist.Hist` whose fills are DEFERRED graphed computations (the `hist.dask` analogue): - QuickConstruct (`Hist.new.Reg(...).Double()`) and named-axis fills record into the graphed - IR; `.compute()` returns a concrete in-memory `hist.Hist`.""" - - @property - def _in_memory_type(self) -> type[HistInMemory[S]]: - return HistInMemory + """A `hist.Hist` whose fills are DEFERRED graphed computations: QuickConstruct + (`Hist.new.Reg(...).Double()`) and named-axis fills record into the graphed IR. Evaluation + is graphed's own idiom — `plan()` + an R7 executor (whose result wraps back into an + in-memory `hist.Hist` via `hist.Hist(value)`), or the reference `session.materialize` on a + fill node.""" diff --git a/src/hist/graphed/namedhist.py b/src/hist/graphed/namedhist.py index 31e5cc38..0d5aa5b7 100644 --- a/src/hist/graphed/namedhist.py +++ b/src/hist/graphed/namedhist.py @@ -13,6 +13,4 @@ class NamedHist(NamedHistInMemory[S], ghb.Histogram, Generic[S], family=hist): # type: ignore[misc] - @property - def _in_memory_type(self) -> type[NamedHistInMemory[S]]: - return NamedHistInMemory + pass diff --git a/tests/test_graphed.py b/tests/test_graphed.py index 5e03c35b..3c50cd01 100644 --- a/tests/test_graphed.py +++ b/tests/test_graphed.py @@ -2,7 +2,8 @@ # ADL-benchmarks port). QuickConstruct-built deferred histograms must equal their eager hist.Hist # twins BIT FOR BIT over graphed-numpy AND graphed-awkward sources and over a real uproot TTree, # with named-axis fills, weights, NamedHist, and the partition-wise efficiency witness (the -# source's whole-dataset loader never runs). +# source's whole-dataset loader never runs). Evaluation is graphed's idiom [freeze-HIST-2, +# user-directed]: plan() + an R7 executor; hist.Hist(value) wraps results back in-memory. from __future__ import annotations import numpy as np @@ -17,6 +18,7 @@ from dataclasses import dataclass, field # noqa: E402 from graphed import Session # noqa: E402 +from graphed.write import SequentialRunner # noqa: E402 from graphed_core import Partition # noqa: E402 RNG = np.random.default_rng(7) @@ -54,7 +56,8 @@ def test_quickconstruct_matches_the_eager_twin_bit_for_bit(): pytest.importorskip("graphed_numpy") x, src = _numpy_source() h = hist_graphed.Hist.new.Reg(40, 0, 10, name="met", label="$E_T$").Int64().fill(met=x) - out = h.compute(steps_per_file=4) + # graphed idiom: the executor aggregates; hist.Hist(value) wraps back into the in-memory type + out = hist.Hist(SequentialRunner().run(h.plan(steps_per_file=4)).value) assert isinstance(out, hist.Hist) and not isinstance(out, hist_graphed.Hist) eager = hist.Hist.new.Reg(40, 0, 10, name="met", label="$E_T$").Int64() eager.fill(met=DATA) @@ -71,7 +74,7 @@ def test_weighted_2d_and_namedhist(): hist_graphed.NamedHist.new.Reg(10, 0, 10, name="a").Reg(8, 0, 5, name="b").Weight() .fill(a=x, b=x * 0.5, weight=np.sqrt(abs(x))) ) - out = h.compute(steps_per_file=3) + out = hist.NamedHist(SequentialRunner().run(h.plan(steps_per_file=3)).value) eager = hist.NamedHist.new.Reg(10, 0, 10, name="a").Reg(8, 0, 5, name="b").Weight() eager.fill(a=DATA, b=DATA * 0.5, weight=np.sqrt(np.abs(DATA))) assert np.allclose(out.values(flow=True), eager.values(flow=True)) @@ -107,7 +110,7 @@ def read_partition(self, partition, columns, resources): g = s.source("events", form=AwkwardForm(tt), data=src) h = hist_graphed.Hist.new.Reg(20, 0, 100, name="pt").Int64().fill(pt=g.Jet_pt) - out = h.compute(steps_per_file=5) + out = hist.Hist(SequentialRunner().run(h.plan(steps_per_file=5)).value) eager = hist.Hist.new.Reg(20, 0, 100, name="pt").Int64() eager.fill(pt=ak.flatten(events.Jet_pt, axis=None)) # ragged fills flatten completely assert np.array_equal(out.values(flow=True), eager.values(flow=True)) @@ -124,7 +127,7 @@ def test_uproot_ttree_fill_end_to_end(): h = hist_graphed.Hist.new.Reg(50, 0, 100, name="pt1").Double().fill( pt1=np.hypot(g.px1, g.py1) ) - out = h.compute(steps_per_file=3) + out = hist.Hist(SequentialRunner().run(h.plan(steps_per_file=3)).value) raw = uproot.open(where).arrays(["px1", "py1"]) eager = hist.Hist.new.Reg(50, 0, 100, name="pt1").Double() eager.fill(pt1=np.hypot(np.asarray(raw.px1), np.asarray(raw.py1))) @@ -138,7 +141,7 @@ def test_multiple_fills_and_process_executor(): x, _ = _numpy_source() h = hist_graphed.Hist.new.Reg(16, 0, 10, name="v").Int64() h.fill(v=x).fill(v=abs(x) * 0.5) - direct = h.compute(steps_per_file=3) + direct = SequentialRunner().run(h.plan(steps_per_file=3)).value later = pexec.ProcessExecutor(max_workers=2).run(h.plan(steps_per_file=3)).value eager = hist.Hist.new.Reg(16, 0, 10, name="v").Int64() eager.fill(v=DATA) From aace67f97e4f25e1e0a2d0da54e9e58f2225269e Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Thu, 11 Jun 2026 10:17:23 -0400 Subject: [PATCH 04/18] ci: graphed-histogram repo renamed to graphed-histogram-mvp Co-Authored-By: Claude Fable 5 --- .github/workflows/graphed.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/graphed.yml b/.github/workflows/graphed.yml index db374e1b..38925718 100644 --- a/.github/workflows/graphed.yml +++ b/.github/workflows/graphed.yml @@ -19,7 +19,7 @@ env: NUMPY: "graphed-numpy @ git+https://github.com/graphed-org/graphed-numpy-mvp@main" AWKWARD: "graphed-awkward @ git+https://github.com/graphed-org/graphed-awkward-mvp@main" EXEC: "graphed-exec-local @ git+https://github.com/graphed-org/graphed-exec-local-mvp@main" - HISTO: "graphed-histogram @ git+https://github.com/graphed-org/graphed-histogram@main" + HISTO: "graphed-histogram @ git+https://github.com/graphed-org/graphed-histogram-mvp@main" UPROOT: "uproot @ git+https://github.com/graphed-org/uproot5-graphed-mvp@graphed-mvp" jobs: From 83c3b1e5a060f447bfa786d5e73b431b0bac0946 Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Sat, 13 Jun 2026 08:57:27 -0400 Subject: [PATCH 05/18] M32: import SequentialRunner from graphed_core.execution (was graphed.write) The reference runner moved to the execution contract; graphed.write no longer exposes it. Co-Authored-By: Claude Fable 5 --- tests/test_graphed.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_graphed.py b/tests/test_graphed.py index 3c50cd01..5de11422 100644 --- a/tests/test_graphed.py +++ b/tests/test_graphed.py @@ -18,7 +18,7 @@ from dataclasses import dataclass, field # noqa: E402 from graphed import Session # noqa: E402 -from graphed.write import SequentialRunner # noqa: E402 +from graphed_core.execution import SequentialRunner # noqa: E402 from graphed_core import Partition # noqa: E402 RNG = np.random.default_rng(7) From a96160f1fb03452e07980e1b5ceafe23101c0c93 Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Sat, 18 Jul 2026 09:01:34 -0500 Subject: [PATCH 06/18] refactor(deps): consume the consolidated graphed package The 8 graphed-*-mvp prototype packages are now one pip-installable distribution `graphed`. Rewrite the merged import roots in the graphed integration test (graphed_core/graphed_numpy/graphed_awkward -> graphed.core/.numpy/.awkward); hist.graphed's own graphed_histogram / graphed_exec_local deps stay separate. CI graphed.yml installs the consolidated `graphed[awkward,numpy]` (one git URL in place of the CORE/DEBUG/FRONTEND/NUMPY/AWKWARD siblings) plus graphed-exec-local, graphed-histogram, and the uproot fork. Assisted-by: ClaudeCode:claude-opus-4.8 --- .github/workflows/graphed.yml | 11 ++++------- tests/test_graphed.py | 20 ++++++++++---------- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/.github/workflows/graphed.yml b/.github/workflows/graphed.yml index 38925718..02d4c610 100644 --- a/.github/workflows/graphed.yml +++ b/.github/workflows/graphed.yml @@ -13,11 +13,9 @@ concurrency: cancel-in-progress: true env: - CORE: "graphed-core @ git+https://github.com/graphed-org/graphed-core-mvp@main" - DEBUG: "graphed-debug @ git+https://github.com/graphed-org/graphed-debug-mvp@main" - FRONTEND: "graphed @ git+https://github.com/graphed-org/graphed-mvp@main" - NUMPY: "graphed-numpy @ git+https://github.com/graphed-org/graphed-numpy-mvp@main" - AWKWARD: "graphed-awkward @ git+https://github.com/graphed-org/graphed-awkward-mvp@main" + # graphed core/frontend/numpy/awkward/debug are now ONE consolidated package (graphed.core, ...); + # graphed-histogram + graphed-exec-local + the uproot fork stay separate packages. + GRAPHED: "graphed[awkward,numpy] @ git+https://github.com/graphed-org/graphed@main" EXEC: "graphed-exec-local @ git+https://github.com/graphed-org/graphed-exec-local-mvp@main" HISTO: "graphed-histogram @ git+https://github.com/graphed-org/graphed-histogram-mvp@main" UPROOT: "uproot @ git+https://github.com/graphed-org/uproot5-graphed-mvp@graphed-mvp" @@ -40,8 +38,7 @@ jobs: - name: Install graphed (mvp) siblings + hist (test extras) run: | python -m pip install --upgrade pip - python -m pip install "${{ env.CORE }}" "${{ env.DEBUG }}" "${{ env.FRONTEND }}" \ - "${{ env.NUMPY }}" "${{ env.AWKWARD }}" "${{ env.EXEC }}" "${{ env.HISTO }}" \ + python -m pip install "${{ env.GRAPHED }}" "${{ env.EXEC }}" "${{ env.HISTO }}" \ "${{ env.UPROOT }}" # hist's test deps are a PEP 735 dependency GROUP (not an extra) python -m pip install -e . --group test diff --git a/tests/test_graphed.py b/tests/test_graphed.py index 5de11422..3414d4eb 100644 --- a/tests/test_graphed.py +++ b/tests/test_graphed.py @@ -18,8 +18,8 @@ from dataclasses import dataclass, field # noqa: E402 from graphed import Session # noqa: E402 -from graphed_core.execution import SequentialRunner # noqa: E402 -from graphed_core import Partition # noqa: E402 +from graphed.core.execution import SequentialRunner # noqa: E402 +from graphed.core import Partition # noqa: E402 RNG = np.random.default_rng(7) DATA = RNG.normal(5.0, 2.0, 800) @@ -44,8 +44,8 @@ def read_partition(self, partition, columns, resources): # type: ignore[no-unty def _numpy_source(): - from graphed_numpy import NumpyBackend - from graphed_numpy.forms import NumpyForm + from graphed.numpy import NumpyBackend + from graphed.numpy.forms import NumpyForm s = Session(NumpyBackend()) src = ChunkedNumpySource(DATA) @@ -53,7 +53,7 @@ def _numpy_source(): def test_quickconstruct_matches_the_eager_twin_bit_for_bit(): - pytest.importorskip("graphed_numpy") + pytest.importorskip("graphed.numpy") x, src = _numpy_source() h = hist_graphed.Hist.new.Reg(40, 0, 10, name="met", label="$E_T$").Int64().fill(met=x) # graphed idiom: the executor aggregates; hist.Hist(value) wraps back into the in-memory type @@ -68,7 +68,7 @@ def test_quickconstruct_matches_the_eager_twin_bit_for_bit(): def test_weighted_2d_and_namedhist(): - pytest.importorskip("graphed_numpy") + pytest.importorskip("graphed.numpy") x, _ = _numpy_source() h = ( hist_graphed.NamedHist.new.Reg(10, 0, 10, name="a").Reg(8, 0, 5, name="b").Weight() @@ -83,8 +83,8 @@ def test_weighted_2d_and_namedhist(): def test_awkward_ragged_fills_flatten(): ak = pytest.importorskip("awkward") - pytest.importorskip("graphed_awkward") - from graphed_awkward import AwkwardBackend, AwkwardForm + pytest.importorskip("graphed.awkward") + from graphed.awkward import AwkwardBackend, AwkwardForm events = ak.Array({"Jet_pt": [[50.0, 30.0], [], [70.0, 20.0, 10.0]] * 50}) @@ -119,7 +119,7 @@ def read_partition(self, partition, columns, resources): def test_uproot_ttree_fill_end_to_end(): uproot = pytest.importorskip("uproot") - pytest.importorskip("graphed_awkward") + pytest.importorskip("graphed.awkward") skhep_testdata = pytest.importorskip("skhep_testdata") where = skhep_testdata.data_path("uproot-Zmumu.root") + ":events" @@ -135,7 +135,7 @@ def test_uproot_ttree_fill_end_to_end(): def test_multiple_fills_and_process_executor(): - pytest.importorskip("graphed_numpy") + pytest.importorskip("graphed.numpy") pexec = pytest.importorskip("graphed_exec_local") x, _ = _numpy_source() From d4d83b75f5006a5ea1322f9ee3e2e259b4712c14 Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Sat, 18 Jul 2026 10:58:25 -0500 Subject: [PATCH 07/18] test: migrate ProcessExecutor -> ProcessPoolExecutor (exec-local deprecation) graphed-exec-local deprecated ProcessExecutor on 2026-06-17; the warning fails under this suite's `filterwarnings = error`. Use the non-deprecated parent (same behaviour) in the process-executor integration test. Assisted-by: ClaudeCode:claude-opus-4.8 --- tests/test_graphed.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_graphed.py b/tests/test_graphed.py index 3414d4eb..c34186dd 100644 --- a/tests/test_graphed.py +++ b/tests/test_graphed.py @@ -142,7 +142,7 @@ def test_multiple_fills_and_process_executor(): h = hist_graphed.Hist.new.Reg(16, 0, 10, name="v").Int64() h.fill(v=x).fill(v=abs(x) * 0.5) direct = SequentialRunner().run(h.plan(steps_per_file=3)).value - later = pexec.ProcessExecutor(max_workers=2).run(h.plan(steps_per_file=3)).value + later = pexec.ProcessPoolExecutor(max_workers=2).run(h.plan(steps_per_file=3)).value eager = hist.Hist.new.Reg(16, 0, 10, name="v").Int64() eager.fill(v=DATA) eager.fill(v=np.abs(DATA) * 0.5) From a9de0a8e0c39d75fc227e4ab38b004703768c29b Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Sat, 18 Jul 2026 13:56:35 -0500 Subject: [PATCH 08/18] chore: track graphed-exec-local -> graphed-executors + histogram repo rename CI now installs graphed-executors @ .../graphed-executors and graphed-histogram @ .../graphed-histogram; the executor importorskip targets graphed_executors.local. Assisted-by: ClaudeCode:claude-opus-4.8 --- .github/workflows/graphed.yml | 6 +++--- tests/test_graphed.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/graphed.yml b/.github/workflows/graphed.yml index 02d4c610..0e336453 100644 --- a/.github/workflows/graphed.yml +++ b/.github/workflows/graphed.yml @@ -14,10 +14,10 @@ concurrency: env: # graphed core/frontend/numpy/awkward/debug are now ONE consolidated package (graphed.core, ...); - # graphed-histogram + graphed-exec-local + the uproot fork stay separate packages. + # graphed-histogram + graphed-executors + the uproot fork stay separate packages. GRAPHED: "graphed[awkward,numpy] @ git+https://github.com/graphed-org/graphed@main" - EXEC: "graphed-exec-local @ git+https://github.com/graphed-org/graphed-exec-local-mvp@main" - HISTO: "graphed-histogram @ git+https://github.com/graphed-org/graphed-histogram-mvp@main" + EXEC: "graphed-executors @ git+https://github.com/graphed-org/graphed-executors@main" + HISTO: "graphed-histogram @ git+https://github.com/graphed-org/graphed-histogram@main" UPROOT: "uproot @ git+https://github.com/graphed-org/uproot5-graphed-mvp@graphed-mvp" jobs: diff --git a/tests/test_graphed.py b/tests/test_graphed.py index c34186dd..4ae65c7e 100644 --- a/tests/test_graphed.py +++ b/tests/test_graphed.py @@ -136,7 +136,7 @@ def test_uproot_ttree_fill_end_to_end(): def test_multiple_fills_and_process_executor(): pytest.importorskip("graphed.numpy") - pexec = pytest.importorskip("graphed_exec_local") + pexec = pytest.importorskip("graphed_executors.local") x, _ = _numpy_source() h = hist_graphed.Hist.new.Reg(16, 0, 10, name="v").Int64() From 282a99e123c9dd2200a7bd41d22bf07b32cbfe27 Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Sat, 18 Jul 2026 16:02:36 -0500 Subject: [PATCH 09/18] ci: install graphed-executors + graphed-histogram from PyPI (now released) Both are published on PyPI, so switch their CI env vars from git+@main to the released dists. graphed stays git+@main (active development); the uproot/hist forks stay on their branches. Assisted-by: ClaudeCode:claude-opus-4.8 --- .github/workflows/graphed.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/graphed.yml b/.github/workflows/graphed.yml index 0e336453..03a090ca 100644 --- a/.github/workflows/graphed.yml +++ b/.github/workflows/graphed.yml @@ -16,8 +16,8 @@ env: # graphed core/frontend/numpy/awkward/debug are now ONE consolidated package (graphed.core, ...); # graphed-histogram + graphed-executors + the uproot fork stay separate packages. GRAPHED: "graphed[awkward,numpy] @ git+https://github.com/graphed-org/graphed@main" - EXEC: "graphed-executors @ git+https://github.com/graphed-org/graphed-executors@main" - HISTO: "graphed-histogram @ git+https://github.com/graphed-org/graphed-histogram@main" + EXEC: "graphed-executors" + HISTO: "graphed-histogram" UPROOT: "uproot @ git+https://github.com/graphed-org/uproot5-graphed-mvp@graphed-mvp" jobs: From 3d27322194722184da8add1765c7861fd03ab3c7 Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Mon, 20 Jul 2026 09:08:22 -0400 Subject: [PATCH 10/18] use graphed from pypi as well --- .github/workflows/graphed.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/graphed.yml b/.github/workflows/graphed.yml index 03a090ca..7898f7ee 100644 --- a/.github/workflows/graphed.yml +++ b/.github/workflows/graphed.yml @@ -15,7 +15,7 @@ concurrency: env: # graphed core/frontend/numpy/awkward/debug are now ONE consolidated package (graphed.core, ...); # graphed-histogram + graphed-executors + the uproot fork stay separate packages. - GRAPHED: "graphed[awkward,numpy] @ git+https://github.com/graphed-org/graphed@main" + GRAPHED: "graphed[awkward,numpy]" EXEC: "graphed-executors" HISTO: "graphed-histogram" UPROOT: "uproot @ git+https://github.com/graphed-org/uproot5-graphed-mvp@graphed-mvp" From dfb479010b3ca762279a8dd21318213a7504d916 Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Sat, 5 Sep 2026 11:25:12 -0500 Subject: [PATCH 11/18] chore(graphed): let mypy ignore missing graphed_histogram stubs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream hist's `nox -s mypy` (the "Type check" CI job, absent from the older base this fork was cut from) type-checks all of `src` — including `src/hist/graphed/` — in an env that installs only hist's own `test`/`plot` groups, not `graphed_histogram`. So `import graphed_histogram.boost` raised `import-not-found` (4 errors, 2 files). Add `graphed_histogram.*` to the existing untyped-external `ignore_missing_imports` override, alongside scipy/matplotlib/mplhep/etc. Correct whether or not the package is installed (an ignore-missing-imports override is a no-op when the module is found), so it needs no per-line `# type: ignore` that strict mode would flag unused where the dep is present. Verified: `nox -s mypy` -> "Success: no issues found in 30 source files". Assisted-by: ClaudeCode:claude-opus-4.8 --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index c3ff089b..e65fb4df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -158,6 +158,7 @@ module = [ "iminuit.*", "mplhep.*", "dask_histogram.*", + "graphed_histogram.*", ] ignore_missing_imports = true From 4e05e1b4ef4a3d7deae93a1190b87679f5c1360e Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Sat, 5 Sep 2026 09:58:04 -0500 Subject: [PATCH 12/18] fix(graphed): pass variation_axis/unweighted through Hist.fill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hist.graphed.Hist`/`NamedHist` inherit `BaseHist.fill`, which resolves EVERY keyword through `_name_to_index` before delegating to `graphed_histogram.boost.Histogram.fill`. So graphed's two fill-MODE flags — `variation_axis=` (m52 axis mode) and `unweighted=` — were read as axis names and died: ValueError: The axis name variation_axis could not be found Add one `FillModeMixin`, mixed into both graphed hist classes ahead of the hist bases (the single point both ghb.Histogram subclasses route through). With neither flag set it delegates to `super().fill(...)` verbatim (sibling/default mode byte-identical to before); with one set it resolves the axis order itself via `_name_to_index` and calls `ghb.Histogram.fill` directly. Tests: `fill(..., variation_axis=True)` through hist.graphed now equals the low-level positional fill (fails on origin/graphed-mvp with the exact ValueError); a 2-D out-of-order kwarg fill matches the positional fill and NOT a swapped one (axis order); a no-flag missing-axis fill still raises hist's message (the flag path engages only for flags); and `unweighted=True` with a weight= factor is forwarded to graphed's refusal. All three properties verified discriminating by mutation. Assisted-by: ClaudeCode:claude-opus-5 --- src/hist/graphed/hist.py | 41 ++++++++++++- src/hist/graphed/namedhist.py | 9 ++- tests/test_graphed.py | 108 ++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 3 deletions(-) diff --git a/src/hist/graphed/hist.py b/src/hist/graphed/hist.py index ceefa3a4..7e0a2caa 100644 --- a/src/hist/graphed/hist.py +++ b/src/hist/graphed/hist.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Generic, TypeVar +from typing import Any, Generic, TypeVar import boost_histogram as bh import graphed_histogram.boost as ghb @@ -12,7 +12,44 @@ S = TypeVar("S", bound=bh.storage.Storage) -class Hist(HistInMemory[S], ghb.Histogram, Generic[S], family=hist): # type: ignore[misc] +class FillModeMixin: + """Routes graphed's fill-MODE flags past hist's named-axis fill. + + `BaseHist.fill` resolves every keyword as an axis name, so `variation_axis=`/`unweighted=` + die at `_name_to_index` before reaching `graphed_histogram.boost.Histogram.fill`. When one is + set, resolve the axis names here and call graphed's fill directly; otherwise defer to hist + unchanged. + """ + + def fill( + self, + *args: Any, + weight: Any = None, + sample: Any = None, + threads: int | None = None, + unweighted: bool = False, + variation_axis: bool = False, + **kwargs: Any, + ) -> Any: + base: Any = self + if not (unweighted or variation_axis): + return super().fill( # type: ignore[misc] + *args, weight=weight, sample=sample, threads=threads, **kwargs + ) + by_index = {base._name_to_index(k): v for k, v in kwargs.items()} + return ghb.Histogram.fill( + base, + *args, + *(by_index[i] for i in sorted(by_index)), + weight=weight, + sample=sample, + threads=threads, + unweighted=unweighted, + variation_axis=variation_axis, + ) + + +class Hist(FillModeMixin, HistInMemory[S], ghb.Histogram, Generic[S], family=hist): # type: ignore[misc] """A `hist.Hist` whose fills are DEFERRED graphed computations: QuickConstruct (`Hist.new.Reg(...).Double()`) and named-axis fills record into the graphed IR. Evaluation is graphed's own idiom — `plan()` + an R7 executor (whose result wraps back into an diff --git a/src/hist/graphed/namedhist.py b/src/hist/graphed/namedhist.py index 0d5aa5b7..797795a3 100644 --- a/src/hist/graphed/namedhist.py +++ b/src/hist/graphed/namedhist.py @@ -8,9 +8,16 @@ import hist from ..namedhist import NamedHist as NamedHistInMemory +from .hist import FillModeMixin S = TypeVar("S", bound=bh.storage.Storage) -class NamedHist(NamedHistInMemory[S], ghb.Histogram, Generic[S], family=hist): # type: ignore[misc] +class NamedHist( # type: ignore[misc] + FillModeMixin, + NamedHistInMemory[S], + ghb.Histogram, # type: ignore[misc] + Generic[S], + family=hist, +): pass diff --git a/tests/test_graphed.py b/tests/test_graphed.py index 4ae65c7e..5ae50053 100644 --- a/tests/test_graphed.py +++ b/tests/test_graphed.py @@ -24,6 +24,7 @@ RNG = np.random.default_rng(7) DATA = RNG.normal(5.0, 2.0, 800) WEIGHTS = RNG.uniform(0.5, 1.5, 800) +DATA2 = RNG.normal(5.0, 2.0, 800) # a distinct second axis, so axis ORDER is observable @dataclass @@ -148,3 +149,110 @@ def test_multiple_fills_and_process_executor(): eager.fill(v=np.abs(DATA) * 0.5) assert np.array_equal(direct.values(flow=True), eager.values(flow=True)) assert np.array_equal(np.asarray(later.values(flow=True)), eager.values(flow=True)) + + +def test_variation_axis_flag_reaches_graphed_and_is_not_read_as_an_axis_name(): + """hist's named-axis fill maps every keyword to an axis name, which swallowed graphed's + fill-MODE flags (`variation_axis=`) with `ValueError: axis name ... could not be found`.""" + pytest.importorskip("graphed.numpy") + import boost_histogram as bh + import graphed_histogram as ghist + import graphed_histogram.boost as ghb + + def varied(): + x, _ = _numpy_source() + w = x * 0.1 + return x, graphed.vary(w, "wgt", up=w * 1.2, down=w * 0.8) + + x, w = varied() + h = hist_graphed.Hist.new.Reg(10, 0, 10, name="met").Weight() + h.fill(met=x, weight=[w], variation_axis=True) + (got,) = dict(SequentialRunner().run(ghist.plan({"h": h}, steps_per_file=4)).value).values() + + # axis mode: graphed declares the extra "variation" StrCategory itself + assert [ax.__class__.__name__ for ax in got.axes] == ["Regular", "StrCategory"] + assert list(got.axes[1]) == ["nominal", "wgt_down", "wgt_up"] + + # ... and it is the same histogram the low-level fill produces + x2, w2 = varied() + low = ghb.Histogram(bh.axis.Regular(10, 0, 10), storage=bh.storage.Weight()) + low.fill(x2, weight=[w2], variation_axis=True) + (want,) = dict(SequentialRunner().run(ghist.plan({"h": low}, steps_per_file=4)).value).values() + assert np.array_equal(got.view(flow=True), want.view(flow=True)) + + # the other end of the class: NamedHist, and the sibling control kwarg `unweighted=` + x3, w3 = varied() + named = hist_graphed.NamedHist.new.Reg(10, 0, 10, name="met").Weight() + named.fill(met=x3, weight=[w3], variation_axis=True) + (n,) = dict(SequentialRunner().run(ghist.plan({"h": named}, steps_per_file=4)).value).values() + assert np.array_equal(n.view(flow=True), want.view(flow=True)) + + x4, _ = _numpy_source() + bare = hist_graphed.Hist.new.Reg(10, 0, 10, name="met").Weight() + bare.fill(met=x4, unweighted=True) # read as an axis name before the passthrough + assert SequentialRunner().run(bare.plan(steps_per_file=4)).value.sum().value == np.count_nonzero( + (DATA >= 0) & (DATA < 10) + ) + + +def _named_numpy_source(name, data): # type: ignore[no-untyped-def] + from graphed.numpy import NumpyBackend + from graphed.numpy.forms import NumpyForm + + s = Session(NumpyBackend()) + return s.source(name, form=NumpyForm(data.dtype, shape=(None,)), data=ChunkedNumpySource(data)) + + +def test_flag_path_reorders_axes_and_engages_only_for_flags(): + """Two guards the 1-D flag tests cannot see: the flag path re-derives axis ORDER from + `_name_to_index` (a 2-D out-of-order kwarg fill must equal the low-level POSITIONAL fill, not a + swapped one), and it engages ONLY when a flag is set (a no-flag fill still takes hist's path, + whose missing-axis message differs from the arity error the flag path would raise). Also: the + `unweighted=` flag is forwarded, not defaulted away.""" + pytest.importorskip("graphed.numpy") + import boost_histogram as bh + import graphed_histogram as ghist + import graphed_histogram.boost as ghb + from graphed.errors import GraphedError + + def run(h): # type: ignore[no-untyped-def] + return next(iter(dict(SequentialRunner().run(ghist.plan({"h": h}, steps_per_file=4)).value).values())) + + # (reorder) axes are declared a, b; passing them as b=, a= must land on a, b — i.e. equal the + # positional fill(a_data, b_data), and NOT the swapped fill(b_data, a_data). + h = hist_graphed.Hist.new.Reg(10, 0, 10, name="a").Reg(10, 0, 10, name="b").Double() + h.fill(b=_named_numpy_source("b", DATA2), a=_named_numpy_source("a", DATA), variation_axis=True) + got = run(h).view(flow=True) + low = ghb.Histogram(bh.axis.Regular(10, 0, 10), bh.axis.Regular(10, 0, 10)) + low.fill(_named_numpy_source("a", DATA), _named_numpy_source("b", DATA2), variation_axis=True) + assert np.array_equal(got, run(low).view(flow=True)) + swapped = ghb.Histogram(bh.axis.Regular(10, 0, 10), bh.axis.Regular(10, 0, 10)) + swapped.fill(_named_numpy_source("b", DATA2), _named_numpy_source("a", DATA), variation_axis=True) + assert not np.array_equal(got, run(swapped).view(flow=True)) # order genuinely matters + + # (routing) no flag -> hist's path, whose missing-axis TypeError names the axis ("Missing + # values ... ['b']"); the flag path would instead raise graphed's arity message. + miss = hist_graphed.Hist.new.Reg(10, 0, 10, name="a").Reg(10, 0, 10, name="b").Double() + with pytest.raises(TypeError, match="Missing values"): + miss.fill(a=_named_numpy_source("a", DATA)) + + # (unweighted) forwarded, not silently set False: unweighted=True with a weight= factor is the + # contradiction graphed refuses at fill time. + x = _named_numpy_source("met", DATA) + hbad = hist_graphed.Hist.new.Reg(10, 0, 10, name="met").Weight() + with pytest.raises(GraphedError, match="unweighted=True suppresses"): + hbad.fill(met=x, weight=[x], unweighted=True) + + +def test_sibling_mode_is_unchanged_by_the_flag_passthrough(): + pytest.importorskip("graphed.numpy") + import graphed_histogram as ghist + + x, _ = _numpy_source() + w = x * 0.1 + h = hist_graphed.Hist.new.Reg(10, 0, 10, name="met").Weight() + h.fill(met=x, weight=[graphed.vary(w, "wgt", up=w * 1.2, down=w * 0.8)]) # default: siblings + slots = dict(SequentialRunner().run(ghist.plan({"h": h}, steps_per_file=4)).value) + assert {k[1] for k in slots} == {"nominal", "wgt_up", "wgt_down"} + for got in slots.values(): + assert [ax.__class__.__name__ for ax in got.axes] == ["Regular"] # no variation axis From cb4878633d30d5664e2ab38689b7f4b812adb3a6 Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Sat, 5 Sep 2026 12:04:45 -0500 Subject: [PATCH 13/18] ci(graphed): install the graphed stack from git main, not PyPI The `graphed` integration workflow pinned graphed / graphed-executors / graphed-histogram from PyPI ("now released"), but the hist.graphed tests use m52 features that land on the repos' main branches ahead of any PyPI release: `graphed.vary`/`graphed.points` and the `variation_axis`/`unweighted` histogram fill modes. Post-merge, the released PyPI graphed had neither, so the graphed-mvp push CI failed: AttributeError: module 'graphed' has no attribute 'vary' TypeError: Histogram.fill() got an unexpected keyword argument 'unweighted' Point the three co-developed packages at git main (uproot already installs from git), matching what the local editable dev stack tests against. Repin to PyPI once a release carries m52. Assisted-by: ClaudeCode:claude-opus-4.8 --- .github/workflows/graphed.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/graphed.yml b/.github/workflows/graphed.yml index 7898f7ee..a8e4b741 100644 --- a/.github/workflows/graphed.yml +++ b/.github/workflows/graphed.yml @@ -13,11 +13,15 @@ concurrency: cancel-in-progress: true env: - # graphed core/frontend/numpy/awkward/debug are now ONE consolidated package (graphed.core, ...); + # This integration branch tracks the graphed stack's git main, NOT PyPI: the mvp packages are + # co-developed and the hist.graphed tests use features (m52 `graphed.vary`/`points`, the + # `variation_axis`/`unweighted` histogram fill modes) that land on main ahead of any PyPI release. + # Pinning PyPI here silently regresses to a pre-feature graphed. Repin to PyPI once a release ships. + # graphed core/frontend/numpy/awkward/debug are ONE consolidated package (graphed.core, ...); # graphed-histogram + graphed-executors + the uproot fork stay separate packages. - GRAPHED: "graphed[awkward,numpy]" - EXEC: "graphed-executors" - HISTO: "graphed-histogram" + GRAPHED: "graphed[awkward,numpy] @ git+https://github.com/graphed-org/graphed@main" + EXEC: "graphed-executors @ git+https://github.com/graphed-org/graphed-executors@main" + HISTO: "graphed-histogram @ git+https://github.com/graphed-org/graphed-histogram@main" UPROOT: "uproot @ git+https://github.com/graphed-org/uproot5-graphed-mvp@graphed-mvp" jobs: From 0b9efb15d0325872eccc201e59d242007baf56f0 Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Mon, 14 Sep 2026 11:19:49 -0500 Subject: [PATCH 14/18] chore: drop the graphed-project machinery from the upstream PR The graphed.yml workflow and the .graphed/ tracking directory belong to the graphed-project development pipeline, not to hist; upstream CI covers the hist.graphed hooks once graphed is a test dependency. Assisted-by: ClaudeCode:claude-opus-5[1m] --- .github/workflows/graphed.yml | 51 ----------------------------------- .graphed/attempts.md | 26 ------------------ .graphed/state.json | 27 ------------------- 3 files changed, 104 deletions(-) delete mode 100644 .github/workflows/graphed.yml delete mode 100644 .graphed/attempts.md delete mode 100644 .graphed/state.json diff --git a/.github/workflows/graphed.yml b/.github/workflows/graphed.yml deleted file mode 100644 index a8e4b741..00000000 --- a/.github/workflows/graphed.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: graphed - -# The graphed-mvp integration (HIST-1): runs the FULL hist test suite together with the -# hist.graphed tests, so regressions from the graphed additions are caught — not just the graphed -# tests in isolation. Runs only on the graphed-mvp branch (hist's own CI runs on main / PRs). -on: - push: - branches: [graphed-mvp] - workflow_dispatch: - -concurrency: - group: graphed-${{ github.ref }} - cancel-in-progress: true - -env: - # This integration branch tracks the graphed stack's git main, NOT PyPI: the mvp packages are - # co-developed and the hist.graphed tests use features (m52 `graphed.vary`/`points`, the - # `variation_axis`/`unweighted` histogram fill modes) that land on main ahead of any PyPI release. - # Pinning PyPI here silently regresses to a pre-feature graphed. Repin to PyPI once a release ships. - # graphed core/frontend/numpy/awkward/debug are ONE consolidated package (graphed.core, ...); - # graphed-histogram + graphed-executors + the uproot fork stay separate packages. - GRAPHED: "graphed[awkward,numpy] @ git+https://github.com/graphed-org/graphed@main" - EXEC: "graphed-executors @ git+https://github.com/graphed-org/graphed-executors@main" - HISTO: "graphed-histogram @ git+https://github.com/graphed-org/graphed-histogram@main" - UPROOT: "uproot @ git+https://github.com/graphed-org/uproot5-graphed-mvp@graphed-mvp" - -jobs: - tests: - name: hist + graphed tests ${{ matrix.python }} - runs-on: ubuntu-latest - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - python: ["3.11", "3.12"] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python }} - - uses: dtolnay/rust-toolchain@stable - - name: Install graphed (mvp) siblings + hist (test extras) - run: | - python -m pip install --upgrade pip - python -m pip install "${{ env.GRAPHED }}" "${{ env.EXEC }}" "${{ env.HISTO }}" \ - "${{ env.UPROOT }}" - # hist's test deps are a PEP 735 dependency GROUP (not an extra) - python -m pip install -e . --group test - python -m pip install scikit-hep-testdata - - name: Full hist suite + the graphed integration tests - run: python -m pytest tests -q diff --git a/.graphed/attempts.md b/.graphed/attempts.md deleted file mode 100644 index 6e5a508d..00000000 --- a/.graphed/attempts.md +++ /dev/null @@ -1,26 +0,0 @@ -# attempts — hist-graphed-mvp (branch graphed-mvp) - -## HIST-1 — hist.graphed: deferred hist filling on graphed task graphs — 2026-06-10 (freeze-HIST-1) - -P0.1 of the ADL-benchmarks port (user-confirmed plan). src/hist/graphed mirrors src/hist/dask: -Hist/NamedHist as the MRO sandwich (hist's QuickConstruct + named-axis handling over -graphed_histogram.boost.Histogram's deferred fill), with `_in_memory_type` so compute() returns -a REAL hist.Hist (named indexing works on results). - -- tests/test_graphed.py (5 tests, test-first against the integration surface): QuickConstruct - deferred == eager twins BIT FOR BIT (counts incl. flow) over graphed-numpy AND graphed-awkward - sources (ragged fills flatten completely) and a real uproot TTree (np.hypot of branches); - weighted 2D NamedHist (values + variances); multi-fill accumulation; ProcessExecutor plan == - compute; the efficiency witness (the source's whole-dataset loader never runs). -- Findings folded back into graphed-histogram iteration 1 (M23): the `_in_memory_type` wrapping - hook, and hist's name/label living in the axis `__dict__` (boost's metadata mechanism) — now - captured/restored by the canonical spec. -- Full hist suite green alongside: 200 passed, 8 skipped (mplhep + pytest-mpl are test deps). - -## freeze-HIST-2 — USER-DIRECTED respin: no compute() (graphed evaluation idiom) — 2026-06-11 - -- graphed-histogram removed compute() (freeze-M23-1): evaluation is plan() + an R7 executor, or - the reference session.materialize. hist.graphed classes drop the now-unused _in_memory_type - property (pure MRO sandwiches); results wrap back in-memory via hist.Hist(value) / - hist.NamedHist(value) — names/labels survive (the spec carries the axis __dict__). -- tests/test_graphed.py respun to the executor idiom; same pins. Full hist suite green. diff --git a/.graphed/state.json b/.graphed/state.json deleted file mode 100644 index 0229ce20..00000000 --- a/.graphed/state.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "milestones": { - "HIST-1": { - "dispute_count": 0, - "escalated": false, - "freeze_tag": "freeze-HIST-2", - "gates": { - "benchmark": null, - "coverage": true, - "determinism": true, - "frozen_tests": true, - "integrity_scan": true, - "lint": true, - "types": true - }, - "incident": null, - "l0_count": 0, - "metrics_history": [ - { - "benchmark_ok": null, - "pass_count": 205 - } - ] - } - }, - "updated_at": "2026-06-11T13:19:42Z" -} \ No newline at end of file From e2f39802a5b15e844fbbafd5687be846b0de8e8d Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Mon, 14 Sep 2026 11:22:02 -0500 Subject: [PATCH 15/18] ci: graphed is a test dependency on Python 3.11+ tests/test_graphed.py is guarded by importorskip, so without this the hist.graphed hooks are never exercised in CI. graphed needs Python 3.11+, so the marker keeps the 3.10 jobs (checks, mypy, minimums) unchanged; the floors keep the lowest-direct "Check minimums" resolution off the 0.0.1 releases. Assisted-by: ClaudeCode:claude-opus-5[1m] --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index e65fb4df..4272e392 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,6 +96,10 @@ test = [ { include-group = "test-core" }, { include-group = "fit" }, "awkward>=2.0.7", + # tests/test_graphed.py; graphed requires Python 3.11+ + 'graphed[awkward,numpy]>=0.0.2;python_version>="3.11"', + 'graphed-histogram>=0.0.2;python_version>="3.11"', + 'graphed-executors>=0.0.2;python_version>="3.11"', ] test-core = [ "pytest >=8", From 44f6358566acfd0f53df3f5b4c1c5584b8cfa43c Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Mon, 14 Sep 2026 11:23:12 -0500 Subject: [PATCH 16/18] style: ruff Satisfy the repo's ruff-check/ruff-format hooks on tests/test_graphed.py: unused PartitionedSource.read_partition arguments become _columns/_resources, two compound asserts are split, imports sorted, dead noqa dropped, reformatted. Assisted-by: ClaudeCode:claude-opus-5[1m] --- tests/test_graphed.py | 112 +++++++++++++++++++++++++++++++----------- 1 file changed, 82 insertions(+), 30 deletions(-) diff --git a/tests/test_graphed.py b/tests/test_graphed.py index 5ae50053..d45816e6 100644 --- a/tests/test_graphed.py +++ b/tests/test_graphed.py @@ -15,11 +15,11 @@ graphed = pytest.importorskip("graphed") hist_graphed = pytest.importorskip("hist.graphed") -from dataclasses import dataclass, field # noqa: E402 +from dataclasses import dataclass, field -from graphed import Session # noqa: E402 -from graphed.core.execution import SequentialRunner # noqa: E402 -from graphed.core import Partition # noqa: E402 +from graphed import Session +from graphed.core import Partition +from graphed.core.execution import SequentialRunner RNG = np.random.default_rng(7) DATA = RNG.normal(5.0, 2.0, 800) @@ -37,9 +37,12 @@ def __call__(self) -> np.ndarray: return self.data def partitions(self, steps_per_file: int = 1) -> tuple[Partition, ...]: - return tuple(Partition.blind("toy://x", "", s, steps_per_file) for s in range(steps_per_file)) + return tuple( + Partition.blind("toy://x", "", s, steps_per_file) + for s in range(steps_per_file) + ) - def read_partition(self, partition, columns, resources): # type: ignore[no-untyped-def] + def read_partition(self, partition, _columns, _resources): # type: ignore[no-untyped-def] part = partition.resolve(len(self.data)) return self.data[part.entry_start : part.entry_stop] @@ -56,14 +59,20 @@ def _numpy_source(): def test_quickconstruct_matches_the_eager_twin_bit_for_bit(): pytest.importorskip("graphed.numpy") x, src = _numpy_source() - h = hist_graphed.Hist.new.Reg(40, 0, 10, name="met", label="$E_T$").Int64().fill(met=x) + h = ( + hist_graphed.Hist.new.Reg(40, 0, 10, name="met", label="$E_T$") + .Int64() + .fill(met=x) + ) # graphed idiom: the executor aggregates; hist.Hist(value) wraps back into the in-memory type out = hist.Hist(SequentialRunner().run(h.plan(steps_per_file=4)).value) - assert isinstance(out, hist.Hist) and not isinstance(out, hist_graphed.Hist) + assert isinstance(out, hist.Hist) + assert not isinstance(out, hist_graphed.Hist) eager = hist.Hist.new.Reg(40, 0, 10, name="met", label="$E_T$").Int64() eager.fill(met=DATA) assert np.array_equal(out.values(flow=True), eager.values(flow=True)) - assert out.axes[0].name == "met" and out.axes[0].label == "$E_T$" + assert out.axes[0].name == "met" + assert out.axes[0].label == "$E_T$" assert out[{"met": sum}] == eager[{"met": sum}] assert src.whole_calls == [] # partition-wise: the whole-dataset loader never ran @@ -72,7 +81,9 @@ def test_weighted_2d_and_namedhist(): pytest.importorskip("graphed.numpy") x, _ = _numpy_source() h = ( - hist_graphed.NamedHist.new.Reg(10, 0, 10, name="a").Reg(8, 0, 5, name="b").Weight() + hist_graphed.NamedHist.new.Reg(10, 0, 10, name="a") + .Reg(8, 0, 5, name="b") + .Weight() .fill(a=x, b=x * 0.5, weight=np.sqrt(abs(x))) ) out = hist.NamedHist(SequentialRunner().run(h.plan(steps_per_file=3)).value) @@ -99,9 +110,12 @@ def __call__(self): return self.data def partitions(self, steps_per_file: int = 1): - return tuple(Partition.blind("toy://e", "", s, steps_per_file) for s in range(steps_per_file)) + return tuple( + Partition.blind("toy://e", "", s, steps_per_file) + for s in range(steps_per_file) + ) - def read_partition(self, partition, columns, resources): + def read_partition(self, partition, _columns, _resources): part = partition.resolve(len(self.data)) return self.data[part.entry_start : part.entry_stop] @@ -113,7 +127,9 @@ def read_partition(self, partition, columns, resources): h = hist_graphed.Hist.new.Reg(20, 0, 100, name="pt").Int64().fill(pt=g.Jet_pt) out = hist.Hist(SequentialRunner().run(h.plan(steps_per_file=5)).value) eager = hist.Hist.new.Reg(20, 0, 100, name="pt").Int64() - eager.fill(pt=ak.flatten(events.Jet_pt, axis=None)) # ragged fills flatten completely + eager.fill( + pt=ak.flatten(events.Jet_pt, axis=None) + ) # ragged fills flatten completely assert np.array_equal(out.values(flow=True), eager.values(flow=True)) assert src.whole_calls == [] @@ -125,8 +141,10 @@ def test_uproot_ttree_fill_end_to_end(): where = skhep_testdata.data_path("uproot-Zmumu.root") + ":events" g = uproot.graphed(where, library="ak", filter_name=["px1", "py1"]) - h = hist_graphed.Hist.new.Reg(50, 0, 100, name="pt1").Double().fill( - pt1=np.hypot(g.px1, g.py1) + h = ( + hist_graphed.Hist.new.Reg(50, 0, 100, name="pt1") + .Double() + .fill(pt1=np.hypot(g.px1, g.py1)) ) out = hist.Hist(SequentialRunner().run(h.plan(steps_per_file=3)).value) raw = uproot.open(where).arrays(["px1", "py1"]) @@ -167,7 +185,9 @@ def varied(): x, w = varied() h = hist_graphed.Hist.new.Reg(10, 0, 10, name="met").Weight() h.fill(met=x, weight=[w], variation_axis=True) - (got,) = dict(SequentialRunner().run(ghist.plan({"h": h}, steps_per_file=4)).value).values() + (got,) = dict( + SequentialRunner().run(ghist.plan({"h": h}, steps_per_file=4)).value + ).values() # axis mode: graphed declares the extra "variation" StrCategory itself assert [ax.__class__.__name__ for ax in got.axes] == ["Regular", "StrCategory"] @@ -177,22 +197,26 @@ def varied(): x2, w2 = varied() low = ghb.Histogram(bh.axis.Regular(10, 0, 10), storage=bh.storage.Weight()) low.fill(x2, weight=[w2], variation_axis=True) - (want,) = dict(SequentialRunner().run(ghist.plan({"h": low}, steps_per_file=4)).value).values() + (want,) = dict( + SequentialRunner().run(ghist.plan({"h": low}, steps_per_file=4)).value + ).values() assert np.array_equal(got.view(flow=True), want.view(flow=True)) # the other end of the class: NamedHist, and the sibling control kwarg `unweighted=` x3, w3 = varied() named = hist_graphed.NamedHist.new.Reg(10, 0, 10, name="met").Weight() named.fill(met=x3, weight=[w3], variation_axis=True) - (n,) = dict(SequentialRunner().run(ghist.plan({"h": named}, steps_per_file=4)).value).values() + (n,) = dict( + SequentialRunner().run(ghist.plan({"h": named}, steps_per_file=4)).value + ).values() assert np.array_equal(n.view(flow=True), want.view(flow=True)) x4, _ = _numpy_source() bare = hist_graphed.Hist.new.Reg(10, 0, 10, name="met").Weight() bare.fill(met=x4, unweighted=True) # read as an axis name before the passthrough - assert SequentialRunner().run(bare.plan(steps_per_file=4)).value.sum().value == np.count_nonzero( - (DATA >= 0) & (DATA < 10) - ) + assert SequentialRunner().run( + bare.plan(steps_per_file=4) + ).value.sum().value == np.count_nonzero((DATA >= 0) & (DATA < 10)) def _named_numpy_source(name, data): # type: ignore[no-untyped-def] @@ -200,7 +224,9 @@ def _named_numpy_source(name, data): # type: ignore[no-untyped-def] from graphed.numpy.forms import NumpyForm s = Session(NumpyBackend()) - return s.source(name, form=NumpyForm(data.dtype, shape=(None,)), data=ChunkedNumpySource(data)) + return s.source( + name, form=NumpyForm(data.dtype, shape=(None,)), data=ChunkedNumpySource(data) + ) def test_flag_path_reorders_axes_and_engages_only_for_flags(): @@ -216,23 +242,45 @@ def test_flag_path_reorders_axes_and_engages_only_for_flags(): from graphed.errors import GraphedError def run(h): # type: ignore[no-untyped-def] - return next(iter(dict(SequentialRunner().run(ghist.plan({"h": h}, steps_per_file=4)).value).values())) + return next( + iter( + dict( + SequentialRunner().run(ghist.plan({"h": h}, steps_per_file=4)).value + ).values() + ) + ) # (reorder) axes are declared a, b; passing them as b=, a= must land on a, b — i.e. equal the # positional fill(a_data, b_data), and NOT the swapped fill(b_data, a_data). h = hist_graphed.Hist.new.Reg(10, 0, 10, name="a").Reg(10, 0, 10, name="b").Double() - h.fill(b=_named_numpy_source("b", DATA2), a=_named_numpy_source("a", DATA), variation_axis=True) + h.fill( + b=_named_numpy_source("b", DATA2), + a=_named_numpy_source("a", DATA), + variation_axis=True, + ) got = run(h).view(flow=True) low = ghb.Histogram(bh.axis.Regular(10, 0, 10), bh.axis.Regular(10, 0, 10)) - low.fill(_named_numpy_source("a", DATA), _named_numpy_source("b", DATA2), variation_axis=True) + low.fill( + _named_numpy_source("a", DATA), + _named_numpy_source("b", DATA2), + variation_axis=True, + ) assert np.array_equal(got, run(low).view(flow=True)) swapped = ghb.Histogram(bh.axis.Regular(10, 0, 10), bh.axis.Regular(10, 0, 10)) - swapped.fill(_named_numpy_source("b", DATA2), _named_numpy_source("a", DATA), variation_axis=True) - assert not np.array_equal(got, run(swapped).view(flow=True)) # order genuinely matters + swapped.fill( + _named_numpy_source("b", DATA2), + _named_numpy_source("a", DATA), + variation_axis=True, + ) + assert not np.array_equal( + got, run(swapped).view(flow=True) + ) # order genuinely matters # (routing) no flag -> hist's path, whose missing-axis TypeError names the axis ("Missing # values ... ['b']"); the flag path would instead raise graphed's arity message. - miss = hist_graphed.Hist.new.Reg(10, 0, 10, name="a").Reg(10, 0, 10, name="b").Double() + miss = ( + hist_graphed.Hist.new.Reg(10, 0, 10, name="a").Reg(10, 0, 10, name="b").Double() + ) with pytest.raises(TypeError, match="Missing values"): miss.fill(a=_named_numpy_source("a", DATA)) @@ -251,8 +299,12 @@ def test_sibling_mode_is_unchanged_by_the_flag_passthrough(): x, _ = _numpy_source() w = x * 0.1 h = hist_graphed.Hist.new.Reg(10, 0, 10, name="met").Weight() - h.fill(met=x, weight=[graphed.vary(w, "wgt", up=w * 1.2, down=w * 0.8)]) # default: siblings + h.fill( + met=x, weight=[graphed.vary(w, "wgt", up=w * 1.2, down=w * 0.8)] + ) # default: siblings slots = dict(SequentialRunner().run(ghist.plan({"h": h}, steps_per_file=4)).value) assert {k[1] for k in slots} == {"nominal", "wgt_up", "wgt_down"} for got in slots.values(): - assert [ax.__class__.__name__ for ax in got.axes] == ["Regular"] # no variation axis + assert [ax.__class__.__name__ for ax in got.axes] == [ + "Regular" + ] # no variation axis From f1e4108cdc0c473898eb05a283a62b810d8837d9 Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Mon, 14 Sep 2026 11:53:48 -0500 Subject: [PATCH 17/18] docs: say what the graphed hooks do without the project's labels The hist.graphed docstring and the test-module header carried graphed-project work-item and freeze-tag labels an upstream reader cannot resolve; they now state the same behaviour in plain words. Assisted-by: ClaudeCode:claude-opus-5 --- src/hist/graphed/hist.py | 2 +- tests/test_graphed.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/hist/graphed/hist.py b/src/hist/graphed/hist.py index 7e0a2caa..bd22b901 100644 --- a/src/hist/graphed/hist.py +++ b/src/hist/graphed/hist.py @@ -52,6 +52,6 @@ def fill( class Hist(FillModeMixin, HistInMemory[S], ghb.Histogram, Generic[S], family=hist): # type: ignore[misc] """A `hist.Hist` whose fills are DEFERRED graphed computations: QuickConstruct (`Hist.new.Reg(...).Double()`) and named-axis fills record into the graphed IR. Evaluation - is graphed's own idiom — `plan()` + an R7 executor (whose result wraps back into an + is graphed's own idiom — `plan()` + a graphed executor (whose result wraps back into an in-memory `hist.Hist` via `hist.Hist(value)`), or the reference `session.materialize` on a fill node.""" diff --git a/tests/test_graphed.py b/tests/test_graphed.py index d45816e6..a74515a2 100644 --- a/tests/test_graphed.py +++ b/tests/test_graphed.py @@ -1,9 +1,9 @@ -# Tests for hist.graphed — deferred hist filling on graphed task graphs (HIST-1; P0.1 of the -# ADL-benchmarks port). QuickConstruct-built deferred histograms must equal their eager hist.Hist -# twins BIT FOR BIT over graphed-numpy AND graphed-awkward sources and over a real uproot TTree, -# with named-axis fills, weights, NamedHist, and the partition-wise efficiency witness (the -# source's whole-dataset loader never runs). Evaluation is graphed's idiom [freeze-HIST-2, -# user-directed]: plan() + an R7 executor; hist.Hist(value) wraps results back in-memory. +# Tests for hist.graphed — deferred hist filling on graphed task graphs. QuickConstruct-built +# deferred histograms must equal their eager hist.Hist twins BIT FOR BIT over graphed-numpy AND +# graphed.awkward sources and over a real uproot TTree, with named-axis fills, weights, NamedHist, +# and the partition-wise efficiency witness (the source's whole-dataset loader never runs). +# Evaluation is graphed's idiom: plan() + a graphed executor; hist.Hist(value) wraps results back +# in-memory. from __future__ import annotations import numpy as np From 8bc3281b8c8651aa54e824f9a338648da24644ef Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Mon, 14 Sep 2026 12:29:27 -0500 Subject: [PATCH 18/18] test: skip the uproot end-to-end fill when uproot lacks uproot.graphed An installed uproot without the graphed reader (any release before scikit-hep/uproot5#1720) raised AttributeError instead of skipping. Assisted-by: ClaudeCode:claude-fable-5-1 --- tests/test_graphed.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_graphed.py b/tests/test_graphed.py index a74515a2..f93a7ab5 100644 --- a/tests/test_graphed.py +++ b/tests/test_graphed.py @@ -136,6 +136,8 @@ def read_partition(self, partition, _columns, _resources): def test_uproot_ttree_fill_end_to_end(): uproot = pytest.importorskip("uproot") + if not hasattr(uproot, "graphed"): + pytest.skip("uproot without uproot.graphed (scikit-hep/uproot5#1720)") pytest.importorskip("graphed.awkward") skhep_testdata = pytest.importorskip("skhep_testdata")