diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 64bc4e2eda..6c4f7a04ee 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -100,6 +100,7 @@ TiledArray/dist_eval/binary_eval.h
TiledArray/dist_eval/contraction_eval.h
TiledArray/dist_eval/dist_eval.h
TiledArray/dist_eval/unary_eval.h
+TiledArray/dist_eval/zero_volume_eval.h
TiledArray/einsum/index.h
TiledArray/einsum/range.h
TiledArray/einsum/string.h
diff --git a/src/TiledArray/dist_eval/zero_volume_eval.h b/src/TiledArray/dist_eval/zero_volume_eval.h
new file mode 100644
index 0000000000..36796b20e7
--- /dev/null
+++ b/src/TiledArray/dist_eval/zero_volume_eval.h
@@ -0,0 +1,69 @@
+/*
+ * This file is a part of TiledArray.
+ * Copyright (C) 2026 Virginia Tech
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+#ifndef TILEDARRAY_DIST_EVAL_ZERO_VOLUME_EVAL_H__INCLUDED
+#define TILEDARRAY_DIST_EVAL_ZERO_VOLUME_EVAL_H__INCLUDED
+
+#include
+
+namespace TiledArray {
+namespace detail {
+
+/// A distributed evaluator over a zero-volume tiled range: it owns no tiles,
+/// so evaluation produces nothing and no tile can be requested. The general
+/// (fused x contracted) product evaluates a zero-volume result through it,
+/// since its SUMMA evaluator needs a process grid and ProcGrid requires at
+/// least one row and one column (see ContEngine::init_distribution_general).
+/// \tparam Tile The output tile type
+/// \tparam Policy The tensor policy class
+template
+class ZeroVolumeEvalImpl final : public DistEvalImpl {
+ public:
+ typedef DistEvalImpl DistEvalImpl_; ///< The base class type
+ typedef typename DistEvalImpl_::ordinal_type ordinal_type; ///< Ordinal type
+ typedef typename DistEvalImpl_::trange_type trange_type; ///< Tiled range
+ typedef typename DistEvalImpl_::shape_type shape_type; ///< Shape type
+ typedef typename DistEvalImpl_::pmap_interface pmap_interface; ///< Pmap
+ typedef typename DistEvalImpl_::value_type value_type; ///< Tile
+
+ /// \param world The world of the result
+ /// \param trange The result tiled range; its tile range must be empty
+ /// \param shape The result shape
+ /// \param pmap The result process map
+ ZeroVolumeEvalImpl(World& world, const trange_type& trange,
+ const shape_type& shape,
+ const std::shared_ptr& pmap)
+ : DistEvalImpl_(world, trange, shape, pmap, Permutation{}) {
+ TA_ASSERT(trange.tiles_range().volume() == 0);
+ }
+
+ Future get_tile(ordinal_type) const override {
+ TA_EXCEPTION("ZeroVolumeEvalImpl owns no tiles");
+ return Future();
+ }
+
+ void discard_tile(ordinal_type) const override {}
+
+ int internal_eval() override { return 0; }
+};
+
+} // namespace detail
+} // namespace TiledArray
+
+#endif // TILEDARRAY_DIST_EVAL_ZERO_VOLUME_EVAL_H__INCLUDED
diff --git a/src/TiledArray/expressions/cont_engine.h b/src/TiledArray/expressions/cont_engine.h
index a7ff78c1d4..c0bc4512f3 100644
--- a/src/TiledArray/expressions/cont_engine.h
+++ b/src/TiledArray/expressions/cont_engine.h
@@ -28,6 +28,7 @@
#include
#include
+#include
#include
#include
#include
@@ -1120,6 +1121,19 @@ class ContEngine : public BinaryEngine {
batched_op_type, typename Derived::policy>
impl_type;
+ // A zero-volume result (some result mode has no tiles): the corner case
+ // of init_distribution_general skipped the process grid (ProcGrid needs
+ // at least one row and one column) and the slabbed process maps, so
+ // neither the Summa evaluator nor its canonical pmap can be built here.
+ // The result has no tiles, so evaluate it through the tile-less
+ // evaluator in the target layout.
+ if (trange_.tiles_range().volume() == 0) {
+ typedef TiledArray::detail::ZeroVolumeEvalImpl
+ empty_impl_type;
+ return dist_eval_type(
+ std::make_shared(*world_, trange_, shape_, pmap_));
+ }
+
typename left_type::dist_eval_type left = left_.make_dist_eval();
typename right_type::dist_eval_type right = right_.make_dist_eval();
diff --git a/tests/general_product.cpp b/tests/general_product.cpp
index e76fa24db9..ba2622de0a 100644
--- a/tests/general_product.cpp
+++ b/tests/general_product.cpp
@@ -911,6 +911,107 @@ BOOST_AUTO_TEST_CASE(expression_general_product_sparse_no_externals) {
BOOST_CHECK_SMALL(diff_norm_sp(c, c_ref, "b,i"), 1e-10);
}
+namespace {
+
+/// tranges for C("b,i,k") = A("b,i,j") * B("b,j,k") in which exactly one
+/// result mode has no tiles, so the result has zero volume. Which mode is
+/// empty selects which of the general-product distribution extents vanishes:
+/// `i` -> M == 0, `b` -> n_slabs_ == 0, `k` -> N == 0 (see
+/// ContEngine::init_distribution_general).
+struct ZeroVolumeTRanges {
+ TA::TiledRange a; ///< b, i, j
+ TA::TiledRange b; ///< b, j, k
+};
+
+/// \param empty_mode one of "i" (M == 0), "b" (n_slabs_ == 0), "k" (N == 0)
+ZeroVolumeTRanges make_zero_volume_tranges(const std::string& empty_mode) {
+ const TA::TiledRange1 none{0}; // no tiles
+ const TA::TiledRange1 tr_b{0, 2, 5}, tr_i{0, 3, 4}, tr_j{0, 2, 6, 7},
+ tr_k{0, 4, 5};
+ if (empty_mode == "i")
+ return {TA::TiledRange{tr_b, none, tr_j}, TA::TiledRange{tr_b, tr_j, tr_k}};
+ if (empty_mode == "b")
+ return {TA::TiledRange{none, tr_i, tr_j}, TA::TiledRange{none, tr_j, tr_k}};
+ BOOST_REQUIRE_EQUAL(empty_mode, "k");
+ return {TA::TiledRange{tr_b, tr_i, tr_j}, TA::TiledRange{tr_b, tr_j, none}};
+}
+
+/// checks that \p array carries no tiles and no elements
+template
+void check_zero_volume(const Array& array) {
+ BOOST_CHECK_EQUAL(array.trange().tiles_range().volume(), 0ul);
+ BOOST_CHECK_EQUAL(array.trange().elements_range().volume(), 0ul);
+ BOOST_CHECK(array.begin() == array.end());
+}
+
+} // namespace
+
+BOOST_AUTO_TEST_CASE(expression_general_product_zero_volume_dense) {
+ // a result mode with no tiles => zero-volume result.
+ // init_distribution_general skips the process grid in that corner case
+ // (ProcGrid needs at least one row and one column), so make_dist_eval_general
+ // must not build the Summa evaluator or its canonical slabbed pmap: it
+ // evaluates through the tile-less ZeroVolumeEvalImpl instead. Before that,
+ // this tripped TA_ASSERT(world_) in ProcGrid::make_pmap.
+ auto& world = TA::get_default_world();
+
+ for (const std::string empty_mode : {"i", "b", "k"}) {
+ BOOST_TEST_CONTEXT("empty mode = " << empty_mode) {
+ const auto tr = make_zero_volume_tranges(empty_mode);
+ auto a = make_patterned_array(world, tr.a, 1.0);
+ auto b = make_patterned_array(world, tr.b, 2.0);
+
+ // canonical target
+ TA::TArrayD c;
+ BOOST_REQUIRE_NO_THROW(c("b,i,k") = a("b,i,j") * b("b,j,k"));
+ check_zero_volume(c);
+
+ // repermuted target: the result layout differs from the canonical
+ // (h, e_A, e_B) one, so the evaluator is built in the target layout
+ TA::TArrayD d;
+ BOOST_REQUIRE_NO_THROW(d("i,b,k") = a("b,i,j") * b("b,j,k"));
+ check_zero_volume(d);
+ }
+ }
+}
+
+BOOST_AUTO_TEST_CASE(expression_general_product_zero_volume_sparse) {
+ // same three degenerate shapes on SparsePolicy: the zero-volume path runs
+ // before any shape-driven work, so it must behave identically
+ auto& world = TA::get_default_world();
+
+ for (const std::string empty_mode : {"i", "b", "k"}) {
+ BOOST_TEST_CONTEXT("empty mode = " << empty_mode) {
+ const auto tr = make_zero_volume_tranges(empty_mode);
+ auto a = make_patterned_sparse_array(world, tr.a, 1.0, 3);
+ auto b = make_patterned_sparse_array(world, tr.b, 2.0, 4);
+
+ TA::TSpArrayD c;
+ BOOST_REQUIRE_NO_THROW(c("b,i,k") = a("b,i,j") * b("b,j,k"));
+ check_zero_volume(c);
+
+ TA::TSpArrayD d;
+ BOOST_REQUIRE_NO_THROW(d("i,b,k") = a("b,i,j") * b("b,j,k"));
+ check_zero_volume(d);
+ }
+ }
+}
+
+BOOST_AUTO_TEST_CASE(expression_general_product_zero_volume_contracted_mode) {
+ // control: a zero-tile CONTRACTED mode leaves the result volume nonzero, so
+ // the ordinary (process-grid) path still runs and produces a zero array
+ auto& world = TA::get_default_world();
+ const TA::TiledRange1 none{0};
+ const TA::TiledRange1 tr_b{0, 2, 5}, tr_i{0, 3, 4}, tr_k{0, 4, 5};
+ auto a = make_patterned_array(world, TA::TiledRange{tr_b, tr_i, none}, 1.0);
+ auto b = make_patterned_array(world, TA::TiledRange{tr_b, none, tr_k}, 2.0);
+
+ TA::TArrayD c;
+ BOOST_REQUIRE_NO_THROW(c("b,i,k") = a("b,i,j") * b("b,j,k"));
+ BOOST_CHECK_EQUAL(c.trange().tiles_range().volume(), 8ul); // 2 * 2 * 2
+ BOOST_CHECK_SMALL(c("b,i,k").norm().get(), 1e-10);
+}
+
BOOST_AUTO_TEST_CASE(expression_general_product_tot_inner_outer_product) {
// the PNO-CC PPL building-block shape: ToT x ToT with an EMPTY right
// outer-external set and an inner OUTER-product: