From 40612fa727fab4497c7b5fdfbf7ac2cfc15f0e06 Mon Sep 17 00:00:00 2001
From: Sam Yates <yates@cscs.ch>
Date: Thu, 5 Jul 2018 08:47:18 +0200
Subject: [PATCH] Feature/lib install target part 3 (#518)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

This time we're moving `recipe.hpp` and `simulation.hpp`, plus the requirements they bring.

Code changes:
* Pimplize `simulation`.
* Consolidate arbor exceptions: all non-cell kind specific exceptions that might be expected to reach user code now have consistent messages and fit in an exception hierarchy based at `arb::arbor_exception`. Internal errors throw an `arb::arbor_internal_error` exception.
* Renamed `postsynaptic_spike_event` to `spike_event`. (Note: `pse_vector` name is unchanged.)
* Repurposed `pprintf` and moved it into `strprintf.h` — further consolidation is a TODO.
* Made a generic `util::to_string` to avoid redundancy of `operator<<` overloads and other `to_string` definitions. Defaults to ADL `to_string`, `std::to_string`, and finally tries using `operator<<`.
---
 arbor/CMakeLists.txt                          |   2 +
 arbor/arbexcept.cpp                           |  74 +++++
 arbor/backends.hpp                            |  22 --
 arbor/backends/gpu/mechanism.cpp              |  11 +-
 arbor/backends/gpu/multi_event_stream.hpp     |   4 +-
 arbor/backends/gpu/threshold_watcher.hpp      |   3 +-
 arbor/backends/multicore/mechanism.cpp        |  10 +-
 .../backends/multicore/multi_event_stream.hpp |   5 +-
 arbor/benchmark_cell_group.cpp                |   8 +-
 arbor/benchmark_cell_group.hpp                |   7 +-
 arbor/cell_group.hpp                          |   6 +-
 arbor/cell_group_factory.cpp                  |  22 +-
 arbor/cell_group_factory.hpp                  |   8 +-
 arbor/common_types_io.cpp                     |  18 +-
 arbor/communication/communicator.hpp          |   4 +-
 arbor/connection.hpp                          |   2 +-
 arbor/event_binner.cpp                        |   3 +-
 arbor/event_binner.hpp                        |   6 -
 arbor/event_queue.hpp                         |  32 +--
 arbor/fvm_layout.cpp                          |  16 +-
 arbor/fvm_lowered_cell.hpp                    |  12 +-
 arbor/fvm_lowered_cell_impl.cpp               |   6 +-
 arbor/fvm_lowered_cell_impl.hpp               |  33 ++-
 arbor/lif_cell_group.cpp                      |   1 +
 arbor/lif_cell_group.hpp                      |   7 +-
 arbor/load_balance.hpp                        |   6 +-
 arbor/mc_cell.cpp                             |  12 +-
 arbor/mc_cell_group.cpp                       |   2 +-
 arbor/mc_cell_group.hpp                       |   4 +-
 arbor/mechcat.cpp                             |  21 +-
 arbor/merge_events.cpp                        |  13 +-
 arbor/merge_events.hpp                        |  15 +-
 arbor/partition_load_balance.cpp              |   5 +-
 arbor/sampler_map.hpp                         |   2 +-
 arbor/schedule.cpp                            |   3 +-
 arbor/simulation.cpp                          | 258 ++++++++++++------
 arbor/simulation.hpp                          | 104 -------
 arbor/spike_event_io.cpp                      |  11 +
 arbor/spike_source_cell_group.cpp             |   4 +-
 arbor/spike_source_cell_group.hpp             |   8 +-
 arbor/threading/threading.cpp                 |   6 +-
 arbor/util/pprintf.hpp                        |  49 ----
 arbor/util/strprintf.hpp                      |  68 ++++-
 example/bench/bench.cpp                       |  11 +-
 example/bench/recipe.hpp                      |   3 +-
 example/brunel/brunel_miniapp.cpp             |   6 +-
 example/brunel/partitioner.hpp                |   9 +-
 example/generators/event_gen.cpp              |   6 +-
 example/miniapp/miniapp.cpp                   |   8 +-
 example/miniapp/miniapp_recipes.cpp           |   6 +-
 example/miniapp/miniapp_recipes.hpp           |   3 +-
 include/arbor/arbexcept.hpp                   |  98 +++++++
 include/arbor/common_types.hpp                |  22 +-
 .../arbor}/domain_decomposition.hpp           |  14 +-
 {arbor => include/arbor}/event_generator.hpp  |  46 ++--
 {arbor => include/arbor}/generic_event.hpp    |   0
 include/arbor/mc_cell.hpp                     |  20 +-
 {arbor => include/arbor}/recipe.hpp           |  14 +-
 {arbor => include/arbor}/schedule.hpp         |  15 +-
 include/arbor/simulation.hpp                  |  63 +++++
 include/arbor/spike_event.hpp                 |  31 +++
 include/arbor/util/compat.hpp                 |   4 +-
 {arbor => include/arbor}/util/handle_set.hpp  |   0
 include/arbor/util/uninitialized.hpp          |   8 +-
 test/common_cells.hpp                         |   3 +-
 test/simple_recipes.hpp                       |   5 +-
 test/ubench/event_binning.cpp                 |  14 +-
 test/ubench/event_setup.cpp                   |  16 +-
 test/unit-distributed/test_communicator.cpp   |  17 +-
 test/unit/test_backend.cpp                    |   2 +-
 test/unit/test_domain_decomposition.cpp       |   3 +-
 test/unit/test_event_generators.cpp           |  12 +-
 test/unit/test_event_queue.cpp                |  10 +-
 test/unit/test_fvm_lowered.cpp                |   6 +-
 test/unit/test_lif_cell_group.cpp             |   8 +-
 test/unit/test_mc_cell_group.cpp              |   1 -
 test/unit/test_mechcat.cpp                    |   5 +-
 test/unit/test_schedule.cpp                   |   2 +-
 test/unit/test_time_seq.cpp                   |   5 +-
 test/validation/convergence_test.hpp          |   4 +-
 test/validation/validate_ball_and_stick.cpp   |   7 +-
 .../validate_compartment_policy.cpp           |   6 +-
 test/validation/validate_kinetic.cpp          |  15 +-
 test/validation/validate_soma.cpp             |  15 +-
 test/validation/validate_synapses.cpp         |   9 +-
 85 files changed, 886 insertions(+), 579 deletions(-)
 create mode 100644 arbor/arbexcept.cpp
 delete mode 100644 arbor/backends.hpp
 delete mode 100644 arbor/simulation.hpp
 create mode 100644 arbor/spike_event_io.cpp
 delete mode 100644 arbor/util/pprintf.hpp
 create mode 100644 include/arbor/arbexcept.hpp
 rename {arbor => include/arbor}/domain_decomposition.hpp (85%)
 rename {arbor => include/arbor}/event_generator.hpp (84%)
 rename {arbor => include/arbor}/generic_event.hpp (100%)
 rename {arbor => include/arbor}/recipe.hpp (88%)
 rename {arbor => include/arbor}/schedule.hpp (92%)
 create mode 100644 include/arbor/simulation.hpp
 create mode 100644 include/arbor/spike_event.hpp
 rename {arbor => include/arbor}/util/handle_set.hpp (100%)

diff --git a/arbor/CMakeLists.txt b/arbor/CMakeLists.txt
index 8208a98e..4ea56fd8 100644
--- a/arbor/CMakeLists.txt
+++ b/arbor/CMakeLists.txt
@@ -1,6 +1,7 @@
 # Sources:
 
 set(arbor_sources
+    arbexcept.cpp
     assert.cpp
     backends/multicore/mechanism.cpp
     backends/multicore/shared_state.cpp
@@ -35,6 +36,7 @@ set(arbor_sources
     profile/power_meter.cpp
     profile/profiler.cpp
     schedule.cpp
+    spike_event_io.cpp
     spike_source_cell_group.cpp
     swcio.cpp
     threadinfo.cpp
diff --git a/arbor/arbexcept.cpp b/arbor/arbexcept.cpp
new file mode 100644
index 00000000..a8b589b7
--- /dev/null
+++ b/arbor/arbexcept.cpp
@@ -0,0 +1,74 @@
+#include <string>
+#include <sstream>
+
+#include <arbor/arbexcept.hpp>
+#include <arbor/common_types.hpp>
+
+#include "util/strprintf.hpp"
+
+namespace arb {
+
+using arb::util::pprintf;
+
+bad_cell_description::bad_cell_description(cell_kind kind, cell_gid_type gid):
+    arbor_exception(pprintf("bad description for cell kind {} on gid {}", kind, gid)),
+    gid(gid),
+    kind(kind)
+{}
+
+bad_global_property::bad_global_property(cell_kind kind):
+    arbor_exception(pprintf("bad global property for cell kind {}", kind)),
+    kind(kind)
+{}
+
+bad_probe_id::bad_probe_id(cell_member_type probe_id):
+    arbor_exception(pprintf("bad probe id {}", probe_id)),
+    probe_id(probe_id)
+{}
+
+bad_event_time::bad_event_time(time_type event_time, time_type sim_time):
+    arbor_exception(pprintf("event time {} precedes current simulation time {}", event_time, sim_time)),
+    event_time(event_time),
+    sim_time(sim_time)
+{}
+
+no_such_mechanism::no_such_mechanism(const std::string& mech_name):
+    arbor_exception(pprintf("no mechanism {} in catalogue", mech_name)),
+    mech_name(mech_name)
+{}
+
+duplicate_mechanism::duplicate_mechanism(const std::string& mech_name):
+    arbor_exception(pprintf("mechanism {} already exists", mech_name)),
+    mech_name(mech_name)
+{}
+
+fingerprint_mismatch::fingerprint_mismatch(const std::string& mech_name):
+    arbor_exception(pprintf("mechanism {} has different fingerprint in schema", mech_name)),
+    mech_name(mech_name)
+{}
+
+no_such_parameter::no_such_parameter(const std::string& mech_name, const std::string& param_name):
+    arbor_exception(pprintf("mechanism {} has no parameter {}", mech_name, param_name)),
+    mech_name(mech_name),
+    param_name(param_name)
+{}
+
+invalid_parameter_value::invalid_parameter_value(const std::string& mech_name, const std::string& param_name, double value):
+    arbor_exception(pprintf("invalid parameter value for mechanism {} parameter {}: {}", mech_name, param_name, value)),
+    mech_name(mech_name),
+    param_name(param_name),
+    value(value)
+{}
+
+no_such_implementation::no_such_implementation(const std::string& mech_name):
+    arbor_exception(pprintf("missing implementation for mechanism {} in catalogue", mech_name)),
+    mech_name(mech_name)
+{}
+
+range_check_failure::range_check_failure(const std::string& whatstr, double value):
+    arbor_exception(pprintf("range check failure: {} with value {}", whatstr, value)),
+    value(value)
+{}
+
+} // namespace arb
+
diff --git a/arbor/backends.hpp b/arbor/backends.hpp
deleted file mode 100644
index e45f96e0..00000000
--- a/arbor/backends.hpp
+++ /dev/null
@@ -1,22 +0,0 @@
-#pragma once
-
-#include <string>
-
-namespace arb {
-
-enum class backend_kind {
-    multicore,   //  use multicore backend for all computation
-    gpu          //  use gpu back end when supported by cell_group type
-};
-
-inline std::string to_string(backend_kind p) {
-    switch (p) {
-        case backend_kind::multicore:
-            return "multicore";
-        case backend_kind::gpu:
-            return "gpu";
-    }
-    return "unknown";
-}
-
-} // namespace arb
diff --git a/arbor/backends/gpu/mechanism.cpp b/arbor/backends/gpu/mechanism.cpp
index 8e6d7bae..cb57c95a 100644
--- a/arbor/backends/gpu/mechanism.cpp
+++ b/arbor/backends/gpu/mechanism.cpp
@@ -5,6 +5,7 @@
 #include <utility>
 #include <vector>
 
+#include <arbor/arbexcept.hpp>
 #include <arbor/common_types.hpp>
 #include <arbor/fvm_types.hpp>
 #include <arbor/mechanism.hpp>
@@ -81,7 +82,7 @@ void mechanism::instantiate(unsigned id,
     for (auto i: ion_state_tbl) {
         util::optional<ion_state&> oion = value_by_key(shared.ion_data, i.first);
         if (!oion) {
-            throw std::logic_error("mechanism holds ion with no corresponding shared state");
+            throw arbor_internal_error("gpu/mechanism: mechanism holds ion with no corresponding shared state");
         }
 
         ion_state_view& ion_view = *i.second;
@@ -132,7 +133,7 @@ void mechanism::instantiate(unsigned id,
     for (auto i: make_span(0, num_ions_)) {
         util::optional<ion_state&> oion = value_by_key(shared.ion_data, ion_index_tbl[i].first);
         if (!oion) {
-            throw std::logic_error("mechanism holds ion with no corresponding shared state");
+            throw arbor_internal_error("gpu/mechanism: mechanism holds ion with no corresponding shared state");
         }
 
         auto ni = memory::on_host(oion->node_index_);
@@ -150,7 +151,7 @@ void mechanism::instantiate(unsigned id,
 void mechanism::set_parameter(const std::string& key, const std::vector<fvm_value_type>& values) {
     if (auto opt_ptr = value_by_key(field_table(), key)) {
         if (values.size()!=width_) {
-            throw std::logic_error("internal error: mechanism parameter size mismatch");
+            throw arbor_internal_error("gpu/mechanism: mechanism parameter size mismatch");
         }
 
         if (width_>0) {
@@ -160,7 +161,7 @@ void mechanism::set_parameter(const std::string& key, const std::vector<fvm_valu
         }
     }
     else {
-        throw std::logic_error("internal error: no such mechanism parameter");
+        throw arbor_internal_error("gpu/mechanism: no such mechanism parameter");
     }
 }
 
@@ -171,7 +172,7 @@ void mechanism::set_global(const std::string& key, fvm_value_type value) {
         global = value;
     }
     else {
-        throw std::logic_error("internal error: no such mechanism global");
+        throw arbor_internal_error("gpu/mechanism: no such mechanism global");
     }
 }
 
diff --git a/arbor/backends/gpu/multi_event_stream.hpp b/arbor/backends/gpu/multi_event_stream.hpp
index 4498b87c..b03e374c 100644
--- a/arbor/backends/gpu/multi_event_stream.hpp
+++ b/arbor/backends/gpu/multi_event_stream.hpp
@@ -4,10 +4,10 @@
 
 #include <arbor/common_types.hpp>
 #include <arbor/fvm_types.hpp>
+#include <arbor/generic_event.hpp>
 
 #include "backends/event.hpp"
 #include "backends/multi_event_stream_state.hpp"
-#include "generic_event.hpp"
 #include "memory/array.hpp"
 #include "memory/copy.hpp"
 #include "profile/profiler_macro.hpp"
@@ -68,7 +68,7 @@ protected:
         using ::arb::event_index;
 
         if (staged.size()>std::numeric_limits<size_type>::max()) {
-            throw std::range_error("too many events");
+            throw arbor_internal_error("gpu/multi_event_stream: too many events for size type");
         }
 
         arb_assert(util::is_sorted_by(staged, [](const Event& ev) { return event_index(ev); }));
diff --git a/arbor/backends/gpu/threshold_watcher.hpp b/arbor/backends/gpu/threshold_watcher.hpp
index 09f56275..7cc62f8a 100644
--- a/arbor/backends/gpu/threshold_watcher.hpp
+++ b/arbor/backends/gpu/threshold_watcher.hpp
@@ -1,5 +1,6 @@
 #pragma once
 
+#include <arbor/arbexcept.hpp>
 #include <arbor/common_types.hpp>
 #include <arbor/fvm_types.hpp>
 
@@ -88,7 +89,7 @@ public:
         stack_.host_access();
 
         if (stack_.overflow()) {
-            throw std::runtime_error("GPU spike buffer overflow.");
+            throw arbor_internal_error("gpu/threshold_watcher: gpu spike buffer overflow");
         }
 
         crossings_.clear();
diff --git a/arbor/backends/multicore/mechanism.cpp b/arbor/backends/multicore/mechanism.cpp
index 3fbb4fb1..a5b22f3e 100644
--- a/arbor/backends/multicore/mechanism.cpp
+++ b/arbor/backends/multicore/mechanism.cpp
@@ -83,7 +83,7 @@ void mechanism::instantiate(unsigned id, backend::shared_state& shared, const la
     for (auto i: ion_state_tbl) {
         util::optional<ion_state&> oion = value_by_key(shared.ion_data, i.first);
         if (!oion) {
-            throw std::logic_error("mechanism holds ion with no corresponding shared state");
+            throw arbor_internal_error("multicore/mechanism: mechanism holds ion with no corresponding shared state");
         }
 
         ion_state_view& ion_view = *i.second;
@@ -136,7 +136,7 @@ void mechanism::instantiate(unsigned id, backend::shared_state& shared, const la
     for (auto i: ion_index_table()) {
         util::optional<ion_state&> oion = value_by_key(shared.ion_data, i.first);
         if (!oion) {
-            throw std::logic_error("mechanism holds ion with no corresponding shared state");
+            throw arbor_internal_error("multicore/mechanism: mechanism holds ion with no corresponding shared state");
         }
 
         auto indices = util::index_into(node_index_, oion->node_index_);
@@ -154,7 +154,7 @@ void mechanism::instantiate(unsigned id, backend::shared_state& shared, const la
 void mechanism::set_parameter(const std::string& key, const std::vector<fvm_value_type>& values) {
     if (auto opt_ptr = value_by_key(field_table(), key)) {
         if (values.size()!=width_) {
-            throw std::logic_error("internal error: mechanism parameter size mismatch");
+            throw arbor_internal_error("multicore/mechanism: mechanism parameter size mismatch");
         }
 
         if (width_>0) {
@@ -166,7 +166,7 @@ void mechanism::set_parameter(const std::string& key, const std::vector<fvm_valu
         }
     }
     else {
-        throw std::logic_error("internal error: no such mechanism parameter");
+        throw arbor_internal_error("multicore/mechanism: no such mechanism parameter");
     }
 }
 
@@ -177,7 +177,7 @@ void mechanism::set_global(const std::string& key, fvm_value_type value) {
         global = value;
     }
     else {
-        throw std::logic_error("internal error: no such mechanism global");
+        throw arbor_internal_error("multicore/mechanism: no such mechanism global");
     }
 }
 
diff --git a/arbor/backends/multicore/multi_event_stream.hpp b/arbor/backends/multicore/multi_event_stream.hpp
index 2a761b8c..aa0bea06 100644
--- a/arbor/backends/multicore/multi_event_stream.hpp
+++ b/arbor/backends/multicore/multi_event_stream.hpp
@@ -7,11 +7,12 @@
 #include <utility>
 
 #include <arbor/assert.hpp>
+#include <arbor/arbexcept.hpp>
 #include <arbor/fvm_types.hpp>
+#include <arbor/generic_event.hpp>
 
 #include "backends/event.hpp"
 #include "backends/multi_event_stream_state.hpp"
-#include "generic_event.hpp"
 #include "algorithms.hpp"
 #include "util/range.hpp"
 #include "util/rangeutil.hpp"
@@ -58,7 +59,7 @@ public:
         using ::arb::event_data;
 
         if (staged.size()>std::numeric_limits<size_type>::max()) {
-            throw std::range_error("too many events");
+            throw arbor_internal_error("multicore/multi_event_stream: too many events for size type");
         }
 
         // Sort by index (staged events should already be time-sorted).
diff --git a/arbor/benchmark_cell_group.cpp b/arbor/benchmark_cell_group.cpp
index 6466d7c1..0556045d 100644
--- a/arbor/benchmark_cell_group.cpp
+++ b/arbor/benchmark_cell_group.cpp
@@ -2,12 +2,12 @@
 #include <exception>
 
 #include <arbor/benchmark_cell.hpp>
+#include <arbor/recipe.hpp>
 #include <arbor/time_sequence.hpp>
 
-#include <cell_group.hpp>
-#include <profile/profiler_macro.hpp>
-#include <recipe.hpp>
-#include <benchmark_cell_group.hpp>
+#include "cell_group.hpp"
+#include "profile/profiler_macro.hpp"
+#include "benchmark_cell_group.hpp"
 
 #include "util/span.hpp"
 
diff --git a/arbor/benchmark_cell_group.hpp b/arbor/benchmark_cell_group.hpp
index 146baf7f..02fee8d5 100644
--- a/arbor/benchmark_cell_group.hpp
+++ b/arbor/benchmark_cell_group.hpp
@@ -1,10 +1,13 @@
 #pragma once
 
 #include <arbor/benchmark_cell.hpp>
-#include <arbor/time_sequence.hpp>
+#include <arbor/common_types.hpp>
+#include <arbor/recipe.hpp>
+#include <arbor/sampling.hpp>
+#include <arbor/spike.hpp>
 
 #include "cell_group.hpp"
-#include "recipe.hpp"
+#include "epoch.hpp"
 
 namespace arb {
 
diff --git a/arbor/cell_group.hpp b/arbor/cell_group.hpp
index ed53924f..98cf20ab 100644
--- a/arbor/cell_group.hpp
+++ b/arbor/cell_group.hpp
@@ -6,15 +6,19 @@
 
 #include <arbor/common_types.hpp>
 #include <arbor/sampling.hpp>
+#include <arbor/schedule.hpp>
 #include <arbor/spike.hpp>
+#include <arbor/spike_event.hpp>
 
 #include "epoch.hpp"
 #include "event_binner.hpp"
 #include "event_queue.hpp"
-#include "schedule.hpp"
+#include "util/rangeutil.hpp"
 
 namespace arb {
 
+using event_lane_subrange = util::subrange_view_type<std::vector<pse_vector>>;
+
 class cell_group {
 public:
     virtual ~cell_group() = default;
diff --git a/arbor/cell_group_factory.cpp b/arbor/cell_group_factory.cpp
index 96079522..54e343ea 100644
--- a/arbor/cell_group_factory.cpp
+++ b/arbor/cell_group_factory.cpp
@@ -1,14 +1,16 @@
 #include <vector>
 
-#include <backends.hpp>
-#include <benchmark_cell_group.hpp>
-#include <cell_group.hpp>
-#include <domain_decomposition.hpp>
-#include <fvm_lowered_cell.hpp>
-#include <lif_cell_group.hpp>
-#include <mc_cell_group.hpp>
-#include <recipe.hpp>
-#include <spike_source_cell_group.hpp>
+#include <arbor/arbexcept.hpp>
+#include <arbor/common_types.hpp>
+#include <arbor/domain_decomposition.hpp>
+#include <arbor/recipe.hpp>
+
+#include "benchmark_cell_group.hpp"
+#include "cell_group.hpp"
+#include "fvm_lowered_cell.hpp"
+#include "lif_cell_group.hpp"
+#include "mc_cell_group.hpp"
+#include "spike_source_cell_group.hpp"
 
 namespace arb {
 
@@ -27,7 +29,7 @@ cell_group_ptr cell_group_factory(const recipe& rec, const group_description& gr
         return make_cell_group<benchmark_cell_group>(group.gids, rec);
 
     default:
-        throw std::runtime_error("unknown cell kind");
+        throw arbor_internal_error("cell_group_factory: unknown cell kind");
     }
 }
 
diff --git a/arbor/cell_group_factory.hpp b/arbor/cell_group_factory.hpp
index 88770475..320e4ff5 100644
--- a/arbor/cell_group_factory.hpp
+++ b/arbor/cell_group_factory.hpp
@@ -1,13 +1,9 @@
 #pragma once
 
-#include <vector>
+#include <arbor/domain_decomposition.hpp>
+#include <arbor/recipe.hpp>
 
-#include <arbor/util/unique_any.hpp>
-
-#include "backends.hpp"
 #include "cell_group.hpp"
-#include "domain_decomposition.hpp"
-#include "recipe.hpp"
 
 namespace arb {
 
diff --git a/arbor/common_types_io.cpp b/arbor/common_types_io.cpp
index 2e46ddd7..c5b90478 100644
--- a/arbor/common_types_io.cpp
+++ b/arbor/common_types_io.cpp
@@ -2,8 +2,10 @@
 
 #include <arbor/common_types.hpp>
 
-std::ostream& operator<<(std::ostream& O, arb::cell_member_type m) {
-    return O << m.gid << ':' << m.index;
+namespace arb {
+
+std::ostream& operator<<(std::ostream& o, arb::cell_member_type m) {
+    return o << m.gid << ':' << m.index;
 }
 
 std::ostream& operator<<(std::ostream& o, arb::cell_kind k) {
@@ -21,3 +23,15 @@ std::ostream& operator<<(std::ostream& o, arb::cell_kind k) {
     return o;
 }
 
+std::ostream& operator<<(std::ostream& o, arb::backend_kind k) {
+    o << "backend_kind::";
+    switch (k) {
+    case arb::backend_kind::multicore:
+        return o << "multicore";
+    case arb::backend_kind::gpu:
+        return o << "gpu";
+    }
+    return o;
+}
+
+}
diff --git a/arbor/communication/communicator.hpp b/arbor/communication/communicator.hpp
index d9a6f759..78aa7e4c 100644
--- a/arbor/communication/communicator.hpp
+++ b/arbor/communication/communicator.hpp
@@ -11,14 +11,14 @@
 #include <arbor/common_types.hpp>
 #include <arbor/communication/gathered_vector.hpp>
 #include <arbor/distributed_context.hpp>
+#include <arbor/domain_decomposition.hpp>
+#include <arbor/recipe.hpp>
 #include <arbor/spike.hpp>
 
 #include "algorithms.hpp"
 #include "connection.hpp"
-#include "domain_decomposition.hpp"
 #include "event_queue.hpp"
 #include "profile/profiler_macro.hpp"
-#include "recipe.hpp"
 #include "threading/threading.hpp"
 #include "util/double_buffer.hpp"
 #include "util/partition.hpp"
diff --git a/arbor/connection.hpp b/arbor/connection.hpp
index 40d19287..e4fbf150 100644
--- a/arbor/connection.hpp
+++ b/arbor/connection.hpp
@@ -31,7 +31,7 @@ public:
     cell_member_type destination() const { return destination_; }
     cell_size_type index_on_domain() const { return index_on_domain_; }
 
-    postsynaptic_spike_event make_event(const spike& s) {
+    spike_event make_event(const spike& s) {
         return {destination_, s.time + delay_, weight_};
     }
 
diff --git a/arbor/event_binner.cpp b/arbor/event_binner.cpp
index 7fcdbbac..6301a349 100644
--- a/arbor/event_binner.cpp
+++ b/arbor/event_binner.cpp
@@ -4,6 +4,7 @@
 #include <stdexcept>
 #include <unordered_map>
 
+#include <arbor/arbexcept.hpp>
 #include <arbor/common_types.hpp>
 #include <arbor/spike.hpp>
 #include <arbor/util/optional.hpp>
@@ -36,7 +37,7 @@ time_type event_binner::bin(time_type t, time_type t_min) {
         last_event_time_ = t_binned;
         break;
     default:
-        throw std::logic_error("unrecognized binning policy");
+        throw arbor_internal_error("event_binner: unrecognized binning policy");
     }
 
     return std::max(t_binned, t_min);
diff --git a/arbor/event_binner.hpp b/arbor/event_binner.hpp
index 25ff4f0b..c1bcd72c 100644
--- a/arbor/event_binner.hpp
+++ b/arbor/event_binner.hpp
@@ -9,12 +9,6 @@
 
 namespace arb {
 
-enum class binning_kind {
-    none,
-    regular,   // => round time down to multiple of binning interval.
-    following, // => round times down to previous event if within binning interval.
-};
-
 class event_binner {
 public:
     event_binner(): policy_(binning_kind::none), bin_interval_(0) {}
diff --git a/arbor/event_queue.hpp b/arbor/event_queue.hpp
index 0a5a24d9..b785acd0 100644
--- a/arbor/event_queue.hpp
+++ b/arbor/event_queue.hpp
@@ -8,13 +8,9 @@
 #include <utility>
 
 #include <arbor/common_types.hpp>
+#include <arbor/spike_event.hpp>
 #include <arbor/util/optional.hpp>
-
-#include "generic_event.hpp"
-#include "util/meta.hpp"
-#include "util/range.hpp"
-#include "util/rangeutil.hpp"
-#include "util/strprintf.hpp"
+#include <arbor/generic_event.hpp>
 
 namespace arb {
 
@@ -25,31 +21,9 @@ namespace arb {
  * Time values must be well ordered with respect to `operator>`.
  */
 
-struct postsynaptic_spike_event {
-    cell_member_type target;
-    time_type time;
-    float weight;
-
-    friend bool operator==(const postsynaptic_spike_event& l, const postsynaptic_spike_event& r) {
-        return l.target==r.target && l.time==r.time && l.weight==r.weight;
-    }
-
-    friend bool operator<(const postsynaptic_spike_event& l, const postsynaptic_spike_event& r) {
-        return std::tie(l.time, l.target, l.weight) < std::tie(r.time, r.target, r.weight);
-    }
-
-    friend std::ostream& operator<<(std::ostream& o, const arb::postsynaptic_spike_event& e)
-    {
-        return o << "E[tgt " << e.target << ", t " << e.time << ", w " << e.weight << "]";
-    }
-};
-
-using pse_vector = std::vector<postsynaptic_spike_event>;
-using event_lane_subrange = util::subrange_view_type<std::vector<pse_vector>>;
-
 template <typename Event>
 class event_queue {
-public :
+public:
     using value_type = Event;
     using event_time_type = ::arb::event_time_type<Event>;
 
diff --git a/arbor/fvm_layout.cpp b/arbor/fvm_layout.cpp
index d634fc71..6f3d1ee9 100644
--- a/arbor/fvm_layout.cpp
+++ b/arbor/fvm_layout.cpp
@@ -3,6 +3,7 @@
 #include <unordered_set>
 #include <vector>
 
+#include <arbor/arbexcept.hpp>
 #include <arbor/mc_cell.hpp>
 #include <arbor/util/enumhash.hpp>
 
@@ -156,16 +157,16 @@ fvm_discretization fvm_discretize(const std::vector<mc_cell>& cells) {
 
         const auto nseg = seg_comp_part.size();
         if (nseg==0) {
-            throw std::invalid_argument("cannot discretrize cell with no segments");
+            throw arbor_internal_error("fvm_layout: cannot discretrize cell with no segments");
         }
 
         // Handle soma (first segment and root of tree) specifically.
         const auto soma = c.segment(0)->as_soma();
         if (!soma) {
-            throw std::logic_error("First segment of cell must be soma");
+            throw arbor_internal_error("fvm_layout: first segment of cell must be soma");
         }
         else if (soma->num_compartments()!=1) {
-            throw std::logic_error("Soma must have exactly one compartment");
+            throw arbor_internal_error("fvm_layout: soma must have exactly one compartment");
         }
 
         segment_info soma_info;
@@ -190,7 +191,7 @@ fvm_discretization fvm_discretize(const std::vector<mc_cell>& cells) {
 
             const auto cable = c.segment(j)->as_cable();
             if (!cable) {
-                throw std::logic_error("Non-root segments of cell must be cable segments");
+                throw arbor_internal_error("fvm_layout: non-root segments of cell must be cable segments");
             }
             auto cm = cable->cm;    // [F/m²]
             auto rL = cable->rL;    // [Ω·cm]
@@ -309,18 +310,15 @@ fvm_mechanism_data fvm_build_mechanism_data(const mechanism_catalogue& catalogue
     {
         auto& name = desc.name();
         if (!info) {
-            if (!catalogue.has(name)) {
-                throw std::out_of_range("No mechanism "+name+" in mechanism catalogue");
-            }
             info = &catalogue[name];
         }
         for (const auto& pv: desc.values()) {
             if (!paramset.count(pv.first)) {
                 if (!info->parameters.count(pv.first)) {
-                    throw std::out_of_range("Mechanism "+name+" has no parameter "+pv.first);
+                    throw no_such_parameter(name, pv.first);
                 }
                 if (!info->parameters.at(pv.first).valid(pv.second)) {
-                    throw std::out_of_range("Value out of range for mechanism "+name+" parameter "+pv.first);
+                    throw invalid_parameter_value(name, pv.first, pv.second);
                 }
                 paramset.insert(pv.first);
             }
diff --git a/arbor/fvm_lowered_cell.hpp b/arbor/fvm_lowered_cell.hpp
index 3b22c5ff..81a26887 100644
--- a/arbor/fvm_lowered_cell.hpp
+++ b/arbor/fvm_lowered_cell.hpp
@@ -3,14 +3,14 @@
 #include <memory>
 #include <vector>
 
+#include <arbor/common_types.hpp>
 #include <arbor/fvm_types.hpp>
+#include <arbor/recipe.hpp>
 
-#include <backends.hpp>
-#include <backends/event.hpp>
-#include <backends/threshold_crossing.hpp>
-#include <recipe.hpp>
-#include <sampler_map.hpp>
-#include <util/range.hpp>
+#include "backends/event.hpp"
+#include "backends/threshold_crossing.hpp"
+#include "sampler_map.hpp"
+#include "util/range.hpp"
 
 namespace arb {
 
diff --git a/arbor/fvm_lowered_cell_impl.cpp b/arbor/fvm_lowered_cell_impl.cpp
index fe317e93..1136f64e 100644
--- a/arbor/fvm_lowered_cell_impl.cpp
+++ b/arbor/fvm_lowered_cell_impl.cpp
@@ -1,7 +1,9 @@
 #include <memory>
 #include <stdexcept>
 
-#include "backends.hpp"
+#include <arbor/arbexcept.hpp>
+#include <arbor/common_types.hpp>
+
 #include "backends/multicore/fvm.hpp"
 #ifdef ARB_HAVE_GPU
 #include "backends/gpu/fvm.hpp"
@@ -20,7 +22,7 @@ fvm_lowered_cell_ptr make_fvm_lowered_cell(backend_kind p) {
 #endif
         ; // fall through
     default:
-        throw std::logic_error("unsupported back-end");
+        throw arbor_internal_error("fvm_lowered_cell: unsupported back-end");
     }
 }
 
diff --git a/arbor/fvm_lowered_cell_impl.hpp b/arbor/fvm_lowered_cell_impl.hpp
index f8a7f6b9..b07fcfea 100644
--- a/arbor/fvm_lowered_cell_impl.hpp
+++ b/arbor/fvm_lowered_cell_impl.hpp
@@ -16,18 +16,19 @@
 #include <arbor/assert.hpp>
 #include <arbor/common_types.hpp>
 #include <arbor/ion.hpp>
+#include <arbor/recipe.hpp>
 
 #include "builtin_mechanisms.hpp"
 #include "fvm_layout.hpp"
 #include "fvm_lowered_cell.hpp"
 #include "matrix.hpp"
 #include "profile/profiler_macro.hpp"
-#include "recipe.hpp"
 #include "sampler_map.hpp"
 #include "util/maputil.hpp"
 #include "util/meta.hpp"
 #include "util/range.hpp"
 #include "util/rangeutil.hpp"
+#include "util/strprintf.hpp"
 #include "util/transform.hpp"
 
 
@@ -113,10 +114,10 @@ template <typename Backend>
 void fvm_lowered_cell_impl<Backend>::assert_tmin() {
     auto time_minmax = state_->time_bounds();
     if (time_minmax.first != time_minmax.second) {
-        throw std::logic_error("inconsistent times across cells");
+        throw arbor_internal_error("fvm_lowered_cell: inconsistent times across cells");
     }
     if (time_minmax.first != tmin_) {
-        throw std::logic_error("out of synchronziation with cell state time");
+        throw arbor_internal_error("fvm_lowered_cell: out of synchronziation with cell state time");
     }
 }
 
@@ -276,8 +277,9 @@ void fvm_lowered_cell_impl<B>::assert_voltage_bounded(fvm_value_type bound) {
     }
 
     auto t_minmax = state_->time_bounds();
-    throw std::out_of_range("voltage solution out of bounds for t in ["+
-        std::to_string(t_minmax.first)+", "+std::to_string(t_minmax.second)+"]");
+    throw range_check_failure(
+        util::pprintf("voltage solution out of bounds for t in [{}, {}]", t_minmax.first, t_minmax.second),
+        v_minmax.first<-bound? v_minmax.first: v_minmax.second);
 }
 
 template <typename B>
@@ -298,11 +300,24 @@ void fvm_lowered_cell_impl<B>::initialize(
 
     cells.reserve(ncell);
     for (auto gid: gids) {
-        cells.push_back(any_cast<mc_cell>(rec.get_cell_description(gid)));
+        try {
+            cells.push_back(any_cast<mc_cell>(rec.get_cell_description(gid)));
+        }
+        catch (util::bad_any_cast&) {
+            throw bad_cell_description(rec.get_cell_kind(gid), gid);
+        }
     }
 
-    auto rec_props = rec.get_global_properties(cell_kind::cable1d_neuron);
-    auto global_props = rec_props.has_value()? any_cast<mc_cell_global_properties>(rec_props): mc_cell_global_properties{};
+    mc_cell_global_properties global_props;
+    try {
+        util::any rec_props = rec.get_global_properties(cell_kind::cable1d_neuron);
+        if (rec_props.has_value()) {
+            global_props = any_cast<mc_cell_global_properties>(rec_props);
+        }
+    }
+    catch (util::bad_any_cast&) {
+        throw bad_global_property(cell_kind::cable1d_neuron);
+    }
 
     const mechanism_catalogue* catalogue = global_props.catalogue;
     initial_voltage_ = global_props.init_membrane_potential_mV;
@@ -423,7 +438,7 @@ void fvm_lowered_cell_impl<B>::initialize(
                 handle = state_->current_density.data()+cv;
                 break;
             default:
-                throw std::logic_error("unrecognized probeKind");
+                throw arbor_internal_error("fvm_lowered_cell: unrecognized probeKind");
             }
 
             probe_map.insert({pi.id, {handle, pi.tag}});
diff --git a/arbor/lif_cell_group.cpp b/arbor/lif_cell_group.cpp
index 70f51250..7cce6cc5 100644
--- a/arbor/lif_cell_group.cpp
+++ b/arbor/lif_cell_group.cpp
@@ -1,5 +1,6 @@
 #include <lif_cell_group.hpp>
 
+#include "profile/profiler_macro.hpp"
 #include "util/span.hpp"
 
 using namespace arb;
diff --git a/arbor/lif_cell_group.hpp b/arbor/lif_cell_group.hpp
index d81aaf3b..6ed49d39 100644
--- a/arbor/lif_cell_group.hpp
+++ b/arbor/lif_cell_group.hpp
@@ -2,12 +2,13 @@
 
 #include <vector>
 
+#include <arbor/common_types.hpp>
 #include <arbor/lif_cell.hpp>
+#include <arbor/recipe.hpp>
+#include <arbor/sampling.hpp>
+#include <arbor/spike.hpp>
 
 #include "cell_group.hpp"
-#include "event_queue.hpp"
-#include "profile/profiler_macro.hpp"
-#include "recipe.hpp"
 
 namespace arb {
 
diff --git a/arbor/load_balance.hpp b/arbor/load_balance.hpp
index 0dbb0cf4..757f5a31 100644
--- a/arbor/load_balance.hpp
+++ b/arbor/load_balance.hpp
@@ -1,8 +1,10 @@
+#pragma once
+
 #include <arbor/distributed_context.hpp>
+#include <arbor/domain_decomposition.hpp>
+#include <arbor/recipe.hpp>
 
-#include "domain_decomposition.hpp"
 #include "hardware/node_info.hpp"
-#include "recipe.hpp"
 
 namespace arb {
 
diff --git a/arbor/mc_cell.cpp b/arbor/mc_cell.cpp
index 2184134c..5807afd0 100644
--- a/arbor/mc_cell.cpp
+++ b/arbor/mc_cell.cpp
@@ -18,7 +18,7 @@ mc_cell::mc_cell() {
 
 void mc_cell::assert_valid_segment(index_type i) const {
     if (i>=num_segments()) {
-        throw std::out_of_range("no such segment");
+        throw mc_cell_error("no such segment");
     }
 }
 
@@ -32,7 +32,7 @@ size_type mc_cell::num_segments() const {
 //
 soma_segment* mc_cell::add_soma(value_type radius, point_type center) {
     if (has_soma()) {
-        throw std::runtime_error("cell already has soma");
+        throw mc_cell_error("cell already has soma");
     }
     segments_[0] = make_segment<soma_segment>(radius, center);
     return segments_[0]->as_soma();
@@ -40,11 +40,11 @@ soma_segment* mc_cell::add_soma(value_type radius, point_type center) {
 
 cable_segment* mc_cell::add_cable(index_type parent, mc_segment_ptr&& cable) {
     if (!cable->as_cable()) {
-        throw std::invalid_argument("segment is not a cable segment");
+        throw mc_cell_error("segment is not a cable segment");
     }
 
     if (parent>num_segments()) {
-        throw std::out_of_range("parent index out of range");
+        throw mc_cell_error("parent index out of range");
     }
 
     segments_.push_back(std::move(cable));
@@ -78,7 +78,7 @@ const soma_segment* mc_cell::soma() const {
 cable_segment* mc_cell::cable(index_type index) {
     assert_valid_segment(index);
     auto cable = segment(index)->as_cable();
-    return cable? cable: throw std::runtime_error("segment is not a cable segment");
+    return cable? cable: throw mc_cell_error("segment is not a cable segment");
 }
 
 std::vector<size_type> mc_cell::compartment_counts() const {
@@ -127,7 +127,7 @@ mc_cell make_mc_cell(const morphology& morph, bool compartments_from_discretizat
             kind = section_kind::dendrite;
             break;
         case section_kind::soma:
-            throw std::invalid_argument("no support for complex somata");
+            throw mc_cell_error("no support for complex somata");
             break;
         default: ;
         }
diff --git a/arbor/mc_cell_group.cpp b/arbor/mc_cell_group.cpp
index b5e12cc2..e03ae795 100644
--- a/arbor/mc_cell_group.cpp
+++ b/arbor/mc_cell_group.cpp
@@ -5,6 +5,7 @@
 #include <arbor/assert.hpp>
 #include <arbor/common_types.hpp>
 #include <arbor/sampling.hpp>
+#include <arbor/recipe.hpp>
 #include <arbor/spike.hpp>
 
 #include "backends/event.hpp"
@@ -14,7 +15,6 @@
 #include "fvm_lowered_cell.hpp"
 #include "mc_cell_group.hpp"
 #include "profile/profiler_macro.hpp"
-#include "recipe.hpp"
 #include "sampler_map.hpp"
 #include "util/filter.hpp"
 #include "util/maputil.hpp"
diff --git a/arbor/mc_cell_group.hpp b/arbor/mc_cell_group.hpp
index 463e59d0..be9599d7 100644
--- a/arbor/mc_cell_group.hpp
+++ b/arbor/mc_cell_group.hpp
@@ -6,18 +6,18 @@
 #include <unordered_map>
 #include <vector>
 
-#include <arbor/assert.hpp>
 #include <arbor/common_types.hpp>
+#include <arbor/recipe.hpp>
 #include <arbor/sampling.hpp>
 #include <arbor/spike.hpp>
 
 #include "backends/event.hpp"
 #include "cell_group.hpp"
+#include "epoch.hpp"
 #include "event_binner.hpp"
 #include "event_queue.hpp"
 #include "fvm_lowered_cell.hpp"
 #include "profile/profiler_macro.hpp"
-#include "recipe.hpp"
 #include "sampler_map.hpp"
 #include "util/double_buffer.hpp"
 #include "util/filter.hpp"
diff --git a/arbor/mechcat.cpp b/arbor/mechcat.cpp
index 5ce86884..ed314a78 100644
--- a/arbor/mechcat.cpp
+++ b/arbor/mechcat.cpp
@@ -3,6 +3,7 @@
 #include <string>
 #include <vector>
 
+#include <arbor/arbexcept.hpp>
 #include <arbor/mechcat.hpp>
 #include <arbor/util/make_unique.hpp>
 
@@ -15,7 +16,7 @@ using util::make_unique;
 
 void mechanism_catalogue::add(const std::string& name, mechanism_info info) {
     if (has(name)) {
-        throw std::invalid_argument("mechanism '"+name+"' already exists in catalogue");
+        throw duplicate_mechanism(name);
     }
 
     info_map_[name] = mechanism_info_ptr(new mechanism_info(std::move(info)));
@@ -29,7 +30,7 @@ const mechanism_info& mechanism_catalogue::operator[](const std::string& name) c
         return *(p->get());
     }
 
-    throw std::invalid_argument("no mechanism with name +'"+name+"' in catalogue");
+    throw no_such_mechanism(name);
 }
 
 const mechanism_fingerprint& mechanism_catalogue::fingerprint(const std::string& name) const {
@@ -42,16 +43,16 @@ const mechanism_fingerprint& mechanism_catalogue::fingerprint(const std::string&
         return p.value()->fingerprint;
     }
 
-    throw std::invalid_argument("no mechanism with name +'"+name+"' in catalogue");
+    throw no_such_mechanism(name);
 }
 
 void mechanism_catalogue::derive(const std::string& name, const std::string& parent, const std::vector<std::pair<std::string, double>>& global_params) {
     if (has(name)) {
-        throw std::invalid_argument("mechanism with name '"+name+"' already exists in catalogue");
+        throw duplicate_mechanism(name);
     }
 
     if (!has(parent)) {
-        throw std::invalid_argument("no mechanism with name '"+parent+"' in catalogue");
+        throw no_such_mechanism(parent);
     }
 
     derivation deriv = {parent, {}, nullptr};
@@ -63,11 +64,11 @@ void mechanism_catalogue::derive(const std::string& name, const std::string& par
 
         if (auto p = value_by_key(info->globals, param)) {
             if (!p->valid(value)) {
-                throw std::invalid_argument("invalid value for parameter '"+param+"' in mechanism '"+name+"'");
+                throw invalid_parameter_value(name, param, value);
             }
         }
         else {
-            throw std::invalid_argument("mechanism '"+name+"' has no global parameter '"+param+"'");
+            throw no_such_parameter(name, param);
         }
 
         deriv.globals[param] = value;
@@ -80,7 +81,7 @@ void mechanism_catalogue::derive(const std::string& name, const std::string& par
 
 void mechanism_catalogue::remove(const std::string& name) {
     if (!has(name)) {
-        throw std::invalid_argument("no mechanism with name '"+name+"' in catalogue");
+        throw no_such_mechanism(name);
     }
 
     if (is_derived(name)) {
@@ -127,7 +128,7 @@ std::unique_ptr<mechanism> mechanism_catalogue::instance_impl(std::type_index ti
             impl_name = p->parent;
         }
         else {
-            throw std::invalid_argument("missing implementation for mechanism named '"+name+"'");
+            throw no_such_implementation(name);
         }
     }
 
@@ -153,7 +154,7 @@ void mechanism_catalogue::register_impl(std::type_index tidx, const std::string&
     const mechanism_info& info = (*this)[name];
 
     if (mech->fingerprint()!=info.fingerprint) {
-        throw std::invalid_argument("implementation fingerprint does not match schema");
+        throw fingerprint_mismatch(name);
     }
 
     impl_map_[name][tidx] = std::move(mech);
diff --git a/arbor/merge_events.cpp b/arbor/merge_events.cpp
index 4e5d8600..096dd169 100644
--- a/arbor/merge_events.cpp
+++ b/arbor/merge_events.cpp
@@ -2,12 +2,13 @@
 #include <set>
 #include <vector>
 
-#include "backends.hpp"
+#include <arbor/common_types.hpp>
+#include <arbor/domain_decomposition.hpp>
+#include <arbor/recipe.hpp>
+
 #include "cell_group.hpp"
 #include "cell_group_factory.hpp"
-#include "domain_decomposition.hpp"
 #include "merge_events.hpp"
-#include "recipe.hpp"
 #include "util/filter.hpp"
 #include "util/span.hpp"
 #include "profile/profiler_macro.hpp"
@@ -73,7 +74,7 @@ bool tourney_tree::empty(time_type t) const {
     return event(0).time >= t;
 }
 
-postsynaptic_spike_event tourney_tree::head() const {
+spike_event tourney_tree::head() const {
     return event(0);
 }
 
@@ -130,10 +131,10 @@ bool tourney_tree::is_leaf(unsigned i) const {
 const unsigned& tourney_tree::id(unsigned i) const {
     return heap_[i].first;
 }
-postsynaptic_spike_event& tourney_tree::event(unsigned i) {
+spike_event& tourney_tree::event(unsigned i) {
     return heap_[i].second;
 }
-const postsynaptic_spike_event& tourney_tree::event(unsigned i) const {
+const spike_event& tourney_tree::event(unsigned i) const {
     return heap_[i].second;
 }
 
diff --git a/arbor/merge_events.hpp b/arbor/merge_events.hpp
index 72c3918d..eb058fec 100644
--- a/arbor/merge_events.hpp
+++ b/arbor/merge_events.hpp
@@ -1,11 +1,12 @@
 #pragma once
 
-#include <algorithm>
 #include <iosfwd>
 #include <vector>
 
-#include <event_generator.hpp>
-#include <event_queue.hpp>
+#include <arbor/event_generator.hpp>
+#include <arbor/spike_event.hpp>
+
+#include "event_queue.hpp"
 #include "profile/profiler_macro.hpp"
 
 namespace arb {
@@ -49,13 +50,13 @@ namespace impl {
     // it is not intended for use elsewhere. It is exposed here for unit testing
     // of its functionality.
     class tourney_tree {
-        using key_val = std::pair<unsigned, postsynaptic_spike_event>;
+        using key_val = std::pair<unsigned, spike_event>;
 
     public:
         tourney_tree(std::vector<event_generator>& input);
         bool empty() const;
         bool empty(time_type t) const;
-        postsynaptic_spike_event head() const;
+        spike_event head() const;
         void pop();
         friend std::ostream& operator<<(std::ostream&, const tourney_tree&);
 
@@ -69,8 +70,8 @@ namespace impl {
         unsigned leaf(unsigned i) const;
         bool is_leaf(unsigned i) const;
         const unsigned& id(unsigned i) const;
-        postsynaptic_spike_event& event(unsigned i);
-        const postsynaptic_spike_event& event(unsigned i) const;
+        spike_event& event(unsigned i);
+        const spike_event& event(unsigned i) const;
         unsigned next_power_2(unsigned x) const;
 
         std::vector<key_val> heap_;
diff --git a/arbor/partition_load_balance.cpp b/arbor/partition_load_balance.cpp
index 062373de..c9a18ec2 100644
--- a/arbor/partition_load_balance.cpp
+++ b/arbor/partition_load_balance.cpp
@@ -1,9 +1,10 @@
 #include <arbor/distributed_context.hpp>
 #include <arbor/util/enumhash.hpp>
+#include <arbor/domain_decomposition.hpp>
+#include <arbor/recipe.hpp>
 
-#include "domain_decomposition.hpp"
 #include "hardware/node_info.hpp"
-#include "recipe.hpp"
+#include "util/partition.hpp"
 #include "util/span.hpp"
 
 namespace arb {
diff --git a/arbor/sampler_map.hpp b/arbor/sampler_map.hpp
index 08040015..5d137fbf 100644
--- a/arbor/sampler_map.hpp
+++ b/arbor/sampler_map.hpp
@@ -11,8 +11,8 @@
 
 #include <arbor/common_types.hpp>
 #include <arbor/sampling.hpp>
+#include <arbor/schedule.hpp>
 
-#include "schedule.hpp"
 #include "util/deduce_return.hpp"
 #include "util/transform.hpp"
 
diff --git a/arbor/schedule.cpp b/arbor/schedule.cpp
index 18880296..2bfc246e 100644
--- a/arbor/schedule.cpp
+++ b/arbor/schedule.cpp
@@ -4,8 +4,7 @@
 #include <vector>
 
 #include <arbor/common_types.hpp>
-
-#include "schedule.hpp"
+#include <arbor/schedule.hpp>
 
 // Implementations for specific schedules.
 
diff --git a/arbor/simulation.cpp b/arbor/simulation.cpp
index 5134e714..8339b0c0 100644
--- a/arbor/simulation.cpp
+++ b/arbor/simulation.cpp
@@ -2,16 +2,20 @@
 #include <set>
 #include <vector>
 
-#include "backends.hpp"
+#include <arbor/recipe.hpp>
+#include <arbor/domain_decomposition.hpp>
+#include <arbor/schedule.hpp>
+#include <arbor/simulation.hpp>
+
 #include "cell_group.hpp"
 #include "cell_group_factory.hpp"
-#include "domain_decomposition.hpp"
+#include "communication/communicator.hpp"
 #include "merge_events.hpp"
-#include "simulation.hpp"
-#include "recipe.hpp"
 #include "thread_private_spike_store.hpp"
 #include "util/double_buffer.hpp"
 #include "util/filter.hpp"
+#include "util/maputil.hpp"
+#include "util/partition.hpp"
 #include "util/span.hpp"
 #include "profile/profiler_macro.hpp"
 
@@ -38,9 +42,88 @@ public:
     void exchange() { buffer_.exchange(); }
 };
 
-simulation::simulation(const recipe& rec,
-                       const domain_decomposition& decomp,
-                       const distributed_context* ctx):
+class simulation_state {
+public:
+    simulation_state(const recipe& rec, const domain_decomposition& decomp, const distributed_context* ctx);
+
+    void reset();
+
+    time_type run(time_type tfinal, time_type dt);
+
+    sampler_association_handle add_sampler(cell_member_predicate probe_ids,
+        schedule sched, sampler_function f, sampling_policy policy = sampling_policy::lax);
+
+    void remove_sampler(sampler_association_handle);
+
+    void remove_all_samplers();
+
+    std::size_t num_spikes() const {
+        return communicator_.num_spikes();
+    }
+
+    void set_binning_policy(binning_kind policy, time_type bin_interval);
+
+    void inject_events(const pse_vector& events);
+
+    spike_export_function global_export_callback_;
+    spike_export_function local_export_callback_;
+
+private:
+    // Private helper function that sets up the event lanes for an epoch.
+    // See comments on implementation for more information.
+    void setup_events(time_type t_from, time_type time_to, std::size_t epoch_id);
+
+    std::vector<pse_vector>& event_lanes(std::size_t epoch_id) {
+        return event_lanes_[epoch_id%2];
+    }
+
+    // keep track of information about the current integration interval
+    epoch epoch_;
+
+    time_type t_ = 0.;
+    time_type min_delay_;
+    std::vector<cell_group_ptr> cell_groups_;
+
+    // one set of event_generators for each local cell
+    std::vector<std::vector<event_generator>> event_generators_;
+
+    std::unique_ptr<spike_double_buffer> local_spikes_;
+
+    // Hash table for looking up the the local index of a cell with a given gid
+    std::unordered_map<cell_gid_type, cell_size_type> gid_to_local_;
+
+    util::optional<cell_size_type> local_cell_index(cell_gid_type);
+
+    communicator communicator_;
+
+    // Pending events to be delivered.
+    std::array<std::vector<pse_vector>, 2> event_lanes_;
+    std::vector<pse_vector> pending_events_;
+
+    // Sampler associations handles are managed by a helper class.
+    util::handle_set<sampler_association_handle> sassoc_handles_;
+
+    // Apply a functional to each cell group in parallel.
+    template <typename L>
+    void foreach_group(L fn) {
+        threading::parallel_for::apply(0, cell_groups_.size(),
+            [&](int i) { fn(cell_groups_[i]); });
+    }
+
+    // Apply a functional to each cell group in parallel, supplying
+    // the cell group pointer reference and index.
+    template <typename L>
+    void foreach_group_index(L fn) {
+        threading::parallel_for::apply(0, cell_groups_.size(),
+            [&](int i) { fn(cell_groups_[i], i); });
+    }
+};
+
+simulation_state::simulation_state(
+        const recipe& rec,
+        const domain_decomposition& decomp,
+        const distributed_context* ctx
+    ):
     local_spikes_(new spike_double_buffer{}),
     communicator_(rec, decomp, ctx)
 {
@@ -54,9 +137,8 @@ simulation::simulation(const recipe& rec,
 
     event_generators_.resize(num_local_cells);
     cell_local_size_type lidx = 0;
-    const auto& grps = decomp.groups;
-    for (auto i: util::make_span(0, grps.size())) {
-        for (auto gid: grps[i].gids) {
+    for (const auto& group_info: decomp.groups) {
+        for (auto gid: group_info.gids) {
             // Store mapping of gid to local cell index.
             gid_to_local_[gid] = lidx;
 
@@ -79,10 +161,8 @@ simulation::simulation(const recipe& rec,
 
     // Generate the cell groups in parallel, with one task per cell group.
     cell_groups_.resize(decomp.groups.size());
-    threading::parallel_for::apply(0, cell_groups_.size(),
-        [&](cell_gid_type i) {
-            cell_groups_[i] = cell_group_factory(rec, decomp.groups[i]);
-        });
+    foreach_group_index(
+        [&](cell_group_ptr& group, int i) { group = cell_group_factory(rec, decomp.groups[i]); });
 
     // Create event lane buffers.
     // There is one set for each epoch: current (0) and next (1).
@@ -91,15 +171,12 @@ simulation::simulation(const recipe& rec,
     event_lanes_[1].resize(num_local_cells);
 }
 
-simulation::~simulation() = default;
-
-void simulation::reset() {
+void simulation_state::reset() {
     t_ = 0.;
 
     // Reset cell group state.
-    for (auto& group: cell_groups_) {
-        group->reset();
-    }
+    foreach_group(
+        [](cell_group_ptr& group) { group->reset(); });
 
     // Clear all pending events in the event lanes.
     for (auto& lanes: event_lanes_) {
@@ -126,7 +203,7 @@ void simulation::reset() {
     local_spikes_->previous().clear();
 }
 
-time_type simulation::run(time_type tfinal, time_type dt) {
+time_type simulation_state::run(time_type tfinal, time_type dt) {
     // Calculate the size of the largest possible time integration interval
     // before communication of spikes is required.
     // If spike exchange and cell update are serialized, this is the
@@ -136,15 +213,11 @@ time_type simulation::run(time_type tfinal, time_type dt) {
 
     // task that updates cell state in parallel.
     auto update_cells = [&] () {
-        threading::parallel_for::apply(
-            0u, cell_groups_.size(),
-            [&](unsigned i) {
-                auto &group = cell_groups_[i];
-
-                auto queues = util::subrange_view(
-                    event_lanes(epoch_.id),
-                    communicator_.group_queue_range(i));
+        foreach_group_index(
+            [&](cell_group_ptr& group, int i) {
+                auto queues = util::subrange_view(event_lanes(epoch_.id), communicator_.group_queue_range(i));
                 group->advance(epoch_, dt, queues);
+
                 PE(advance_spikes);
                 local_spikes_->current().insert(group->spikes());
                 group->clear_spikes();
@@ -163,8 +236,12 @@ time_type simulation::run(time_type tfinal, time_type dt) {
         auto global_spikes = communicator_.exchange(local_spikes);
 
         PE(communication_spikeio);
-        local_export_callback_(local_spikes);
-        global_export_callback_(global_spikes.values());
+        if (local_export_callback_) {
+            local_export_callback_(local_spikes);
+        }
+        if (global_export_callback_) {
+            global_export_callback_(global_spikes.values());
+        }
         PL();
 
         PE(communication_walkspikes);
@@ -214,7 +291,7 @@ time_type simulation::run(time_type tfinal, time_type dt) {
 //      event_lanes[epoch]: take all events ≥ t_from
 //      event_generators  : take all events < t_to
 //      pending_events    : take all events
-void simulation::setup_events(time_type t_from, time_type t_to, std::size_t epoch) {
+void simulation_state::setup_events(time_type t_from, time_type t_to, std::size_t epoch) {
     const auto n = communicator_.num_local_cells();
     threading::parallel_for::apply(0, n,
         [&](cell_size_type i) {
@@ -228,7 +305,7 @@ void simulation::setup_events(time_type t_from, time_type t_to, std::size_t epoc
         });
 }
 
-sampler_association_handle simulation::add_sampler(
+sampler_association_handle simulation_state::add_sampler(
         cell_member_predicate probe_ids,
         schedule sched,
         sampler_function f,
@@ -236,83 +313,100 @@ sampler_association_handle simulation::add_sampler(
 {
     sampler_association_handle h = sassoc_handles_.acquire();
 
-    threading::parallel_for::apply(0, cell_groups_.size(),
-        [&](std::size_t i) {
-            cell_groups_[i]->add_sampler(h, probe_ids, sched, f, policy);
-        });
+    foreach_group(
+        [&](cell_group_ptr& group) { group->add_sampler(h, probe_ids, sched, f, policy); });
 
     return h;
 }
 
-void simulation::remove_sampler(sampler_association_handle h) {
-    threading::parallel_for::apply(0, cell_groups_.size(),
-        [&](std::size_t i) {
-            cell_groups_[i]->remove_sampler(h);
-        });
+void simulation_state::remove_sampler(sampler_association_handle h) {
+    foreach_group(
+        [h](cell_group_ptr& group) { group->remove_sampler(h); });
 
     sassoc_handles_.release(h);
 }
 
-void simulation::remove_all_samplers() {
-    threading::parallel_for::apply(0, cell_groups_.size(),
-        [&](std::size_t i) {
-            cell_groups_[i]->remove_all_samplers();
-        });
+void simulation_state::remove_all_samplers() {
+    foreach_group(
+        [](cell_group_ptr& group) { group->remove_all_samplers(); });
 
     sassoc_handles_.clear();
 }
 
-std::size_t simulation::num_spikes() const {
-    return communicator_.num_spikes();
+void simulation_state::set_binning_policy(binning_kind policy, time_type bin_interval) {
+    foreach_group(
+        [&](cell_group_ptr& group) { group->set_binning_policy(policy, bin_interval); });
+}
+
+void simulation_state::inject_events(const pse_vector& events) {
+    // Push all events that are to be delivered to local cells into the
+    // pending event list for the event's target cell.
+    for (auto& e: events) {
+        if (e.time<t_) {
+            throw bad_event_time(e.time, t_);
+        }
+        // gid_to_local_ maps gid to index into local set of cells.
+        if (auto lidx = util::value_by_key(gid_to_local_, e.target.gid)) {
+            pending_events_[*lidx].push_back(e);
+        }
+    }
+}
+
+// Simulation class implementations forward to implementation class.
+
+simulation::simulation(
+    const recipe& rec,
+    const domain_decomposition& decomp,
+    const distributed_context* ctx)
+{
+    impl_.reset(new simulation_state(rec, decomp, ctx));
+}
+
+void simulation::reset() {
+    impl_->reset();
+}
+
+time_type simulation::run(time_type tfinal, time_type dt) {
+    return impl_->run(tfinal, dt);
 }
 
-std::size_t simulation::num_groups() const {
-    return cell_groups_.size();
+sampler_association_handle simulation::add_sampler(
+    cell_member_predicate probe_ids,
+    schedule sched,
+    sampler_function f,
+    sampling_policy policy)
+{
+    return impl_->add_sampler(std::move(probe_ids), std::move(sched), std::move(f), policy);
+}
+
+void simulation::remove_sampler(sampler_association_handle h) {
+    impl_->remove_sampler(h);
 }
 
-std::vector<pse_vector>& simulation::event_lanes(std::size_t epoch_id) {
-    return event_lanes_[epoch_id%2];
+void simulation::remove_all_samplers() {
+    impl_->remove_all_samplers();
+}
+
+std::size_t simulation::num_spikes() const {
+    return impl_->num_spikes();
 }
 
 void simulation::set_binning_policy(binning_kind policy, time_type bin_interval) {
-    for (auto& group: cell_groups_) {
-        group->set_binning_policy(policy, bin_interval);
-    }
+    impl_->set_binning_policy(policy, bin_interval);
 }
 
 void simulation::set_global_spike_callback(spike_export_function export_callback) {
-    global_export_callback_ = std::move(export_callback);
+    impl_->global_export_callback_ = std::move(export_callback);
 }
 
 void simulation::set_local_spike_callback(spike_export_function export_callback) {
-    local_export_callback_ = std::move(export_callback);
-}
-
-util::optional<cell_size_type> simulation::local_cell_index(cell_gid_type gid) {
-    auto it = gid_to_local_.find(gid);
-    return it==gid_to_local_.end()?
-        util::nullopt:
-        util::optional<cell_size_type>(it->second);
+    impl_->local_export_callback_ = std::move(export_callback);
 }
 
 void simulation::inject_events(const pse_vector& events) {
-    // Push all events that are to be delivered to local cells into the
-    // pending event list for the event's target cell.
-    for (auto& e: events) {
-        if (e.time<t_) {
-            throw std::runtime_error(
-                "simulation::inject_events(): attempt to inject an event at time: "
-                + std::to_string(e.time)
-                + " ms, which is earlier than the current simulation time: "
-                + std::to_string(t_)
-                + " ms. Events must be injected on or after the current simulation time.");
-        }
-        // local_cell_index returns an optional type that evaluates
-        // to true iff the gid is a local cell.
-        if (auto lidx = local_cell_index(e.target.gid)) {
-            pending_events_[*lidx].push_back(e);
-        }
-    }
+    impl_->inject_events(events);
 }
 
+simulation::~simulation() = default;
+
 } // namespace arb
diff --git a/arbor/simulation.hpp b/arbor/simulation.hpp
deleted file mode 100644
index d5be946e..00000000
--- a/arbor/simulation.hpp
+++ /dev/null
@@ -1,104 +0,0 @@
-#pragma once
-
-#include <array>
-#include <memory>
-#include <unordered_map>
-#include <vector>
-
-#include <arbor/common_types.hpp>
-#include <arbor/distributed_context.hpp>
-#include <arbor/sampling.hpp>
-
-#include "backends.hpp"
-#include "cell_group.hpp"
-#include "communication/communicator.hpp"
-#include "domain_decomposition.hpp"
-#include "epoch.hpp"
-#include "recipe.hpp"
-#include "util/nop.hpp"
-#include "util/handle_set.hpp"
-
-namespace arb {
-
-class spike_double_buffer;
-
-class simulation {
-public:
-    using spike_export_function = std::function<void(const std::vector<spike>&)>;
-
-    simulation(const recipe& rec, const domain_decomposition& decomp, const distributed_context* ctx);
-
-    void reset();
-
-    time_type run(time_type tfinal, time_type dt);
-
-    // Note: sampler functions may be invoked from a different thread than that
-    // which called the `run` method.
-
-    sampler_association_handle add_sampler(cell_member_predicate probe_ids,
-        schedule sched, sampler_function f, sampling_policy policy = sampling_policy::lax);
-
-    void remove_sampler(sampler_association_handle);
-
-    void remove_all_samplers();
-
-    std::size_t num_spikes() const;
-
-    // Set event binning policy on all our groups.
-    void set_binning_policy(binning_kind policy, time_type bin_interval);
-
-    // Register a callback that will perform a export of the global
-    // spike vector.
-    void set_global_spike_callback(spike_export_function export_callback);
-
-    // Register a callback that will perform a export of the rank local
-    // spike vector.
-    void set_local_spike_callback(spike_export_function export_callback);
-
-    // Add events directly to targets.
-    // Must be called before calling simulation::run, and must contain events that
-    // are to be delivered at or after the current simulation time.
-    void inject_events(const pse_vector& events);
-
-    ~simulation();
-
-private:
-    // Private helper function that sets up the event lanes for an epoch.
-    // See comments on implementation for more information.
-    void setup_events(time_type t_from, time_type time_to, std::size_t epoch_id);
-
-    std::vector<pse_vector>& event_lanes(std::size_t epoch_id);
-
-    std::size_t num_groups() const;
-
-    // keep track of information about the current integration interval
-    epoch epoch_;
-
-    time_type t_ = 0.;
-    time_type min_delay_;
-    std::vector<cell_group_ptr> cell_groups_;
-
-    // one set of event_generators for each local cell
-    std::vector<std::vector<event_generator>> event_generators_;
-
-    std::unique_ptr<spike_double_buffer> local_spikes_;
-
-    spike_export_function global_export_callback_ = util::nop_function;
-    spike_export_function local_export_callback_ = util::nop_function;
-
-    // Hash table for looking up the the local index of a cell with a given gid
-    std::unordered_map<cell_gid_type, cell_size_type> gid_to_local_;
-
-    util::optional<cell_size_type> local_cell_index(cell_gid_type);
-
-    communicator communicator_;
-
-    // Pending events to be delivered.
-    std::array<std::vector<pse_vector>, 2> event_lanes_;
-    std::vector<pse_vector> pending_events_;
-
-    // Sampler associations handles are managed by a helper class.
-    util::handle_set<sampler_association_handle> sassoc_handles_;
-};
-
-} // namespace arb
diff --git a/arbor/spike_event_io.cpp b/arbor/spike_event_io.cpp
new file mode 100644
index 00000000..f035ae86
--- /dev/null
+++ b/arbor/spike_event_io.cpp
@@ -0,0 +1,11 @@
+#include <iostream>
+
+#include <arbor/spike_event.hpp>
+
+namespace arb {
+
+std::ostream& operator<<(std::ostream& o, const spike_event& ev) {
+     return o << "E[tgt " << ev.target << ", t " << ev.time << ", w " << ev.weight << "]";
+}
+
+} // namespace arb
diff --git a/arbor/spike_source_cell_group.cpp b/arbor/spike_source_cell_group.cpp
index d62ac2c2..3859ba0c 100644
--- a/arbor/spike_source_cell_group.cpp
+++ b/arbor/spike_source_cell_group.cpp
@@ -1,11 +1,11 @@
 #include <exception>
 
+#include <arbor/recipe.hpp>
 #include <arbor/spike_source_cell.hpp>
 #include <arbor/time_sequence.hpp>
 
 #include "cell_group.hpp"
 #include "profile/profiler_macro.hpp"
-#include "recipe.hpp"
 #include "spike_source_cell_group.hpp"
 #include "util/span.hpp"
 
@@ -21,7 +21,7 @@ spike_source_cell_group::spike_source_cell_group(std::vector<cell_gid_type> gids
             time_sequences_.push_back(std::move(cell.seq));
         }
         catch (util::bad_any_cast& e) {
-            throw std::runtime_error("model cell type mismatch: gid "+std::to_string(gid)+" is not a spike_source_cell");
+            throw bad_cell_description(cell_kind::spike_source, gid);
         }
     }
 }
diff --git a/arbor/spike_source_cell_group.hpp b/arbor/spike_source_cell_group.hpp
index da714570..7169fb08 100644
--- a/arbor/spike_source_cell_group.hpp
+++ b/arbor/spike_source_cell_group.hpp
@@ -1,9 +1,15 @@
 #pragma once
 
+#include <vector>
+
+#include <arbor/common_types.hpp>
+#include <arbor/recipe.hpp>
+#include <arbor/sampling.hpp>
+#include <arbor/spike.hpp>
 #include <arbor/time_sequence.hpp>
 
 #include "cell_group.hpp"
-#include "recipe.hpp"
+#include "epoch.hpp"
 
 namespace arb {
 
diff --git a/arbor/threading/threading.cpp b/arbor/threading/threading.cpp
index 9f985503..6e2ab43e 100644
--- a/arbor/threading/threading.cpp
+++ b/arbor/threading/threading.cpp
@@ -3,10 +3,12 @@
 #include <regex>
 #include <string>
 
+#include <arbor/arbexcept.hpp>
 #include <arbor/util/optional.hpp>
 #include <hardware/affinity.hpp>
 
 #include "threading.hpp"
+#include "util/strprintf.hpp"
 
 namespace arb {
 namespace threading {
@@ -49,8 +51,8 @@ util::optional<size_t> get_env_num_threads() {
     if (errno==ERANGE ||
         !std::regex_match(str, std::regex("\\s*\\d*[0-9]\\d*\\s*")))
     {
-        throw std::runtime_error("The requested number of threads \""
-            +std::string(str)+"\" is not a valid value\n");
+        throw arbor_exception(util::pprintf(
+            "requested number of threads \"{}\" is not a valid value", str));
     }
 
     return nthreads;
diff --git a/arbor/util/pprintf.hpp b/arbor/util/pprintf.hpp
deleted file mode 100644
index f93d58f5..00000000
--- a/arbor/util/pprintf.hpp
+++ /dev/null
@@ -1,49 +0,0 @@
-#pragma once
-
-/*
- *  printf with variadic templates
- */
-
-#include <string>
-#include <sstream>
-
-namespace arb {
-namespace util {
-
-inline std::string pprintf(const char *s) {
-    std::string errstring;
-    while(*s) {
-        if(*s == '%' && s[1]!='%') {
-            // instead of throwing an exception, replace with ??
-            //throw std::runtime_error("pprintf: the number of arguments did not match the format ");
-            errstring += "<?>";
-        }
-        else {
-            errstring += *s;
-        }
-        ++s;
-    }
-    return errstring;
-}
-
-template <typename T, typename ... Args>
-std::string pprintf(const char *s, T value, Args... args) {
-    std::string errstring;
-    while(*s) {
-        if(*s == '%' && s[1]!='%') {
-            std::stringstream str;
-            str << value;
-            errstring += str.str();
-            errstring += pprintf(++s, args...);
-            return errstring;
-        }
-        else {
-            errstring += *s;
-            ++s;
-        }
-    }
-    return errstring;
-}
-
-} // namespace util
-} // namespace arb
diff --git a/arbor/util/strprintf.hpp b/arbor/util/strprintf.hpp
index 738353f9..f548e79b 100644
--- a/arbor/util/strprintf.hpp
+++ b/arbor/util/strprintf.hpp
@@ -1,18 +1,53 @@
 #pragma once
 
-/* Thin wrapper around std::snprintf for sprintf-like formatting
- * to std::string. */
+// printf-like routines that return std::string.
+//
+// TODO: Consolidate with a single routine that provides a consistent interface
+// along the lines of the PO645R2 text formatting proposal.
 
 #include <cstdio>
 #include <memory>
 #include <string>
+#include <sstream>
 #include <system_error>
 #include <utility>
 #include <vector>
 
+#include "util/meta.hpp"
+
 namespace arb {
 namespace util {
 
+// Use ADL to_string or std::to_string, falling back to ostream formatting:
+
+namespace impl_to_string {
+    using std::to_string;
+
+    template <typename T, typename = void>
+    struct select {
+        static std::string str(const T& value) {
+            std::ostringstream o;
+            o << value;
+            return o.str();
+        }
+    };
+
+    template <typename T>
+    struct select<T, util::void_t<decltype(to_string(std::declval<T>()))>> {
+        static std::string str(const T& v) {
+            return to_string(v);
+        }
+    };
+}
+
+template <typename T>
+std::string to_string(const T& value) {
+    return impl_to_string::select<T>::str(value);
+}
+
+// Use snprintf to format a string, with special handling for standard
+// smart pointer types and strings.
+
 namespace impl {
     template <typename X>
     X sprintf_arg_translate(const X& x) { return x; }
@@ -47,5 +82,34 @@ std::string strprintf(const std::string& fmt, Args&&... args) {
     return strprintf(fmt.c_str(), std::forward<Args>(args)...);
 }
 
+// Substitute instances of '{}' in the format string with the following parameters,
+// using default std::ostream formatting.
+
+namespace impl {
+    inline void pprintf_(std::ostringstream& o, const char* s) {
+        o << s;
+    }
+
+    template <typename T, typename... Tail>
+    void pprintf_(std::ostringstream& o, const char* s, T&& value, Tail&&... tail) {
+        const char* t = s;
+        while (*t && !(*t=='{' && t[1]=='}')) {
+            ++t;
+        }
+        o.write(s, t-s);
+        if (*t) {
+            o << std::forward<T>(value);
+            pprintf_(o, t+2, std::forward<Tail>(tail)...);
+        }
+    }
+}
+
+template <typename... Args>
+std::string pprintf(const char *s, Args&&... args) {
+    std::ostringstream o;
+    impl::pprintf_(o, s, std::forward<Args>(args)...);
+    return o.str();
+}
+
 } // namespace util
 } // namespace arb
diff --git a/example/bench/bench.cpp b/example/bench/bench.cpp
index 4f5bff6a..1ac4dcf1 100644
--- a/example/bench/bench.cpp
+++ b/example/bench/bench.cpp
@@ -11,14 +11,17 @@
 #include <arbor/profile/meter_manager.hpp>
 #include <arbor/common_types.hpp>
 #include <arbor/distributed_context.hpp>
+#include <arbor/profile/profiler.hpp>
+#include <arbor/recipe.hpp>
+#include <arbor/simulation.hpp>
 
-#include <hardware/node_info.hpp>
-#include <load_balance.hpp>
-#include <simulation.hpp>
-#include <util/ioutil.hpp>
+#include "hardware/node_info.hpp"
+#include "load_balance.hpp"
+#include "util/ioutil.hpp"
 
 #include "json_meter.hpp"
 
+#include "parameters.hpp"
 #include "recipe.hpp"
 
 using namespace arb;
diff --git a/example/bench/recipe.hpp b/example/bench/recipe.hpp
index f06952be..4c8c8102 100644
--- a/example/bench/recipe.hpp
+++ b/example/bench/recipe.hpp
@@ -1,8 +1,7 @@
 #pragma once
 
 #include <arbor/common_types.hpp>
-
-#include <recipe.hpp>
+#include <arbor/recipe.hpp>
 
 #include "parameters.hpp"
 
diff --git a/example/brunel/brunel_miniapp.cpp b/example/brunel/brunel_miniapp.cpp
index bb2af814..ef07bf2c 100644
--- a/example/brunel/brunel_miniapp.cpp
+++ b/example/brunel/brunel_miniapp.cpp
@@ -8,9 +8,12 @@
 
 #include <arbor/common_types.hpp>
 #include <arbor/distributed_context.hpp>
+#include <arbor/event_generator.hpp>
 #include <arbor/lif_cell.hpp>
 #include <arbor/profile/meter_manager.hpp>
 #include <arbor/profile/profiler.hpp>
+#include <arbor/recipe.hpp>
+#include <arbor/simulation.hpp>
 #include <arbor/threadinfo.hpp>
 #include <arbor/util/make_unique.hpp>
 #include <arbor/version.hpp>
@@ -20,12 +23,9 @@
 #include "with_mpi.hpp"
 #endif
 
-#include "event_generator.hpp"
 #include "hardware/gpu.hpp"
 #include "hardware/node_info.hpp"
 #include "io/exporter_spike_file.hpp"
-#include "recipe.hpp"
-#include "simulation.hpp"
 #include "util/ioutil.hpp"
 
 #include "partitioner.hpp"
diff --git a/example/brunel/partitioner.hpp b/example/brunel/partitioner.hpp
index 1aaae53a..cd4d383d 100644
--- a/example/brunel/partitioner.hpp
+++ b/example/brunel/partitioner.hpp
@@ -1,8 +1,11 @@
 #include <arbor/distributed_context.hpp>
+#include <arbor/domain_decomposition.hpp>
+#include <arbor/recipe.hpp>
 
-#include "domain_decomposition.hpp"
 #include "hardware/node_info.hpp"
-#include "recipe.hpp"
+#include "util/partition.hpp"
+#include "util/span.hpp"
+#include "util/transform.hpp"
 
 namespace arb {
     static
@@ -33,7 +36,7 @@ namespace arb {
         // Global load balance
         std::vector<cell_gid_type> gid_divisions;
         auto gid_part = make_partition(
-            gid_divisions, util::transform_view(util::make_span(0, num_domains), dom_size));
+            gid_divisions, util::transform_view(util::make_span(num_domains), dom_size));
 
         auto range = gid_part[domain_id];
         cell_size_type num_local_cells = range.second - range.first;
diff --git a/example/generators/event_gen.cpp b/example/generators/event_gen.cpp
index 5a21764d..22607137 100644
--- a/example/generators/event_gen.cpp
+++ b/example/generators/event_gen.cpp
@@ -14,14 +14,14 @@
 
 #include <arbor/common_types.hpp>
 #include <arbor/distributed_context.hpp>
+#include <arbor/event_generator.hpp>
 #include <arbor/mc_cell.hpp>
 #include <arbor/simple_sampler.hpp>
+#include <arbor/recipe.hpp>
+#include <arbor/simulation.hpp>
 
-#include "event_generator.hpp"
 #include "hardware/node_info.hpp"
 #include "load_balance.hpp"
-#include "simulation.hpp"
-#include "recipe.hpp"
 
 using arb::cell_gid_type;
 using arb::cell_lid_type;
diff --git a/example/miniapp/miniapp.cpp b/example/miniapp/miniapp.cpp
index 6f43f54a..2fb8be9c 100644
--- a/example/miniapp/miniapp.cpp
+++ b/example/miniapp/miniapp.cpp
@@ -11,17 +11,16 @@
 #include <arbor/profile/meter_manager.hpp>
 #include <arbor/profile/profiler.hpp>
 #include <arbor/sampling.hpp>
+#include <arbor/schedule.hpp>
+#include <arbor/simulation.hpp>
 #include <arbor/threadinfo.hpp>
 #include <arbor/util/any.hpp>
 #include <arbor/version.hpp>
 
-#include "communication/communicator.hpp"
 #include "hardware/gpu.hpp"
 #include "hardware/node_info.hpp"
 #include "io/exporter_spike_file.hpp"
 #include "load_balance.hpp"
-#include "simulation.hpp"
-#include "schedule.hpp"
 #include "util/ioutil.hpp"
 
 #include "json_meter.hpp"
@@ -36,7 +35,6 @@
 using namespace arb;
 
 using util::any_cast;
-using util::make_span;
 
 void banner(hw::node_info, const distributed_context*);
 std::unique_ptr<recipe> make_recipe(const io::cl_options&, const probe_distribution&);
@@ -102,7 +100,7 @@ int main(int argc, char** argv) {
                         continue;
                     }
 
-                    for (cell_lid_type j: make_span(0, recipe->num_probes(gid))) {
+                    for (cell_lid_type j = 0; j<recipe->num_probes(gid); ++j) {
                         sample_traces.push_back(make_trace(recipe->get_probe({gid, j})));
                     }
                 }
diff --git a/example/miniapp/miniapp_recipes.cpp b/example/miniapp/miniapp_recipes.cpp
index dc250abc..50838d1a 100644
--- a/example/miniapp/miniapp_recipes.cpp
+++ b/example/miniapp/miniapp_recipes.cpp
@@ -4,12 +4,12 @@
 #include <utility>
 
 #include <arbor/assert.hpp>
+#include <arbor/event_generator.hpp>
 #include <arbor/mc_cell.hpp>
 #include <arbor/morphology.hpp>
 #include <arbor/spike_source_cell.hpp>
 #include <arbor/time_sequence.hpp>
 
-#include "event_generator.hpp"
 
 #include "io.hpp"
 #include "miniapp_recipes.hpp"
@@ -109,7 +109,7 @@ public:
 
     probe_info get_probe(cell_member_type probe_id) const override {
         if (probe_id.index>=num_probes(probe_id.gid)) {
-            throw invalid_recipe_error("invalid probe id");
+            throw arb::bad_probe_id(probe_id);
         }
 
         // if we have both voltage and current probes, then order them
@@ -305,7 +305,7 @@ public:
         basic_cell_recipe(ncell, std::move(param), std::move(pdist))
     {
         if (std::size_t(param.num_synapses) != ncell-1) {
-            throw invalid_recipe_error("number of synapses per cell must equal number "
+            throw std::runtime_error("number of synapses per cell must equal number "
                 "of cells minus one in complete graph model");
         }
     }
diff --git a/example/miniapp/miniapp_recipes.hpp b/example/miniapp/miniapp_recipes.hpp
index 684b58b8..94103c1f 100644
--- a/example/miniapp/miniapp_recipes.hpp
+++ b/example/miniapp/miniapp_recipes.hpp
@@ -5,8 +5,7 @@
 #include <stdexcept>
 
 #include <arbor/util/optional.hpp>
-
-#include "recipe.hpp"
+#include <arbor/recipe.hpp>
 
 #include "morphology_pool.hpp"
 
diff --git a/include/arbor/arbexcept.hpp b/include/arbor/arbexcept.hpp
new file mode 100644
index 00000000..d136362a
--- /dev/null
+++ b/include/arbor/arbexcept.hpp
@@ -0,0 +1,98 @@
+#pragma once
+
+#include <stdexcept>
+#include <string>
+
+#include <arbor/common_types.hpp>
+
+// Arbor-specific exception hierarchy.
+
+namespace arb {
+
+// Arbor internal logic error (if these are thrown,
+// there is a bug in the library.)
+
+struct arbor_internal_error: std::logic_error {
+    arbor_internal_error(const std::string& what_arg):
+        std::logic_error(what_arg)
+    {}
+};
+
+
+// Common base-class for arbor run-time errors.
+
+struct arbor_exception: std::runtime_error {
+    arbor_exception(const std::string& what_arg):
+        std::runtime_error(what_arg)
+    {}
+};
+
+// Recipe errors:
+
+struct bad_cell_description: arbor_exception {
+    bad_cell_description(cell_kind kind, cell_gid_type gid);
+    cell_gid_type gid;
+    cell_kind kind;
+};
+
+struct bad_global_property: arbor_exception {
+    explicit bad_global_property(cell_kind kind);
+    cell_kind kind;
+};
+
+struct bad_probe_id: arbor_exception {
+    explicit bad_probe_id(cell_member_type id);
+    cell_member_type probe_id;
+};
+
+// Simulation errors:
+
+struct bad_event_time: arbor_exception {
+    explicit bad_event_time(time_type event_time, time_type sim_time);
+    time_type event_time;
+    time_type sim_time;
+};
+
+// Mechanism catalogue errors:
+
+struct no_such_mechanism: arbor_exception {
+    explicit no_such_mechanism(const std::string& mech_name);
+    std::string mech_name;
+};
+
+struct duplicate_mechanism: arbor_exception {
+    explicit duplicate_mechanism(const std::string& mech_name);
+    std::string mech_name;
+};
+
+struct fingerprint_mismatch: arbor_exception {
+    explicit fingerprint_mismatch(const std::string& mech_name);
+    std::string mech_name;
+};
+
+struct no_such_parameter: arbor_exception {
+    no_such_parameter(const std::string& mech_name, const std::string& param_name);
+    std::string mech_name;
+    std::string param_name;
+};
+
+struct invalid_parameter_value: arbor_exception {
+    invalid_parameter_value(const std::string& mech_name, const std::string& param_name, double value);
+    std::string mech_name;
+    std::string param_name;
+    double value;
+};
+
+struct no_such_implementation: arbor_exception {
+    explicit no_such_implementation(const std::string& mech_name);
+    std::string mech_name;
+};
+
+// Run-time value bounds check:
+
+struct range_check_failure: arbor_exception {
+    explicit range_check_failure(const std::string& whatstr, double value);
+    double value;
+};
+
+} // namespace arb
diff --git a/include/arbor/common_types.hpp b/include/arbor/common_types.hpp
index aed91121..923cf6b6 100644
--- a/include/arbor/common_types.hpp
+++ b/include/arbor/common_types.hpp
@@ -64,6 +64,13 @@ using probe_tag = int;
 
 using sample_size_type = std::int32_t;
 
+// Enumeration for execution back-end targets, as specified in domain decompositions.
+
+enum class backend_kind {
+    multicore,   //  Use multicore back-end for all computation.
+    gpu          //  Use gpu back-end when supported by cell_group implementation.
+};
+
 // Enumeration used to indentify the cell type/kind, used by the model to
 // group equal kinds in the same cell group.
 
@@ -74,10 +81,19 @@ enum class cell_kind {
     benchmark,        // Proxy cell used for benchmarking.
 };
 
-} // namespace arb
+// Enumeration for event time binning policy.
 
-std::ostream& operator<<(std::ostream& O, arb::cell_member_type m);
-std::ostream& operator<<(std::ostream& O, arb::cell_kind k);
+enum class binning_kind {
+    none,
+    regular,   // => round time down to multiple of binning interval.
+    following, // => round times down to previous event if within binning interval.
+};
+
+std::ostream& operator<<(std::ostream& o, cell_member_type m);
+std::ostream& operator<<(std::ostream& o, cell_kind k);
+std::ostream& operator<<(std::ostream& o, backend_kind k);
+
+} // namespace arb
 
 namespace std {
     template <> struct hash<arb::cell_member_type> {
diff --git a/arbor/domain_decomposition.hpp b/include/arbor/domain_decomposition.hpp
similarity index 85%
rename from arbor/domain_decomposition.hpp
rename to include/arbor/domain_decomposition.hpp
index b225970d..aecf5a83 100644
--- a/arbor/domain_decomposition.hpp
+++ b/include/arbor/domain_decomposition.hpp
@@ -1,19 +1,11 @@
 #pragma once
 
+#include <algorithm>
 #include <functional>
-#include <type_traits>
-#include <unordered_map>
 #include <vector>
 
+#include <arbor/assert.hpp>
 #include <arbor/common_types.hpp>
-#include <arbor/util/optional.hpp>
-
-#include "backends.hpp"
-#include "hardware/node_info.hpp"
-#include "recipe.hpp"
-#include "util/partition.hpp"
-#include "util/range.hpp"
-#include "util/transform.hpp"
 
 namespace arb {
 
@@ -38,7 +30,7 @@ struct group_description {
     group_description(cell_kind k, std::vector<cell_gid_type> g, backend_kind b):
         kind(k), gids(std::move(g)), backend(b)
     {
-        arb_assert(util::is_sorted(gids));
+        arb_assert(std::is_sorted(gids.begin(), gids.end()));
     }
 };
 
diff --git a/arbor/event_generator.hpp b/include/arbor/event_generator.hpp
similarity index 84%
rename from arbor/event_generator.hpp
rename to include/arbor/event_generator.hpp
index 048b62eb..e18290d7 100644
--- a/arbor/event_generator.hpp
+++ b/include/arbor/event_generator.hpp
@@ -5,24 +5,22 @@
 #include <random>
 
 #include <arbor/common_types.hpp>
+#include <arbor/generic_event.hpp>
+#include <arbor/spike_event.hpp>
 #include <arbor/time_sequence.hpp>
 
-#include "event_queue.hpp"
-#include "util/range.hpp"
-#include "util/rangeutil.hpp"
-
 namespace arb {
 
 // Generate a postsynaptic spike event that has delivery time set to
 // terminal_time. Such events are used as sentinels, to indicate the
 // end of a sequence.
 inline constexpr
-postsynaptic_spike_event make_terminal_pse() {
-    return postsynaptic_spike_event{cell_member_type{0,0}, terminal_time, 0};
+spike_event make_terminal_pse() {
+    return spike_event{cell_member_type{0,0}, terminal_time, 0};
 }
 
 inline
-bool is_terminal_pse(const postsynaptic_spike_event& e) {
+bool is_terminal_pse(const spike_event& e) {
     return e.time==terminal_time;
 }
 
@@ -31,8 +29,8 @@ bool is_terminal_pse(const postsynaptic_spike_event& e) {
 // Declared ahead of event_generator so that it can be used as the default
 // generator.
 struct empty_generator {
-    postsynaptic_spike_event front() {
-        return postsynaptic_spike_event{cell_member_type{0,0}, terminal_time, 0};
+    spike_event front() {
+        return spike_event{cell_member_type{0,0}, terminal_time, 0};
     }
     void pop() {}
     void reset() {}
@@ -78,7 +76,7 @@ public:
     // Does not modify the state of the stream, i.e. multiple calls to
     // front() will return the same event in the absence of calls to pop(),
     // advance() or reset().
-    postsynaptic_spike_event front() {
+    spike_event front() {
         return impl_->front();
     }
 
@@ -100,7 +98,7 @@ public:
 
 private:
     struct interface {
-        virtual postsynaptic_spike_event front() = 0;
+        virtual spike_event front() = 0;
         virtual void pop() = 0;
         virtual void advance(time_type t) = 0;
         virtual void reset() = 0;
@@ -115,7 +113,7 @@ private:
         explicit wrap(const Impl& impl): wrapped(impl) {}
         explicit wrap(Impl&& impl): wrapped(std::move(impl)) {}
 
-        postsynaptic_spike_event front() override {
+        spike_event front() override {
             return wrapped.front();
         }
 
@@ -142,15 +140,15 @@ private:
 // Generator that feeds events that are specified with a vector.
 // Makes a copy of the input sequence of events.
 struct vector_backed_generator {
-    using pse = postsynaptic_spike_event;
+    using pse = spike_event;
     vector_backed_generator(cell_member_type target, float weight, std::vector<time_type> samples):
         target_(target),
         weight_(weight),
         tseq_(std::move(samples))
     {}
 
-    postsynaptic_spike_event front() {
-        return postsynaptic_spike_event{target_, tseq_.front(), weight_};
+    spike_event front() {
+        return spike_event{target_, tseq_.front(), weight_};
     }
 
     void pop() {
@@ -177,15 +175,15 @@ private:
 // does not outlive the sequence.
 template <typename Seq>
 struct seq_generator {
-    using pse = postsynaptic_spike_event;
+    using pse = spike_event;
     seq_generator(Seq& events):
         events_(events),
         it_(std::begin(events_))
     {
-        arb_assert(util::is_sorted(events_));
+        arb_assert(std::is_sorted(events_.begin(), events_.end()));
     }
 
-    postsynaptic_spike_event front() {
+    spike_event front() {
         return it_==events_.end()? make_terminal_pse(): *it_;
     }
 
@@ -212,7 +210,7 @@ private:
 //  * with delivery times t=t_start+n*dt, ∀ t ∈ [t_start, t_stop)
 //  * with a set target and weight
 struct regular_generator {
-    using pse = postsynaptic_spike_event;
+    using pse = spike_event;
 
     regular_generator(cell_member_type target,
                       float weight,
@@ -224,8 +222,8 @@ struct regular_generator {
         tseq_(tstart, dt, tstop)
     {}
 
-    postsynaptic_spike_event front() {
-        return postsynaptic_spike_event{target_, tseq_.front(), weight_};
+    spike_event front() {
+        return spike_event{target_, tseq_.front(), weight_};
     }
 
     void pop() {
@@ -250,7 +248,7 @@ private:
 // with rate_per_ms spikes per ms.
 template <typename RandomNumberEngine>
 struct poisson_generator {
-    using pse = postsynaptic_spike_event;
+    using pse = spike_event;
 
     poisson_generator(cell_member_type target,
                       float weight,
@@ -265,8 +263,8 @@ struct poisson_generator {
         reset();
     }
 
-    postsynaptic_spike_event front() {
-        return postsynaptic_spike_event{target_, tseq_.front(), weight_};
+    spike_event front() {
+        return spike_event{target_, tseq_.front(), weight_};
     }
 
     void pop() {
diff --git a/arbor/generic_event.hpp b/include/arbor/generic_event.hpp
similarity index 100%
rename from arbor/generic_event.hpp
rename to include/arbor/generic_event.hpp
diff --git a/include/arbor/mc_cell.hpp b/include/arbor/mc_cell.hpp
index b9e4b101..71e65794 100644
--- a/include/arbor/mc_cell.hpp
+++ b/include/arbor/mc_cell.hpp
@@ -1,9 +1,10 @@
 #pragma once
 
 #include <unordered_map>
-#include <stdexcept>
+#include <string>
 #include <vector>
 
+#include <arbor/arbexcept.hpp>
 #include <arbor/common_types.hpp>
 #include <arbor/constants.hpp>
 #include <arbor/ion.hpp>
@@ -11,9 +12,16 @@
 #include <arbor/morphology.hpp>
 #include <arbor/mc_segment.hpp>
 
-
 namespace arb {
 
+// Specialize arbor exception for errors in cell building.
+
+struct mc_cell_error: arbor_exception {
+    mc_cell_error(const std::string& what):
+        arbor_exception("mc_cell: "+what)
+    {}
+};
+
 // Location specification for point processes.
 
 struct segment_location {
@@ -161,7 +169,7 @@ public:
     const soma_segment* soma() const;
 
     /// access pointer to a cable segment
-    /// will throw an std::out_of_range exception if
+    /// will throw an mc_cell_error exception if
     /// the cable index is not valid
     cable_segment* cable(index_type index);
 
@@ -252,10 +260,8 @@ template <typename... Args>
 cable_segment* mc_cell::add_cable(mc_cell::index_type parent, Args&&... args)
 {
     // check for a valid parent id
-    if(parent>=num_segments()) {
-        throw std::out_of_range(
-            "parent index of cell segment is out of range"
-        );
+    if (parent>=num_segments()) {
+        throw mc_cell_error("parent index of cell segment is out of range");
     }
     segments_.push_back(make_segment<cable_segment>(std::forward<Args>(args)...));
     parents_.push_back(parent);
diff --git a/arbor/recipe.hpp b/include/arbor/recipe.hpp
similarity index 88%
rename from arbor/recipe.hpp
rename to include/arbor/recipe.hpp
index 8d163c35..00147ff8 100644
--- a/arbor/recipe.hpp
+++ b/include/arbor/recipe.hpp
@@ -5,11 +5,11 @@
 #include <unordered_map>
 #include <stdexcept>
 
+#include <arbor/arbexcept.hpp>
 #include <arbor/common_types.hpp>
+#include <arbor/event_generator.hpp>
 #include <arbor/util/unique_any.hpp>
 
-#include "event_generator.hpp"
-
 namespace arb {
 
 struct probe_info {
@@ -20,11 +20,6 @@ struct probe_info {
     util::any address;
 };
 
-class invalid_recipe_error: public std::runtime_error {
-public:
-    invalid_recipe_error(std::string whatstr): std::runtime_error(std::move(whatstr)) {}
-};
-
 /* Recipe descriptions are cell-oriented: in order that the building
  * phase can be done distributedly and in order that the recipe
  * description can be built indepdently of any runtime execution environment.
@@ -70,8 +65,9 @@ public:
     virtual std::vector<cell_connection> connections_on(cell_gid_type) const {
         return {};
     }
-    virtual probe_info get_probe(cell_member_type) const {
-        throw std::logic_error("no probes");
+
+    virtual probe_info get_probe(cell_member_type probe_id) const {
+        throw bad_probe_id(probe_id);
     }
 
     // Global property type will be specific to given cell kind.
diff --git a/arbor/schedule.hpp b/include/arbor/schedule.hpp
similarity index 92%
rename from arbor/schedule.hpp
rename to include/arbor/schedule.hpp
index 66086a84..15ddadae 100644
--- a/arbor/schedule.hpp
+++ b/include/arbor/schedule.hpp
@@ -1,6 +1,7 @@
 #pragma once
 
 #include <algorithm>
+#include <iterator>
 #include <memory>
 #include <random>
 #include <vector>
@@ -9,8 +10,6 @@
 #include <arbor/common_types.hpp>
 #include <arbor/util/compat.hpp>
 
-#include "util/meta.hpp"
-
 // Time schedules for probe–sampler associations.
 
 namespace arb {
@@ -102,11 +101,17 @@ inline schedule regular_schedule(time_type dt) {
 // Schedule at times given explicitly via a provided sorted sequence.
 class explicit_schedule_impl {
 public:
-    template <typename Seq, typename = util::enable_if_sequence_t<const Seq&>>
+    explicit_schedule_impl(const explicit_schedule_impl&) = default;
+    explicit_schedule_impl(explicit_schedule_impl&&) = default;
+
+    template <typename Seq>
     explicit explicit_schedule_impl(const Seq& seq):
-        start_index_(0),
-        times_(std::begin(seq), compat::end(seq))
+        start_index_(0)
     {
+        using std::begin;
+        using compat::end; // TODO: replace with std::end when we nuke xlC support.
+
+        times_.assign(begin(seq), end(seq));
         arb_assert(std::is_sorted(times_.begin(), times_.end()));
     }
 
diff --git a/include/arbor/simulation.hpp b/include/arbor/simulation.hpp
new file mode 100644
index 00000000..c7844e93
--- /dev/null
+++ b/include/arbor/simulation.hpp
@@ -0,0 +1,63 @@
+#pragma once
+
+#include <array>
+#include <memory>
+#include <unordered_map>
+#include <vector>
+
+#include <arbor/common_types.hpp>
+#include <arbor/distributed_context.hpp>
+#include <arbor/domain_decomposition.hpp>
+#include <arbor/recipe.hpp>
+#include <arbor/sampling.hpp>
+#include <arbor/schedule.hpp>
+#include <arbor/util/handle_set.hpp>
+
+namespace arb {
+
+using spike_export_function = std::function<void(const std::vector<spike>&)>;
+
+struct simulation_state;
+class simulation {
+public:
+    simulation(const recipe& rec, const domain_decomposition& decomp, const distributed_context* ctx);
+
+    void reset();
+
+    time_type run(time_type tfinal, time_type dt);
+
+    // Note: sampler functions may be invoked from a different thread than that
+    // which called the `run` method.
+
+    sampler_association_handle add_sampler(cell_member_predicate probe_ids,
+        schedule sched, sampler_function f, sampling_policy policy = sampling_policy::lax);
+
+    void remove_sampler(sampler_association_handle);
+
+    void remove_all_samplers();
+
+    std::size_t num_spikes() const;
+
+    // Set event binning policy on all our groups.
+    void set_binning_policy(binning_kind policy, time_type bin_interval);
+
+    // Register a callback that will perform a export of the global
+    // spike vector.
+    void set_global_spike_callback(spike_export_function = spike_export_function{});
+
+    // Register a callback that will perform a export of the rank local
+    // spike vector.
+    void set_local_spike_callback(spike_export_function = spike_export_function{});
+
+    // Add events directly to targets.
+    // Must be called before calling simulation::run, and must contain events that
+    // are to be delivered at or after the current simulation time.
+    void inject_events(const pse_vector& events);
+
+    ~simulation();
+
+private:
+    std::unique_ptr<simulation_state> impl_;
+};
+
+} // namespace arb
diff --git a/include/arbor/spike_event.hpp b/include/arbor/spike_event.hpp
new file mode 100644
index 00000000..2b72314c
--- /dev/null
+++ b/include/arbor/spike_event.hpp
@@ -0,0 +1,31 @@
+#pragma once
+
+#include <iosfwd>
+#include <tuple>
+#include <vector>
+
+#include <arbor/common_types.hpp>
+
+namespace arb {
+
+// Events delivered to targets on cells with a cell group.
+
+struct spike_event {
+    cell_member_type target;
+    time_type time;
+    float weight;
+
+    friend bool operator==(const spike_event& l, const spike_event& r) {
+        return l.target==r.target && l.time==r.time && l.weight==r.weight;
+    }
+
+    friend bool operator<(const spike_event& l, const spike_event& r) {
+        return std::tie(l.time, l.target, l.weight) < std::tie(r.time, r.target, r.weight);
+    }
+};
+
+using pse_vector = std::vector<spike_event>;
+
+std::ostream& operator<<(std::ostream&, const spike_event&);
+
+} // namespace arb
diff --git a/include/arbor/util/compat.hpp b/include/arbor/util/compat.hpp
index 7784f047..0603e02c 100644
--- a/include/arbor/util/compat.hpp
+++ b/include/arbor/util/compat.hpp
@@ -25,7 +25,7 @@ constexpr bool using_gnu_compiler(int major=0, int minor=0, int patchlevel=0) {
 #endif
 }
 
-// std::end() broken with (at least) xlC 13.1.4.
+// std::end() broken with xlC 13.1.4; fixed in 13.1.5.
 
 namespace impl {
     using std::end;
@@ -43,6 +43,7 @@ template <typename T, std::size_t N>
 const T* end(const T (&x)[N]) { return &x[0]+N; }
 
 // Work-around bad optimization reordering in xlC 13.1.4.
+// Note: still broken in xlC 14.1.0
 
 inline void compiler_barrier_if_xlc_leq(unsigned ver) {
 #if defined(__xlC__)
@@ -64,6 +65,7 @@ inline void compiler_barrier_if_icc_leq(unsigned ver) {
 
 // Work-around bad ordering of std::isinf() (sometimes) within switch, xlC 13.1.4;
 // wrapping the call within another function appears to be sufficient.
+// Note: still broken in xlC 14.1.0.
 
 template <typename X>
 inline constexpr bool isinf(X x) { return std::isinf(x); }
diff --git a/arbor/util/handle_set.hpp b/include/arbor/util/handle_set.hpp
similarity index 100%
rename from arbor/util/handle_set.hpp
rename to include/arbor/util/handle_set.hpp
diff --git a/include/arbor/util/uninitialized.hpp b/include/arbor/util/uninitialized.hpp
index a94b3c62..5c8e46e2 100644
--- a/include/arbor/util/uninitialized.hpp
+++ b/include/arbor/util/uninitialized.hpp
@@ -43,15 +43,15 @@ public:
     using const_rvalue_reference= const X&&;
 
     pointer ptr() {
-        // COMPAT: xlC 13.1.4 workaround:
+        // COMPAT: xlC 13.1.4 workaround (still broken in 14.1.0):
         // should be equivalent to `return reinterpret_cast<X*>(&data)`.
-        compat::compiler_barrier_if_xlc_leq(0x0d01);
+        compat::compiler_barrier_if_xlc_leq(0x0e01);
         return static_cast<X*>(static_cast<void*>(&data));
     }
     const_pointer cptr() const {
-        // COMPAT: xlC 13.1.4 workaround:
+        // COMPAT: xlC 13.1.4 workaround (still broken in 14.1.0):
         // should be equivalent to `return reinterpret_cast<const X*>(&data)`
-        compat::compiler_barrier_if_xlc_leq(0x0d01);
+        compat::compiler_barrier_if_xlc_leq(0x0e01);
         return static_cast<const X*>(static_cast<const void*>(&data));
     }
 
diff --git a/test/common_cells.hpp b/test/common_cells.hpp
index 3ced9dcb..134395ca 100644
--- a/test/common_cells.hpp
+++ b/test/common_cells.hpp
@@ -3,8 +3,7 @@
 #include <arbor/mc_cell.hpp>
 #include <arbor/mc_segment.hpp>
 #include <arbor/mechinfo.hpp>
-
-#include "recipe.hpp"
+#include <arbor/recipe.hpp>
 
 namespace arb {
 
diff --git a/test/simple_recipes.hpp b/test/simple_recipes.hpp
index 531c66d8..92c39208 100644
--- a/test/simple_recipes.hpp
+++ b/test/simple_recipes.hpp
@@ -5,10 +5,9 @@
 #include <unordered_map>
 #include <vector>
 
+#include <arbor/event_generator.hpp>
 #include <arbor/mc_cell.hpp>
-
-#include <event_generator.hpp>
-#include <recipe.hpp>
+#include <arbor/recipe.hpp>
 
 namespace arb {
 
diff --git a/test/ubench/event_binning.cpp b/test/ubench/event_binning.cpp
index 94b58eba..1c33cfa7 100644
--- a/test/ubench/event_binning.cpp
+++ b/test/ubench/event_binning.cpp
@@ -7,15 +7,15 @@
 #include <unordered_map>
 #include <vector>
 
-#include <event_queue.hpp>
-#include <backends/event.hpp>
+#include <arbor/spike_event.hpp>
+
+#include "event_queue.hpp"
+#include "backends/event.hpp"
 
 #include <benchmark/benchmark.h>
 
 using namespace arb;
 
-using pse = postsynaptic_spike_event;
-
 std::vector<cell_gid_type> generate_gids(size_t n) {
     std::mt19937 engine;
     std::uniform_int_distribution<cell_gid_type> dist(1u, 3u);
@@ -31,9 +31,9 @@ std::vector<cell_gid_type> generate_gids(size_t n) {
     return gids;
 }
 
-std::vector<std::vector<pse>> generate_inputs(const std::vector<cell_gid_type>& gids, size_t ev_per_cell) {
+std::vector<pse_vector> generate_inputs(const std::vector<cell_gid_type>& gids, size_t ev_per_cell) {
     auto ncells = gids.size();
-    std::vector<std::vector<pse>> input_events;
+    std::vector<pse_vector> input_events;
 
     std::uniform_int_distribution<cell_gid_type>(0u, ncells);
     std::mt19937 gen;
@@ -42,7 +42,7 @@ std::vector<std::vector<pse>> generate_inputs(const std::vector<cell_gid_type>&
 
     input_events.resize(ncells);
     for (std::size_t i=0; i<ncells*ev_per_cell; ++i) {
-        postsynaptic_spike_event ev;
+        spike_event ev;
         auto idx = gid_dist(gen);
         auto gid = gids[idx];
         auto t = 1.;
diff --git a/test/ubench/event_setup.cpp b/test/ubench/event_setup.cpp
index 8d5cf3a1..9931e8ba 100644
--- a/test/ubench/event_setup.cpp
+++ b/test/ubench/event_setup.cpp
@@ -7,7 +7,7 @@
 // gid. A similar lookup should be added to theses tests, to more accurately
 // reflect the mc_cell_group implementation.
 //
-// TODO: The staged_events output is a vector of postsynaptic_spike_event, not
+// TODO: The staged_events output is a vector of spike_event, not
 // a deliverable event.
 
 #include <random>
@@ -20,8 +20,8 @@
 
 using namespace arb;
 
-std::vector<postsynaptic_spike_event> generate_inputs(size_t ncells, size_t ev_per_cell) {
-    std::vector<postsynaptic_spike_event> input_events;
+std::vector<spike_event> generate_inputs(size_t ncells, size_t ev_per_cell) {
+    std::vector<spike_event> input_events;
     std::default_random_engine engine;
     std::uniform_int_distribution<cell_gid_type>(0u, ncells);
 
@@ -33,7 +33,7 @@ std::vector<postsynaptic_spike_event> generate_inputs(size_t ncells, size_t ev_p
 
     input_events.reserve(ncells*ev_per_cell);
     for (std::size_t i=0; i<ncells*ev_per_cell; ++i) {
-        postsynaptic_spike_event ev;
+        spike_event ev;
         auto gid = gid_dist(gen);
         auto t = time_dist(gen);
         ev.target = {cell_gid_type(gid), cell_lid_type(0)};
@@ -46,7 +46,7 @@ std::vector<postsynaptic_spike_event> generate_inputs(size_t ncells, size_t ev_p
 }
 
 void single_queue(benchmark::State& state) {
-    using pev = postsynaptic_spike_event;
+    using pev = spike_event;
 
     const std::size_t ncells = state.range(0);
     const std::size_t ev_per_cell = state.range(1);
@@ -84,7 +84,7 @@ void single_queue(benchmark::State& state) {
 }
 
 void n_queue(benchmark::State& state) {
-    using pev = postsynaptic_spike_event;
+    using pev = spike_event;
     const std::size_t ncells = state.range(0);
     const std::size_t ev_per_cell = state.range(1);
 
@@ -123,7 +123,7 @@ void n_queue(benchmark::State& state) {
 }
 
 void n_vector(benchmark::State& state) {
-    using pev = postsynaptic_spike_event;
+    using pev = spike_event;
     const std::size_t ncells = state.range(0);
     const std::size_t ev_per_cell = state.range(1);
 
@@ -165,7 +165,7 @@ void n_vector(benchmark::State& state) {
             part[i+1] = part[i] + ext[i];
         }
         // copy events into the output flat buffer
-        std::vector<postsynaptic_spike_event> staged_events(part.back());
+        std::vector<spike_event> staged_events(part.back());
         auto b = staged_events.begin();
         for (size_t i=0; i<ncells; ++i) {
             auto bi = event_lanes[i].begin();
diff --git a/test/unit-distributed/test_communicator.cpp b/test/unit-distributed/test_communicator.cpp
index 151df0f3..3c3daf17 100644
--- a/test/unit-distributed/test_communicator.cpp
+++ b/test/unit-distributed/test_communicator.cpp
@@ -5,13 +5,14 @@
 #include <vector>
 
 #include <arbor/distributed_context.hpp>
+#include <arbor/spike_event.hpp>
 
-#include <communication/communicator.hpp>
-#include <hardware/node_info.hpp>
-#include <load_balance.hpp>
-#include <util/filter.hpp>
-#include <util/rangeutil.hpp>
-#include <util/span.hpp>
+#include "communication/communicator.hpp"
+#include "hardware/node_info.hpp"
+#include "load_balance.hpp"
+#include "util/filter.hpp"
+#include "util/rangeutil.hpp"
+#include "util/span.hpp"
 
 using namespace arb;
 
@@ -211,7 +212,7 @@ namespace {
 
     // gid expects an event from source_of(gid) with weight gid, and fired at
     // time source_of(gid).
-    postsynaptic_spike_event expected_event_ring(cell_gid_type gid, cell_size_type num_cells) {
+    spike_event expected_event_ring(cell_gid_type gid, cell_size_type num_cells) {
         auto sid = source_of(gid, num_cells);
         return {
             {gid, 0u},  // source
@@ -267,7 +268,7 @@ namespace {
         cell_size_type ranks_;
     };
 
-    postsynaptic_spike_event expected_event_all2all(cell_gid_type gid, cell_gid_type sid) {
+    spike_event expected_event_all2all(cell_gid_type gid, cell_gid_type sid) {
         return {
             {gid, sid},      // target, event from sid goes to synapse with index sid
             sid+1.0f,        // time (all conns have delay 1 ms)
diff --git a/test/unit/test_backend.cpp b/test/unit/test_backend.cpp
index 5e1c18d4..74db35c2 100644
--- a/test/unit/test_backend.cpp
+++ b/test/unit/test_backend.cpp
@@ -1,6 +1,6 @@
+#include <arbor/common_types.hpp>
 #include <arbor/version.hpp>
 
-#include "backends.hpp"
 #include "fvm_lowered_cell.hpp"
 
 #include "../gtest.h"
diff --git a/test/unit/test_domain_decomposition.cpp b/test/unit/test_domain_decomposition.cpp
index 16dd8785..dc27a433 100644
--- a/test/unit/test_domain_decomposition.cpp
+++ b/test/unit/test_domain_decomposition.cpp
@@ -3,9 +3,8 @@
 #include <stdexcept>
 
 #include <arbor/distributed_context.hpp>
+#include <arbor/domain_decomposition.hpp>
 
-#include "backends.hpp"
-#include "domain_decomposition.hpp"
 #include "hardware/node_info.hpp"
 #include "load_balance.hpp"
 #include "util/span.hpp"
diff --git a/test/unit/test_event_generators.cpp b/test/unit/test_event_generators.cpp
index 8d8b538a..6004986c 100644
--- a/test/unit/test_event_generators.cpp
+++ b/test/unit/test_event_generators.cpp
@@ -1,11 +1,13 @@
 #include "../gtest.h"
-#include "common.hpp"
 
-#include <event_generator.hpp>
-#include <util/rangeutil.hpp>
+#include <arbor/event_generator.hpp>
+#include <arbor/spike_event.hpp>
+
+#include "util/rangeutil.hpp"
+
+#include "common.hpp"
 
 using namespace arb;
-using pse = postsynaptic_spike_event;
 
 namespace{
     pse_vector draw(event_generator& gen, time_type t0, time_type t1) {
@@ -60,7 +62,7 @@ TEST(event_generators, regular) {
 }
 
 TEST(event_generators, seq) {
-    std::vector<pse> in = {
+    pse_vector in = {
         {{0, 0}, 0.1, 1.0},
         {{0, 0}, 1.0, 2.0},
         {{0, 0}, 1.0, 3.0},
diff --git a/test/unit/test_event_queue.cpp b/test/unit/test_event_queue.cpp
index 8a3389a4..3ce72e0b 100644
--- a/test/unit/test_event_queue.cpp
+++ b/test/unit/test_event_queue.cpp
@@ -3,12 +3,14 @@
 #include <cmath>
 #include <vector>
 
-#include <event_queue.hpp>
+#include <arbor/spike_event.hpp>
+
+#include "event_queue.hpp"
 
 using namespace arb;
 
 TEST(event_queue, push) {
-    using ps_event_queue = event_queue<postsynaptic_spike_event>;
+    using ps_event_queue = event_queue<spike_event>;
 
     ps_event_queue q;
 
@@ -29,7 +31,7 @@ TEST(event_queue, push) {
 }
 
 TEST(event_queue, pop_if_before) {
-    using ps_event_queue = event_queue<postsynaptic_spike_event>;
+    using ps_event_queue = event_queue<spike_event>;
 
     cell_member_type target[4] = {
         {1u, 0u},
@@ -38,7 +40,7 @@ TEST(event_queue, pop_if_before) {
         {2u, 3u}
     };
 
-    postsynaptic_spike_event events[] = {
+    spike_event events[] = {
         {target[0], 1.f, 2.f},
         {target[1], 2.f, 2.f},
         {target[2], 3.f, 2.f},
diff --git a/test/unit/test_fvm_lowered.cpp b/test/unit/test_fvm_lowered.cpp
index 1a23165b..56f12249 100644
--- a/test/unit/test_fvm_lowered.cpp
+++ b/test/unit/test_fvm_lowered.cpp
@@ -7,7 +7,10 @@
 #include <arbor/fvm_types.hpp>
 #include <arbor/mc_cell.hpp>
 #include <arbor/mc_segment.hpp>
+#include <arbor/recipe.hpp>
 #include <arbor/sampling.hpp>
+#include <arbor/simulation.hpp>
+#include <arbor/schedule.hpp>
 
 #include "algorithms.hpp"
 #include "backends/multicore/fvm.hpp"
@@ -16,10 +19,7 @@
 #include "fvm_lowered_cell_impl.hpp"
 #include "load_balance.hpp"
 #include "math.hpp"
-#include "simulation.hpp"
-#include "recipe.hpp"
 #include "sampler_map.hpp"
-#include "schedule.hpp"
 #include "util/meta.hpp"
 #include "util/maputil.hpp"
 #include "util/rangeutil.hpp"
diff --git a/test/unit/test_lif_cell_group.cpp b/test/unit/test_lif_cell_group.cpp
index 40bc2c38..c2753e52 100644
--- a/test/unit/test_lif_cell_group.cpp
+++ b/test/unit/test_lif_cell_group.cpp
@@ -3,13 +3,15 @@
 #include <arbor/distributed_context.hpp>
 #include <arbor/lif_cell.hpp>
 #include <arbor/threadinfo.hpp>
+#include <arbor/recipe.hpp>
+#include <arbor/simulation.hpp>
 #include <arbor/spike_source_cell.hpp>
 
 #include "cell_group_factory.hpp"
+#include "hardware/node_info.hpp"
 #include "lif_cell_group.hpp"
 #include "load_balance.hpp"
-#include "simulation.hpp"
-#include "recipe.hpp"
+#include "threading/threading.hpp"
 
 using namespace arb;
 // Simple ring network of LIF neurons.
@@ -163,7 +165,7 @@ TEST(lif_cell_group, spikes) {
     auto decomp = partition_load_balance(recipe, nd, &context);
     simulation sim(recipe, decomp, &context);
 
-    std::vector<postsynaptic_spike_event> events;
+    std::vector<spike_event> events;
 
     // First event to trigger the spike (first neuron).
     events.push_back({{0, 0}, 1, 1000});
diff --git a/test/unit/test_mc_cell_group.cpp b/test/unit/test_mc_cell_group.cpp
index 4aaf42eb..a9873cbc 100644
--- a/test/unit/test_mc_cell_group.cpp
+++ b/test/unit/test_mc_cell_group.cpp
@@ -2,7 +2,6 @@
 
 #include <arbor/common_types.hpp>
 
-#include "backends.hpp"
 #include "epoch.hpp"
 #include "fvm_lowered_cell.hpp"
 #include "mc_cell_group.hpp"
diff --git a/test/unit/test_mechcat.cpp b/test/unit/test_mechcat.cpp
index c6a16a61..1c2a04bf 100644
--- a/test/unit/test_mechcat.cpp
+++ b/test/unit/test_mechcat.cpp
@@ -1,3 +1,4 @@
+#include <arbor/arbexcept.hpp>
 #include <arbor/fvm_types.hpp>
 #include <arbor/mechanism.hpp>
 #include <arbor/mechcat.hpp>
@@ -194,7 +195,7 @@ TEST(mechcat, fingerprint) {
     EXPECT_EQ("burbleprint", cat.fingerprint("bleeble"));
 
     EXPECT_THROW(cat.register_implementation<bar_backend>("burble", make_mech<bar_backend, burble_bar>()),
-        std::invalid_argument);
+        arb::fingerprint_mismatch);
 }
 
 TEST(mechcat, derived_info) {
@@ -245,7 +246,7 @@ TEST(mechcat, remove) {
 TEST(mechcat, instance) {
     auto cat = build_fake_catalogue();
 
-    EXPECT_THROW(cat.instance<bar_backend>("burble"), std::invalid_argument);
+    EXPECT_THROW(cat.instance<bar_backend>("burble"), arb::no_such_implementation);
 
     // All fleebs on the bar backend have the same implementation:
 
diff --git a/test/unit/test_schedule.cpp b/test/unit/test_schedule.cpp
index 6f08d2bc..0796352b 100644
--- a/test/unit/test_schedule.cpp
+++ b/test/unit/test_schedule.cpp
@@ -4,8 +4,8 @@
 #include <vector>
 
 #include <arbor/common_types.hpp>
+#include <arbor/schedule.hpp>
 
-#include <schedule.hpp>
 #include <util/partition.hpp>
 #include <util/rangeutil.hpp>
 
diff --git a/test/unit/test_time_seq.cpp b/test/unit/test_time_seq.cpp
index dcb87b4d..b4166279 100644
--- a/test/unit/test_time_seq.cpp
+++ b/test/unit/test_time_seq.cpp
@@ -1,15 +1,14 @@
 #include "../gtest.h"
-#include "common.hpp"
 
 #include <vector>
 
 #include <arbor/time_sequence.hpp>
 
-#include "event_queue.hpp"
 #include "util/rangeutil.hpp"
 
+#include "common.hpp"
+
 using namespace arb;
-using pse = postsynaptic_spike_event;
 
 namespace{
     // Helper function that draws all samples in the half open interval
diff --git a/test/validation/convergence_test.hpp b/test/validation/convergence_test.hpp
index c4de6033..459e788f 100644
--- a/test/validation/convergence_test.hpp
+++ b/test/validation/convergence_test.hpp
@@ -6,9 +6,9 @@
 
 #include <arbor/sampling.hpp>
 #include <arbor/simple_sampler.hpp>
+#include <arbor/simulation.hpp>
+#include <arbor/schedule.hpp>
 
-#include "simulation.hpp"
-#include "schedule.hpp"
 #include "util/filter.hpp"
 #include "util/rangeutil.hpp"
 
diff --git a/test/validation/validate_ball_and_stick.cpp b/test/validation/validate_ball_and_stick.cpp
index 2520321b..6f736ff8 100644
--- a/test/validation/validate_ball_and_stick.cpp
+++ b/test/validation/validate_ball_and_stick.cpp
@@ -4,15 +4,16 @@
 
 #include <arbor/common_types.hpp>
 #include <arbor/mc_cell.hpp>
+#include <arbor/recipe.hpp>
 #include <arbor/simple_sampler.hpp>
+#include <arbor/simulation.hpp>
 
 #include "load_balance.hpp"
 #include "hardware/node_info.hpp"
 #include "hardware/gpu.hpp"
-#include "simulation.hpp"
-#include "recipe.hpp"
 #include "util/meta.hpp"
 #include "util/path.hpp"
+#include "util/strprintf.hpp"
 
 #include "../common_cells.hpp"
 #include "../simple_recipes.hpp"
@@ -50,7 +51,7 @@ void run_ncomp_convergence_test(
         {"dt", dt},
         {"sim", "arbor"},
         {"units", "mV"},
-        {"backend_kind", to_string(backend)}
+        {"backend_kind", util::to_string(backend)}
     };
 
     auto exclude = stimulus_ends(c);
diff --git a/test/validation/validate_compartment_policy.cpp b/test/validation/validate_compartment_policy.cpp
index a2ead6c4..7307c459 100644
--- a/test/validation/validate_compartment_policy.cpp
+++ b/test/validation/validate_compartment_policy.cpp
@@ -5,11 +5,11 @@
 
 #include <arbor/common_types.hpp>
 #include <arbor/mc_cell.hpp>
+#include <arbor/recipe.hpp>
 #include <arbor/simple_sampler.hpp>
+#include <arbor/simulation.hpp>
 
-#include <simulation.hpp>
-#include <recipe.hpp>
-#include <util/rangeutil.hpp>
+#include "util/rangeutil.hpp"
 
 #include "../gtest.h"
 
diff --git a/test/validation/validate_kinetic.cpp b/test/validation/validate_kinetic.cpp
index 063df632..a1d78a56 100644
--- a/test/validation/validate_kinetic.cpp
+++ b/test/validation/validate_kinetic.cpp
@@ -6,14 +6,15 @@
 
 #include <arbor/common_types.hpp>
 #include <arbor/mc_cell.hpp>
+#include <arbor/recipe.hpp>
 #include <arbor/simple_sampler.hpp>
+#include <arbor/simulation.hpp>
 
-#include <hardware/node_info.hpp>
-#include <hardware/gpu.hpp>
-#include <load_balance.hpp>
-#include <simulation.hpp>
-#include <recipe.hpp>
-#include <util/rangeutil.hpp>
+#include "hardware/node_info.hpp"
+#include "hardware/gpu.hpp"
+#include "load_balance.hpp"
+#include "util/rangeutil.hpp"
+#include "util/strprintf.hpp"
 
 #include "../common_cells.hpp"
 #include "../simple_recipes.hpp"
@@ -39,7 +40,7 @@ void run_kinetic_dt(
     probe_label plabels[1] = {"soma.mid", {0u, 0u}};
 
     meta["sim"] = "arbor";
-    meta["backend_kind"] = to_string(backend);
+    meta["backend_kind"] = util::to_string(backend);
 
     convergence_test_runner<float> runner("dt", plabels, meta);
     runner.load_reference_data(ref_file);
diff --git a/test/validation/validate_soma.cpp b/test/validation/validate_soma.cpp
index e912c6b5..27458680 100644
--- a/test/validation/validate_soma.cpp
+++ b/test/validation/validate_soma.cpp
@@ -2,14 +2,15 @@
 
 #include <arbor/common_types.hpp>
 #include <arbor/mc_cell.hpp>
+#include <arbor/recipe.hpp>
 #include <arbor/simple_sampler.hpp>
+#include <arbor/simulation.hpp>
 
-#include <hardware/gpu.hpp>
-#include <hardware/node_info.hpp>
-#include <load_balance.hpp>
-#include <simulation.hpp>
-#include <recipe.hpp>
-#include <util/rangeutil.hpp>
+#include "hardware/gpu.hpp"
+#include "hardware/node_info.hpp"
+#include "load_balance.hpp"
+#include "util/rangeutil.hpp"
+#include "util/strprintf.hpp"
 
 #include "../common_cells.hpp"
 #include "../simple_recipes.hpp"
@@ -41,7 +42,7 @@ void validate_soma(backend_kind backend) {
         {"model", "soma"},
         {"sim", "arbor"},
         {"units", "mV"},
-        {"backend_kind", to_string(backend)}
+        {"backend_kind", util::to_string(backend)}
     };
 
     convergence_test_runner<float> runner("dt", plabels, meta);
diff --git a/test/validation/validate_synapses.cpp b/test/validation/validate_synapses.cpp
index 01c237d8..b987b718 100644
--- a/test/validation/validate_synapses.cpp
+++ b/test/validation/validate_synapses.cpp
@@ -1,14 +1,15 @@
 #include <nlohmann/json.hpp>
 
 #include <arbor/mc_cell.hpp>
+#include <arbor/recipe.hpp>
 #include <arbor/simple_sampler.hpp>
+#include <arbor/simulation.hpp>
 
 #include "hardware/node_info.hpp"
 #include "hardware/gpu.hpp"
 #include "load_balance.hpp"
-#include "simulation.hpp"
-#include "recipe.hpp"
 #include "util/path.hpp"
+#include "util/strprintf.hpp"
 
 #include "../gtest.h"
 
@@ -34,7 +35,7 @@ void run_synapse_test(
         {"model", syn_type},
         {"sim", "arbor"},
         {"units", "mV"},
-        {"backend_kind", to_string(backend)}
+        {"backend_kind", util::to_string(backend)}
     };
 
     mc_cell c = make_cell_ball_and_stick(false); // no stimuli
@@ -42,7 +43,7 @@ void run_synapse_test(
     c.add_synapse({1, 0.5}, syn_default);
 
     // injected spike events
-    std::vector<postsynaptic_spike_event> synthetic_events = {
+    std::vector<spike_event> synthetic_events = {
         {{0u, 0u}, 10.0, 0.04},
         {{0u, 0u}, 20.0, 0.04},
         {{0u, 0u}, 40.0, 0.04}
-- 
GitLab