diff --git a/arbor/CMakeLists.txt b/arbor/CMakeLists.txt
index 8208a98e7ce91ae783799b595c2340d393afca93..4ea56fd86690a783836cce92ae46e951b12f99fa 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 0000000000000000000000000000000000000000..a8b589b7b0c3490560f599b3df951f819e1c257a
--- /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 e45f96e0bc2afe5d855f7db8b6905fc3a3015e96..0000000000000000000000000000000000000000
--- 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 8e6d7baeebbc75bb77247f69d1afdccb77dd071f..cb57c95ae07ae8af312aae225d54d01abe866f00 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 4498b87cb9f208c76f32e5054bf886a48f09594e..b03e374c6f954e4aa18c7ad12386a2c7ca7efb84 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 09f5627561018e51a9c4c0201cc2958456842b02..7cc62f8a0f25dccf7818d563f2e220504e006a5c 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 3fbb4fb1a81238be8531061c980d962208387fb8..a5b22f3e54631b3e5c75e1df5ae26185aed5635e 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 2a761b8c7cb6d218c10eee7f0b354bd1669fbade..aa0bea06b1095c38bb937e9b1d0b3a3feb60fb54 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 6466d7c1b31fb9027ff9e43054b8d8925d3009c5..0556045dea9a2abbd5afa1ad9c6beae64bd5e073 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 146baf7fc48701f5a42f5124d3bf3f90f665cd2b..02fee8d51758043616848e4398e149e81ad21008 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 ed53924f48055d9d9999112b7d9e2d4a0d3baacd..98cf20ab102d43dcce888f76408b724386733f89 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 96079522d497f566a9048ba08bab17515b323ec1..54e343eaf3a04e565df3949d3ac50c495033112b 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 88770475dc11d8faa392fe67cbd3f7b92818c866..320e4ff531aad22074cee94d569bc5a9d9c60942 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 2e46ddd7d301474f098cd2650992011b1d79d4af..c5b904784f700af08d78c8805a04728cf096001d 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 d9a6f75935f93e82448e93210b79d7db8c37a2f4..78aa7e4c6d2b38d159d866154f49d51c75dc004d 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 40d19287763395d9513cd82caaaf5a5f4b080595..e4fbf1509668dca58b11cd18dd91822c855260e8 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 7fcdbbac4f790b41ef5021d17d526755f95c0ee7..6301a3499d84d0380a8ba5a7eea72c4db24994a9 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 25ff4f0bf444fe1da659a9fe8b67b63eb83186f9..c1bcd72c5147972a77a258805e26da8ce8a1c8a4 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 0a5a24d9e6397c3eb2a6c4ca869a23e1d922f909..b785acd06299d6a3fb60e93d1afa50013e98793c 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 d634fc711172bc8549fc4785ee37d1e82f3c4467..6f3d1ee9c10a253c48c30bc898437deae242b595 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 3b22c5ffb8a5618bc126b5c8da8abdb2316dd498..81a2688793d87c2ceaf26e455398582e74e18d53 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 fe317e93b18ec909dd33e92dffcd32ca4d4106b3..1136f64e146eb7c3c8581644b3637afb2b3fbc62 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 f8a7f6b9682c5a29b2b047c7d0bb9f502de5454a..b07fcfea27ab85a76ded70eeec67199f34297d00 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 70f51250471aae1a018db4e4b628145dc62909ea..7cce6cc5ed11a0e9f4451ba7608595d4374573f5 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 d81aaf3b3094c5158377e2fdb4d2eb954c8ce9ab..6ed49d39ba94931419e5a19237d957953cb5e49d 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 0dbb0cf4b86b477da691714a6996e249a41c9d83..757f5a31422d673a3b781ed2b6de9ea1a98b4d3e 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 2184134c5f62b52006b1c9ee2cf33e851523604a..5807afd0dafa9f6b26ed783392c90b8f58b42c33 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 b5e12cc263bdd6676b15d77061e704a3b7c057af..e03ae795d05a0cd93d6e71050edcc20323add82a 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 463e59d09c00e31066b565f99a4fd01f9cc3b036..be9599d75e5264335237be721b3d36fdffd8fe1c 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 5ce8688407fe6ded889f1b4b3ec63b971aead6a1..ed314a78efe9cea2857fd9f84a675cd731e6b0ef 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 4e5d8600c010cf4f9aabd1f23508d83fb5ac847e..096dd16965c0311fe7254052847b80500f4c8358 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 72c3918d8d61543b5b14ae3c482dd5af4e92ff53..eb058fec6c92200c6d5e0ca17bb3b9db1c972af9 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 062373de76450935d0c1153c6da7e67526845621..c9a18ec23f07728b002a5563f6050970faaad941 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 08040015a814b1fa50a025c6fe68282067b57131..5d137fbfb14851f1240ac0550767231d5b0d7a31 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 18880296422e2d9e4ec4d10268e41d0263c9e52c..2bfc246e91ca2592c163bea6fa081942e2c29ebe 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 5134e714d6c99fa0b62f9a7678937161ccbf2ae5..8339b0c05985b4fd2bc278bd1a4ab3dcf54df800 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 d5be946e8a0cf66cf109cd2f0696686bc09b5106..0000000000000000000000000000000000000000
--- 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 0000000000000000000000000000000000000000..f035ae860d9a5d759097825df299282f929f6a8d
--- /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 d62ac2c2ca6c8fd60455082637c374d58a370e28..3859ba0c57a0903a93f6abd07d7b816c83400cba 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 da714570add5d6e4ece3e7be335cc379116191c0..7169fb08bef2c887fbde6cbdf334c2ab49aa9205 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 9f9855037a1eafe4a129ca2a5efe596fb521127d..6e2ab43edb4ac59c54bca4a39973255304e174d9 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 f93d58f56905371def8b6d3bd3089344f5cfe564..0000000000000000000000000000000000000000
--- 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 738353f9631347decb19cc4baae7a5b511cc7f61..f548e79b1136bfdff828e42e790cf2da7af9ee48 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 4f5bff6ab6a6aa9f7fbe6a48a2adbdc097b54f0e..1ac4dcf19b6259a2265862451a539399939bb7d2 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 f06952be902c6f0f60930aa4a70825386f1c661b..4c8c8102f21f10d33a0861c691a73051e03d4e78 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 bb2af814751972a59c5f81b0091b648ba4ddfa9f..ef07bf2c6198b99df4148cbd7cb89b42729dda4c 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 1aaae53ae1665af17092a1759f64c775ec204cd4..cd4d383d37eef21743e8d9ddc532a4cec5d7a825 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 5a21764d7edb4e7bbd6b38ace3720096601f8c39..22607137bf164242b1996c4cc2c7b5981ca352b0 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 6f43f54a4f5b4ab0175a56592b5229158cc45b50..2fb8be9cadfd8d6eaf4ac58f6502205877252059 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 dc250abc0e3a0583d58c9d77b179a0aa90683c5a..50838d1a5d0ef0e7511eba2eb60eeca1ccbe89e0 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 684b58b8b24c475a55385ace9350fbfd057e9eea..94103c1fac4775d873326537542f0cec71b61804 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 0000000000000000000000000000000000000000..d136362a0ecec42a6e4ce6176f06d80ed80bfdbd
--- /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 aed91121afc1117609dc029d10cab95e228d8854..923cf6b62c607e23509c775318f9855be89a2417 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 b225970d7e3d08204592ab9163a7b2298ebf8e25..aecf5a83d287200718d04695328a71358c22604a 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 048b62ebaf52569f8fb67c3c7fc2a230ed4eed34..e18290d7d28de9c95c1c7f74d817b579f0166122 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 b9e4b101d51ce00735f8734d8ef4c0f3eabf83f3..71e6579428ff0dd8c091f252bd81590e72cc18a8 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 8d163c35bc558a6ac0ad235fb61f596a19b7f7e4..00147ff854cbecd8e824d91d4f13cd907abd992d 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 66086a841b1090fd97a1853354a2c601d1b9d7ed..15ddadaef5ee3ceb86ff0fb40c23ee0e5645d2f1 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 0000000000000000000000000000000000000000..c7844e9313f1516dbe222dc6ab3003d1e866f572
--- /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 0000000000000000000000000000000000000000..2b72314c3ca3fce11f8dc61c09391f15fc8962e1
--- /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 7784f04724296a54a62cb57d01d615c86655becb..0603e02c391baba0ada1aa3d7fb73e5168177816 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 a94b3c62ec487764fc56686146b97f339e54f736..5c8e46e21354f8f80f7bd4999067f071b1ccbcd1 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 3ced9dcbda3b89dac6dace5a2034770c53b73a4d..134395caf17a2c391f40b83c20688acea49ecfd0 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 531c66d891178fcc1064308b1ac1bd87f06866c2..92c392082191631e6ce7e98a4ad02c1efa4b288e 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 94b58eba1cb16a9e910b784885eaab2e0e96d336..1c33cfa737a08d0a4814c9e772a2ebbde68eaa90 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 8d5cf3a134860780ee594052f50728d3bd5d0bc3..9931e8bac0a220c834448fff74ea65218a37b452 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 151df0f31c2b499aa106f7a81e1262bca092aae2..3c3daf176c6de3a1aacfe0bf2a902d79152f0a22 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 5e1c18d4741d1efaa3f24d4e46e02a0f61cdb05e..74db35c23896190e96edfaf0654084fbfaf5c23e 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 16dd8785af68acd8047dabe472a03e7ed8ec6b68..dc27a4337cff92f565b995890506e4a8495954f3 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 8d8b538ac6f66fd8090768c0eb9d25071cef5e80..6004986c6777851ee8e7e10f8560be2d993ddb75 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 8a3389a40d997389e1e09e087053a4664f1a1f80..3ce72e0bd7ddbe426619db582413867ebfde578d 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 1a23165b0b21fffe5fa6e58e631223b801683022..56f122492176296361857e2e29ef5cdceb94e9e7 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 40bc2c38adedeb17b94df8d115da6e3627cb5fa4..c2753e520245563707ac0ae0c92aadac0de73442 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 4aaf42ebee3ab1121ee01cacfe28230f941050c1..a9873cbcdd5acd7e2a071d3673104fa9ca5b7e32 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 c6a16a61787ff94b4e2b8fe2610b4c3cbbb664cc..1c2a04bf66e3909a52e47e437b401afb8d7289eb 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 6f08d2bc79acc9dff6e1d1ab66a3179a7c2bccb1..0796352b6d731f71e2b2bd0b245d709fd336342d 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 dcb87b4de0408c3e55d3bb08e13aaa9752f30837..b4166279f590602d5294a98409991bfe9d749c9a 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 c4de6033408c1c42234ae0b726148ca1166edb13..459e788f85efabb4f438092ebc5869d87e1206b5 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 2520321b6dc243284084c029d48d1a5c6927c58b..6f736ff871a39b3a8cd268fe30fa9dfd7cf28a26 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 a2ead6c43bb8a29a9b7d520f5bed37816cfddef7..7307c459cdda933bb0ca3a85e642e30f52238625 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 063df632271c7820eb8de60c349957e3673497d7..a1d78a5682a44052bfc4bf32432ac9d90d59b86f 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 e912c6b53ae56de2e4a73678babac5ed030f601f..27458680b0be4639cc638b4b51ee2aa4a6461b73 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 01c237d8287aa0903d93584dd05bdecf97f8addd..b987b718bb5fdf9892a899c1923034b94fa0ed77 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}