From e0e18976c9c6f6e9dbe0115b9408fa0bbfd96b15 Mon Sep 17 00:00:00 2001
From: Nora Abi Akar <nora.abiakar@gmail.com>
Date: Fri, 18 Jun 2021 16:12:54 +0300
Subject: [PATCH] Labels instead of indices for placeable item identfication.
 (#1504)

New structs and types:
* `cell_tag_type` (std::string): for labelling placeable items on a cell. The label refers to a number of items placed on a locset, equal to the number of locations in a locset. The number of locations in not always known to the user, so the previous way of using indices for items was no longer sufficient.
* `lid_selection_policy`: for allowing a user to select a single item from a group of items sharing a label. Currently only `round_robin` and `assert_univalent` are supported.
* `cell_local_label_type` and `cell_global_label_type`: for identifying the target and source of a connection or gap_junction connection.
* `cell_label_ranges`, and `cell_labels_and_gids`: for propagating information about the labelled items on the cell from the cell groups back to the simulation and communicator.
* `label_resolution_map` and `resolver`: for selecting an item (and retaining state) from a labelled group of items on a cell according to a user-selected policy.

Changes to the model-initialization:
* The `communicator` now needs `label_resolution_maps` constructed from the cell group data in order to build the `connections` vectors.
* The `simulation_state` object handles the transfer of the information when it is constructed.
* Spike exchange at runtime remains unchanged, because `communicator::connections` remains unchanged.

Changes to cells, cell_groups and recipe:
* `decor::place` expects a third label parameter, no longer returns an `lid_range`.
* `lif`, `source`, and `benchmark` cells need source/target labels in their constructors.
* A `cell_group` needs to save data about the gid/labels/lid_ranges of each cell, to propagate back to the `communicator` constructor.
* Connections/gap junction connections are formed between {label, policy} pairs on cells instead of indices.
* `num_sources`, `num_targets`, `num_gap_junction_sites` deleted from `recipe`.

Additional changes:
* Add MPI wrapper for exchanging vectors of strings.
* Corresponding updates to unit tests, Python wrapper, C++ and Python examples, documentation.

Fixes #1394
---
 arbor/CMakeLists.txt                          |   1 +
 arbor/arbexcept.cpp                           |  35 +-
 arbor/benchmark_cell_group.cpp                |  14 +-
 arbor/benchmark_cell_group.hpp                |   3 +-
 arbor/cable_cell.cpp                          |  40 +-
 arbor/cable_cell_param.cpp                    |   5 +-
 arbor/cell_group.hpp                          |   6 +
 arbor/cell_group_factory.cpp                  |  16 +-
 arbor/cell_group_factory.hpp                  |   2 +-
 arbor/common_types_io.cpp                     |  10 +
 arbor/communication/communicator.cpp          |  21 +-
 arbor/communication/communicator.hpp          |   2 +
 arbor/communication/dry_run_context.cpp       |  34 +-
 arbor/communication/mpi.hpp                   |  42 ++
 arbor/communication/mpi_context.cpp           |  28 +-
 arbor/connection.hpp                          |  10 +-
 arbor/distributed_context.hpp                 |  57 ++-
 arbor/fvm_layout.cpp                          |   7 +-
 arbor/fvm_lowered_cell.hpp                    |  27 +-
 arbor/fvm_lowered_cell_impl.hpp               | 125 ++---
 arbor/include/arbor/arbexcept.hpp             |  39 +-
 arbor/include/arbor/benchmark_cell.hpp        |   7 +
 arbor/include/arbor/cable_cell.hpp            |  17 +-
 arbor/include/arbor/cable_cell_param.hpp      |   4 +-
 arbor/include/arbor/common_types.hpp          |  46 ++
 arbor/include/arbor/event_generator.hpp       |  90 ++--
 arbor/include/arbor/lif_cell.hpp              |   8 +
 arbor/include/arbor/recipe.hpp                |  23 +-
 arbor/include/arbor/spike_source_cell.hpp     |   5 +
 arbor/include/arbor/symmetric_recipe.hpp      |   4 -
 arbor/label_resolution.cpp                    | 167 +++++++
 arbor/label_resolution.hpp                    | 114 +++++
 arbor/lif_cell_group.cpp                      |  15 +-
 arbor/lif_cell_group.hpp                      |   3 +-
 arbor/mc_cell_group.cpp                       |  31 +-
 arbor/mc_cell_group.hpp                       |   7 +-
 arbor/simulation.cpp                          |  54 +-
 arbor/spike_source_cell_group.cpp             |  10 +-
 arbor/spike_source_cell_group.hpp             |   3 +-
 arbor/symmetric_recipe.cpp                    |   8 -
 arborio/cableio.cpp                           |  58 +--
 doc/concepts/cell.rst                         |  50 +-
 doc/concepts/decor.rst                        |  17 +-
 doc/concepts/interconnectivity.rst            |  33 +-
 doc/concepts/lif_cell.rst                     |   5 +-
 doc/concepts/recipe.rst                       |  58 +--
 doc/concepts/spike_source_cell.rst            |   3 +-
 doc/cpp/cable_cell.rst                        |   5 +-
 doc/cpp/cell.rst                              |  62 ++-
 doc/cpp/interconnectivity.rst                 |  24 +-
 doc/cpp/recipe.rst                            |  30 +-
 doc/fileformat/cable_cell.rst                 |  30 +-
 doc/python/benchmark_cell.rst                 |  10 +-
 doc/python/cell.rst                           |  92 +++-
 doc/python/decor.rst                          |  45 +-
 doc/python/interconnectivity.rst              |  46 +-
 doc/python/lif_cell.rst                       |  15 +
 doc/python/recipe.rst                         |  50 +-
 doc/python/spike_source_cell.rst              |   8 +-
 doc/tutorial/network_ring.rst                 |  63 +--
 doc/tutorial/single_cell_detailed.rst         |  19 +-
 doc/tutorial/single_cell_detailed_recipe.rst  |  63 +--
 doc/tutorial/single_cell_model.rst            |   4 +-
 doc/tutorial/single_cell_recipe.rst           |  26 +-
 example/bench/bench.cpp                       |  23 +-
 example/brunel/brunel.cpp                     |  16 +-
 example/dryrun/branch_cell.hpp                |   6 +-
 example/dryrun/dryrun.cpp                     |  16 +-
 example/gap_junctions/gap_junctions.cpp       |  31 +-
 example/generators/generators.cpp             |  12 +-
 example/generators/readme.md                  |  50 +-
 example/lfp/lfp.cpp                           |   5 +-
 example/probe-demo/probe-demo.cpp             |   3 +-
 example/ring/branch_cell.hpp                  |   9 +-
 example/ring/ring.cpp                         |  18 +-
 example/single/single.cpp                     |   3 +-
 python/cells.cpp                              | 114 +++--
 python/event_generator.cpp                    |   6 +-
 python/event_generator.hpp                    |   6 +-
 python/example/brunel.py                      |  15 +-
 python/example/dynamic-catalogue.py           |   6 -
 python/example/mpi.py                         |   6 -
 python/example/network_ring.py                |  14 +-
 python/example/single_cell_cable.py           |   4 +-
 python/example/single_cell_detailed.py        |   8 +-
 python/example/single_cell_detailed_recipe.py |  16 +-
 python/example/single_cell_model.py           |   4 +-
 python/example/single_cell_nml.py             |  10 +-
 python/example/single_cell_recipe.py          |   7 +-
 python/example/single_cell_stdp.py            |  16 +-
 python/example/single_cell_swc.py             |  10 +-
 python/identifiers.cpp                        |  86 +++-
 python/recipe.cpp                             |  33 +-
 python/recipe.hpp                             |  33 --
 python/single_cell_model.cpp                  |  14 +-
 python/test/unit/test_cable_probes.py         |  12 +-
 python/test/unit/test_catalogues.py           |   6 -
 python/test/unit/test_event_generators.py     |  17 +-
 python/test/unit/test_simulator.py            | 252 ++++++++++
 python/test/unit/test_spikes.py               |   8 +-
 .../test_domain_decompositions.py             |  38 +-
 .../test/unit_distributed/test_simulator.py   |  10 +-
 test/common_cells.cpp                         |  10 +-
 test/simple_recipes.hpp                       |   8 -
 test/unit-distributed/test_communicator.cpp   | 272 ++++++++--
 .../test_domain_decomposition.cpp             |  23 +-
 test/unit-distributed/test_mpi.cpp            |  34 ++
 test/unit/CMakeLists.txt                      |   1 +
 test/unit/test_cable_cell.cpp                 |  56 ++-
 test/unit/test_domain_decomposition.cpp       |  34 +-
 test/unit/test_event_delivery.cpp             |  17 +-
 test/unit/test_event_generators.cpp           |  66 ++-
 test/unit/test_fvm_layout.cpp                 | 112 ++---
 test/unit/test_fvm_lowered.cpp                | 469 ++++++++++++------
 test/unit/test_label_resolution.cpp           | 318 ++++++++++++
 test/unit/test_lif_cell_group.cpp             |  44 +-
 test/unit/test_mc_cell_group.cpp              |  15 +-
 test/unit/test_mc_cell_group_gpu.cpp          |   7 +-
 test/unit/test_merge_events.cpp               |  51 +-
 test/unit/test_probe.cpp                      |  98 ++--
 test/unit/test_recipe.cpp                     | 173 ++-----
 test/unit/test_s_expr.cpp                     |  51 +-
 test/unit/test_simulation.cpp                 |  12 +-
 test/unit/test_spike_source.cpp               |  20 +-
 test/unit/test_spikes.cpp                     |   6 +-
 test/unit/test_synapses.cpp                   |   8 +-
 126 files changed, 3130 insertions(+), 1674 deletions(-)
 create mode 100644 arbor/label_resolution.cpp
 create mode 100644 arbor/label_resolution.hpp
 create mode 100644 python/test/unit/test_simulator.py
 create mode 100644 test/unit/test_label_resolution.cpp

diff --git a/arbor/CMakeLists.txt b/arbor/CMakeLists.txt
index e9779a4a..2af505ec 100644
--- a/arbor/CMakeLists.txt
+++ b/arbor/CMakeLists.txt
@@ -23,6 +23,7 @@ set(arbor_sources
     hardware/power.cpp
     io/locked_ostream.cpp
     io/serialize_hex.cpp
+    label_resolution.cpp
     lif_cell_group.cpp
     mc_cell_group.cpp
     mechcat.cpp
diff --git a/arbor/arbexcept.cpp b/arbor/arbexcept.cpp
index 2a384b51..13158e36 100644
--- a/arbor/arbexcept.cpp
+++ b/arbor/arbexcept.cpp
@@ -22,37 +22,14 @@ bad_cell_description::bad_cell_description(cell_kind kind, cell_gid_type gid):
     kind(kind)
 {}
 
-bad_target_description::bad_target_description(cell_gid_type gid, cell_size_type rec_val, cell_size_type cell_val):
-    arbor_exception(pprintf("Model building error on cell {}: recipe::num_targets(gid={}) = {} is greater than the number of synapses on the cell = {}", gid, gid, rec_val, cell_val)),
-    gid(gid), rec_val(rec_val), cell_val(cell_val)
-{}
-
-bad_source_description::bad_source_description(cell_gid_type gid, cell_size_type rec_val, cell_size_type cell_val):
-    arbor_exception(pprintf("Model building error on cell {}: recipe::num_sources(gid={}) = {} is not equal to the number of detectors on the cell = {}", gid, gid, rec_val, cell_val)),
-    gid(gid), rec_val(rec_val), cell_val(cell_val)
-{}
-
 bad_connection_source_gid::bad_connection_source_gid(cell_gid_type gid, cell_gid_type src_gid, cell_size_type num_cells):
     arbor_exception(pprintf("Model building error on cell {}: connection source gid {} is out of range: there are only {} cells in the model, in the range [{}:{}].", gid, src_gid, num_cells, 0, num_cells-1)),
     gid(gid), src_gid(src_gid), num_cells(num_cells)
 {}
 
-bad_connection_source_lid::bad_connection_source_lid(cell_gid_type gid, cell_lid_type src_lid, cell_size_type num_sources):
-    arbor_exception(pprintf("Model building error on cell {}: connection source index {} is out of range. Cell {} has {} sources", gid, src_lid, gid, num_sources) +
-                    (num_sources? pprintf(", in the range [{}:{}].",  0, num_sources-1) : ".")),
-    gid(gid), src_lid(src_lid), num_sources(num_sources)
-{}
-
-bad_connection_target_lid::bad_connection_target_lid(cell_gid_type gid, cell_lid_type tgt_lid, cell_size_type num_targets):
-    arbor_exception(pprintf("Model building error on cell {}: connection target index {} is out of range. Cell {} has {} targets", gid, tgt_lid, gid, num_targets) +
-                    (num_targets ? pprintf(", in the range [{}:{}].", 0, num_targets-1) : ".")),
-    gid(gid), tgt_lid(tgt_lid), num_targets(num_targets)
-{}
-
-bad_event_generator_target_lid::bad_event_generator_target_lid(cell_gid_type gid, cell_lid_type tgt_lid, cell_size_type num_targets):
-    arbor_exception(pprintf("Model building error on cell {}: event_generator target index {} is out of range. Cell {} has {} targets", gid, tgt_lid, gid, num_targets) +
-                    (num_targets ? pprintf(", in the range [{}:{}].", 0, num_targets-1) : ".")),
-    gid(gid), tgt_lid(tgt_lid), num_targets(num_targets)
+bad_connection_label::bad_connection_label(cell_gid_type gid, const cell_tag_type& label, const std::string& msg):
+    arbor_exception(pprintf("Model building error on cell {}: connection endpoint label \"{}\": {}.", gid, label, msg)),
+    gid(gid), label(label)
 {}
 
 bad_global_property::bad_global_property(cell_kind kind):
@@ -71,10 +48,10 @@ gj_unsupported_domain_decomposition::gj_unsupported_domain_decomposition(cell_gi
     gid_1(gid_1)
 {}
 
-bad_gj_connection_lid::bad_gj_connection_lid(cell_gid_type gid, cell_member_type site):
-    arbor_exception(pprintf("Model building error on cell {}: gap junction index {} on cell {} does not exist)", gid, site.gid, site.index)),
+gj_unsupported_lid_selection_policy::gj_unsupported_lid_selection_policy(cell_gid_type gid, cell_tag_type label):
+    arbor_exception(pprintf("Model building error on cell {}: gap junction site label \"{}\" must be univalent.", gid, label)),
     gid(gid),
-    site(site)
+    label(label)
 {}
 
 gj_kind_mismatch::gj_kind_mismatch(cell_gid_type gid_0, cell_gid_type gid_1):
diff --git a/arbor/benchmark_cell_group.cpp b/arbor/benchmark_cell_group.cpp
index edd6dbe2..a2ed5f34 100644
--- a/arbor/benchmark_cell_group.cpp
+++ b/arbor/benchmark_cell_group.cpp
@@ -6,16 +6,19 @@
 #include <arbor/recipe.hpp>
 #include <arbor/schedule.hpp>
 
+#include "benchmark_cell_group.hpp"
 #include "cell_group.hpp"
+#include "label_resolution.hpp"
 #include "profile/profiler_macro.hpp"
-#include "benchmark_cell_group.hpp"
 
 #include "util/span.hpp"
 
 namespace arb {
 
 benchmark_cell_group::benchmark_cell_group(const std::vector<cell_gid_type>& gids,
-                                           const recipe& rec):
+                                           const recipe& rec,
+                                           cell_label_range& cg_sources,
+                                           cell_label_range& cg_targets):
     gids_(gids)
 {
     for (auto gid: gids_) {
@@ -29,6 +32,13 @@ benchmark_cell_group::benchmark_cell_group(const std::vector<cell_gid_type>& gid
         cells_.push_back(util::any_cast<benchmark_cell>(rec.get_cell_description(gid)));
     }
 
+    for (const auto& c: cells_) {
+        cg_sources.add_cell();
+        cg_targets.add_cell();
+        cg_sources.add_label(c.source, {0, 1});
+        cg_targets.add_label(c.target, {0, 1});
+    }
+
     reset();
 }
 
diff --git a/arbor/benchmark_cell_group.hpp b/arbor/benchmark_cell_group.hpp
index 62c27abf..ea64cbce 100644
--- a/arbor/benchmark_cell_group.hpp
+++ b/arbor/benchmark_cell_group.hpp
@@ -8,12 +8,13 @@
 
 #include "cell_group.hpp"
 #include "epoch.hpp"
+#include "label_resolution.hpp"
 
 namespace arb {
 
 class benchmark_cell_group: public cell_group {
 public:
-    benchmark_cell_group(const std::vector<cell_gid_type>& gids, const recipe& rec);
+    benchmark_cell_group(const std::vector<cell_gid_type>& gids, const recipe& rec, cell_label_range& cg_sources, cell_label_range& cg_targets);
 
     cell_kind get_cell_kind() const override;
 
diff --git a/arbor/cable_cell.cpp b/arbor/cable_cell.cpp
index 35268b7e..7cd703b4 100644
--- a/arbor/cable_cell.cpp
+++ b/arbor/cable_cell.cpp
@@ -50,8 +50,8 @@ struct cable_cell_impl {
     // The decorations on the cell.
     decor decorations;
 
-    // The lid ranges of placements.
-    std::vector<lid_range> placed_lid_ranges;
+    // The placeable label to lid_range map
+    dynamic_typed_map<constant_type<std::unordered_multimap<cell_tag_type, lid_range>>::type> labeled_lid_ranges;
 
     cable_cell_impl(const arb::morphology& m, const label_dict& labels, const decor& decorations):
         provider(m, labels),
@@ -79,7 +79,7 @@ struct cable_cell_impl {
     }
 
     template <typename Item>
-    lid_range place(const locset& ls, const Item& item) {
+    void place(const locset& ls, const Item& item, const cell_tag_type& label) {
         auto& mm = get_location_map(item);
         cell_lid_type& lid = placed_count.get<Item>();
         cell_lid_type first = lid;
@@ -88,7 +88,9 @@ struct cable_cell_impl {
             placed<Item> p{l, lid++, item};
             mm.push_back(p);
         }
-        return lid_range(first, lid);
+        auto range = lid_range(first, lid);
+        auto& lid_ranges = labeled_lid_ranges.get<Item>();
+        lid_ranges.insert(std::make_pair(label, range));
     }
 
     template <typename T>
@@ -134,13 +136,6 @@ struct cable_cell_impl {
     mextent concrete_region(const region& r) const {
         return thingify(r, provider);
     }
-
-    lid_range placed_lid_range(unsigned id) const {
-        if (id>=placed_lid_ranges.size()) {
-            throw cable_cell_error(util::pprintf("invalid placement identifier {}", id));
-        }
-        return placed_lid_ranges[id];
-    }
 };
 
 using impl_ptr = std::unique_ptr<cable_cell_impl, void (*)(cable_cell_impl*)>;
@@ -151,15 +146,12 @@ impl_ptr make_impl(cable_cell_impl* c) {
 void cable_cell_impl::init(const decor& d) {
     for (const auto& p: d.paintings()) {
         auto& where = p.first;
-        std::visit([this, &where] (auto&& what) {this->paint(where, what);},
-                   p.second);
+        std::visit([this, &where] (auto&& what) {this->paint(where, what);}, p.second);
     }
     for (const auto& p: d.placements()) {
-        auto& where = p.first;
-        auto lids =
-            std::visit([this, &where] (auto&& what) {return this->place(where, what);},
-                       p.second);
-        placed_lid_ranges.push_back(lids);
+        auto& where = std::get<0>(p);
+        auto& label = std::get<2>(p);
+        std::visit([this, &where, &label] (auto&& what) {return this->place(where, what, label);}, std::get<1>(p));
     }
 }
 
@@ -213,8 +205,16 @@ const cable_cell_parameter_set& cable_cell::default_parameters() const {
     return impl_->decorations.defaults();
 }
 
-lid_range cable_cell::placed_lid_range(unsigned id) const {
-    return impl_->placed_lid_range(id);
+const std::unordered_multimap<cell_tag_type, lid_range>& cable_cell::detector_ranges() const {
+    return impl_->labeled_lid_ranges.get<threshold_detector>();
+}
+
+const std::unordered_multimap<cell_tag_type, lid_range>& cable_cell::synapse_ranges() const {
+    return impl_->labeled_lid_ranges.get<mechanism_desc>();
+}
+
+const std::unordered_multimap<cell_tag_type, lid_range>& cable_cell::gap_junction_ranges() const {
+    return impl_->labeled_lid_ranges.get<gap_junction_site>();
 }
 
 } // namespace arb
diff --git a/arbor/cable_cell_param.cpp b/arbor/cable_cell_param.cpp
index c8666e45..c238a48e 100644
--- a/arbor/cable_cell_param.cpp
+++ b/arbor/cable_cell_param.cpp
@@ -113,9 +113,8 @@ void decor::paint(region where, paintable what) {
     paintings_.push_back({std::move(where), std::move(what)});
 }
 
-unsigned decor::place(locset where, placeable what) {
-    placements_.push_back({std::move(where), std::move(what)});
-    return std::size(placements_)-1;
+void decor::place(locset where, placeable what, cell_tag_type label) {
+    placements_.push_back({std::move(where), std::move(what), std::move(label)});
 }
 
 void decor::set_default(defaultable what) {
diff --git a/arbor/cell_group.hpp b/arbor/cell_group.hpp
index c624946b..b89b8275 100644
--- a/arbor/cell_group.hpp
+++ b/arbor/cell_group.hpp
@@ -14,6 +14,12 @@
 #include "event_binner.hpp"
 #include "util/rangeutil.hpp"
 
+// The specialized cell_group constructors are expected to accept at least:
+// - The gid vector of the cells belonging to the cell_group.
+// - The recipe.
+// - 2 cell_label_range objects, one for the targets and one for the sources,
+//   that are to be filled during the construction of the cell group. These
+//   ranges are needed to map (gid, label) pairs to their corresponding lid sets.
 namespace arb {
 
 using event_lane_subrange = util::subrange_view_type<std::vector<pse_vector>>;
diff --git a/arbor/cell_group_factory.cpp b/arbor/cell_group_factory.cpp
index 68f3f9a2..1351299f 100644
--- a/arbor/cell_group_factory.cpp
+++ b/arbor/cell_group_factory.cpp
@@ -26,29 +26,29 @@ cell_group_factory cell_kind_implementation(
 
     switch (ck) {
     case cell_kind::cable:
-        return [bk, ctx](const gid_vector& gids, const recipe& rec) {
-            return make_cell_group<mc_cell_group>(gids, rec, make_fvm_lowered_cell(bk, ctx));
+        return [bk, ctx](const gid_vector& gids, const recipe& rec, cell_label_range& cg_sources, cell_label_range& cg_targets) {
+            return make_cell_group<mc_cell_group>(gids, rec, cg_sources, cg_targets, make_fvm_lowered_cell(bk, ctx));
         };
 
     case cell_kind::spike_source:
         if (bk!=backend_kind::multicore) break;
 
-        return [](const gid_vector& gids, const recipe& rec) {
-            return make_cell_group<spike_source_cell_group>(gids, rec);
+        return [](const gid_vector& gids, const recipe& rec, cell_label_range& cg_sources, cell_label_range& cg_targets) {
+            return make_cell_group<spike_source_cell_group>(gids, rec, cg_sources, cg_targets);
         };
 
     case cell_kind::lif:
         if (bk!=backend_kind::multicore) break;
 
-        return [](const gid_vector& gids, const recipe& rec) {
-            return make_cell_group<lif_cell_group>(gids, rec);
+        return [](const gid_vector& gids, const recipe& rec, cell_label_range& cg_sources, cell_label_range& cg_targets) {
+            return make_cell_group<lif_cell_group>(gids, rec, cg_sources, cg_targets);
         };
 
     case cell_kind::benchmark:
         if (bk!=backend_kind::multicore) break;
 
-        return [](const gid_vector& gids, const recipe& rec) {
-            return make_cell_group<benchmark_cell_group>(gids, rec);
+        return [](const gid_vector& gids, const recipe& rec, cell_label_range& cg_sources, cell_label_range& cg_targets) {
+            return make_cell_group<benchmark_cell_group>(gids, rec, cg_sources, cg_targets);
         };
 
     default: ;
diff --git a/arbor/cell_group_factory.hpp b/arbor/cell_group_factory.hpp
index 90352653..7723d822 100644
--- a/arbor/cell_group_factory.hpp
+++ b/arbor/cell_group_factory.hpp
@@ -18,7 +18,7 @@
 namespace arb {
 
 using cell_group_factory = std::function<
-        cell_group_ptr(const std::vector<cell_gid_type>&, const recipe&)>;
+        cell_group_ptr(const std::vector<cell_gid_type>&, const recipe&, cell_label_range& cg_sources, cell_label_range& cg_targets)>;
 
 cell_group_factory cell_kind_implementation(
         cell_kind, backend_kind, const execution_context&);
diff --git a/arbor/common_types_io.cpp b/arbor/common_types_io.cpp
index c08d0b66..366ba114 100644
--- a/arbor/common_types_io.cpp
+++ b/arbor/common_types_io.cpp
@@ -4,6 +4,16 @@
 
 namespace arb {
 
+std::ostream& operator<<(std::ostream& o, lid_selection_policy policy) {
+    switch (policy) {
+    case lid_selection_policy::round_robin:
+        return o << "round_robin";
+    case lid_selection_policy::assert_univalent:
+        return o << "univalent";
+    }
+    return o;
+}
+
 std::ostream& operator<<(std::ostream& o, arb::cell_member_type m) {
     return o << m.gid << ':' << m.index;
 }
diff --git a/arbor/communication/communicator.cpp b/arbor/communication/communicator.cpp
index 16b08fb5..30835fff 100644
--- a/arbor/communication/communicator.cpp
+++ b/arbor/communication/communicator.cpp
@@ -23,8 +23,10 @@
 namespace arb {
 
 communicator::communicator(const recipe& rec,
-                          const domain_decomposition& dom_dec,
-                          execution_context& ctx)
+                           const domain_decomposition& dom_dec,
+                           const label_resolution_map& source_resolution_map,
+                           const label_resolution_map& target_resolution_map,
+                           execution_context& ctx)
 {
     distributed_ = ctx.distributed;
     thread_pool_ = ctx.thread_pool;
@@ -78,17 +80,10 @@ communicator::communicator(const recipe& rec,
     std::vector<cell_size_type> src_counts(num_domains_);
 
     for (const auto& cell: gid_infos) {
-        auto num_targets = rec.num_targets(cell.gid);
         for (auto c: cell.conns) {
             if (c.source.gid >= num_total_cells) {
                 throw arb::bad_connection_source_gid(cell.gid, c.source.gid, num_total_cells);
             }
-            if (auto num_sources = rec.num_sources(c.source.gid); c.source.index >= num_sources) {
-                throw arb::bad_connection_source_lid(cell.gid, c.source.index, num_sources);
-            }
-            if (c.dest >= num_targets) {
-                throw arb::bad_connection_target_lid(cell.gid, c.dest, num_targets);
-            }
             const auto src = dom_dec.gid_domain(c.source.gid);
             src_domains.push_back(src);
             src_counts[src]++;
@@ -102,10 +97,14 @@ communicator::communicator(const recipe& rec,
     util::make_partition(connection_part_, src_counts);
     auto offsets = connection_part_;
     std::size_t pos = 0;
+    auto target_resolver = resolver(&target_resolution_map);
     for (const auto& cell: gid_infos) {
-        for (auto c: cell.conns) {
+        auto source_resolver = resolver(&source_resolution_map);
+        for (const auto& c: cell.conns) {
             const auto i = offsets[src_domains[pos]]++;
-            connections_[i] = {c.source, c.dest, c.weight, c.delay, cell.index_on_domain};
+            auto src_lid = source_resolver.resolve(c.source);
+            auto tgt_lid = target_resolver.resolve({cell.gid, c.dest});
+            connections_[i] = {{c.source.gid, src_lid}, tgt_lid, c.weight, c.delay, cell.index_on_domain};
             ++pos;
         }
     }
diff --git a/arbor/communication/communicator.hpp b/arbor/communication/communicator.hpp
index 2cc98a78..33e8f2f7 100644
--- a/arbor/communication/communicator.hpp
+++ b/arbor/communication/communicator.hpp
@@ -31,6 +31,8 @@ public:
 
     explicit communicator(const recipe& rec,
                           const domain_decomposition& dom_dec,
+                          const label_resolution_map& source_resolver,
+                          const label_resolution_map& target_resolver,
                           execution_context& ctx);
 
     /// The range of event queues that belong to cells in group i.
diff --git a/arbor/communication/dry_run_context.cpp b/arbor/communication/dry_run_context.cpp
index cfa6ddf0..1ef55220 100644
--- a/arbor/communication/dry_run_context.cpp
+++ b/arbor/communication/dry_run_context.cpp
@@ -4,8 +4,10 @@
 
 #include <arbor/spike.hpp>
 
-#include <distributed_context.hpp>
-#include <threading/threading.hpp>
+#include "distributed_context.hpp"
+#include "label_resolution.hpp"
+#include "threading/threading.hpp"
+#include "util/rangeutil.hpp"
 
 namespace arb {
 
@@ -24,7 +26,7 @@ struct dry_run_context_impl {
         gathered_spikes.reserve(local_size*num_ranks_);
 
         for (count_type i = 0; i < num_ranks_; i++) {
-            gathered_spikes.insert(gathered_spikes.end(), local_spikes.begin(), local_spikes.end());
+            util::append(gathered_spikes, local_spikes);
         }
 
         for (count_type i = 0; i < num_ranks_; i++) {
@@ -51,7 +53,7 @@ struct dry_run_context_impl {
         gathered_gids.reserve(local_size*num_ranks_);
 
         for (count_type i = 0; i < num_ranks_; i++) {
-            gathered_gids.insert(gathered_gids.end(), local_gids.begin(), local_gids.end());
+            util::append(gathered_gids, local_gids);
         }
 
         for (count_type i = 0; i < num_ranks_; i++) {
@@ -68,6 +70,25 @@ struct dry_run_context_impl {
         return gathered_vector<cell_gid_type>(std::move(gathered_gids), std::move(partition));
     }
 
+    cell_label_range gather_cell_label_range(const cell_label_range& local_ranges) const {
+        cell_label_range global_ranges;
+        for (unsigned i = 0; i < num_ranks_; i++) {
+            global_ranges.append(local_ranges);
+        }
+        return global_ranges;
+    }
+
+    cell_labels_and_gids gather_cell_labels_and_gids(const cell_labels_and_gids& local_labels_and_gids) const {
+        auto global_ranges = gather_cell_label_range(local_labels_and_gids.label_range);
+        auto gids = gather_gids(local_labels_and_gids.gids);
+        return cell_labels_and_gids(global_ranges, gids.values());
+    }
+
+    template <typename T>
+    std::vector<T> gather(T value, int) const {
+        return std::vector<T>(num_ranks_, value);
+    }
+
     int id() const { return 0; }
 
     int size() const { return num_ranks_; }
@@ -81,11 +102,6 @@ struct dry_run_context_impl {
     template <typename T>
     T sum(T value) const { return value * num_ranks_; }
 
-    template <typename T>
-    std::vector<T> gather(T value, int) const {
-        return std::vector<T>(num_ranks_, value);
-    }
-
     void barrier() const {}
 
     std::string name() const { return "dryrun"; }
diff --git a/arbor/communication/mpi.hpp b/arbor/communication/mpi.hpp
index f07412af..972e95e7 100644
--- a/arbor/communication/mpi.hpp
+++ b/arbor/communication/mpi.hpp
@@ -12,6 +12,7 @@
 
 #include "communication/gathered_vector.hpp"
 #include "profile/profiler_macro.hpp"
+#include "util/rangeutil.hpp"
 #include "util/partition.hpp"
 
 namespace arb {
@@ -144,6 +145,47 @@ std::vector<T> gather_all(const std::vector<T>& values, MPI_Comm comm) {
     return buffer;
 }
 
+inline std::vector<std::string> gather_all(const std::vector<std::string>& values, MPI_Comm comm) {
+    using traits = mpi_traits<char>;
+    std::vector<int> counts_individual, counts_total, displs_individual, displs_total;
+
+    // vector of individual string sizes
+    std::vector<int> individual_sizes(values.size());
+    std::transform(values.begin(), values.end(), individual_sizes.begin(), [](const std::string& val){return int(val.size());});
+
+    counts_individual = gather_all(individual_sizes, comm);
+    counts_total      = gather_all(util::sum(individual_sizes, 0), comm);
+
+    util::make_partition(displs_total, counts_total);
+    std::vector<char> buffer(displs_total.back());
+
+    // Concatenate string data
+    std::string values_concat;
+    for (const auto& v: values) {
+        values_concat += v;
+    }
+
+    // Cast to ptr
+    // const_cast required for MPI implementations that don't use const* in
+    // their interfaces.
+    std::string::value_type* ptr = const_cast<std::string::value_type*>(values_concat.data());
+    MPI_OR_THROW(MPI_Allgatherv,
+                 ptr, counts_total[rank(comm)], traits::mpi_type(),  // send buffer
+                 buffer.data(), counts_total.data(), displs_total.data(), traits::mpi_type(), // receive buffer
+                 comm);
+
+    // Construct the vector of strings
+    std::vector<std::string> string_buffer;
+    string_buffer.reserve(counts_individual.size());
+
+    auto displs_individual_part = util::make_partition(displs_individual, counts_individual);
+    for (const auto& str_range: displs_individual_part) {
+        string_buffer.emplace_back(buffer.begin()+str_range.first, buffer.begin()+str_range.second);
+    }
+
+    return string_buffer;
+}
+
 /// Gather all of a distributed vector
 /// Retains the meta data (i.e. vector partition)
 template <typename T>
diff --git a/arbor/communication/mpi_context.cpp b/arbor/communication/mpi_context.cpp
index 9d0bc30b..fa1e57c1 100644
--- a/arbor/communication/mpi_context.cpp
+++ b/arbor/communication/mpi_context.cpp
@@ -14,6 +14,7 @@
 
 #include "communication/mpi.hpp"
 #include "distributed_context.hpp"
+#include "label_resolution.hpp"
 
 namespace arb {
 
@@ -38,6 +39,28 @@ struct mpi_context_impl {
         return mpi::gather_all_with_partition(local_gids, comm_);
     }
 
+    cell_label_range gather_cell_label_range(const cell_label_range& local_ranges) const {
+        std::vector<cell_size_type> sizes;
+        std::vector<cell_tag_type> labels;
+        std::vector<lid_range> ranges;
+        sizes  = mpi::gather_all(local_ranges.sizes(), comm_);
+        labels = mpi::gather_all(local_ranges.labels(), comm_);
+        ranges = mpi::gather_all(local_ranges.ranges(), comm_);
+        return cell_label_range(sizes, labels, ranges);
+    }
+
+    cell_labels_and_gids gather_cell_labels_and_gids(const cell_labels_and_gids& local_labels_and_gids) const {
+        auto global_ranges = gather_cell_label_range(local_labels_and_gids.label_range);
+        auto global_gids = mpi::gather_all(local_labels_and_gids.gids, comm_);
+
+        return cell_labels_and_gids(global_ranges, global_gids);
+    }
+
+    template <typename T>
+    std::vector<T> gather(T value, int root) const {
+        return mpi::gather(value, root, comm_);
+    }
+
     std::string name() const { return "MPI"; }
     int id() const { return rank_; }
     int size() const { return size_; }
@@ -57,11 +80,6 @@ struct mpi_context_impl {
         return mpi::reduce(value, MPI_SUM, comm_);
     }
 
-    template <typename T>
-    std::vector<T> gather(T value, int root) const {
-        return mpi::gather(value, root, comm_);
-    }
-
     void barrier() const {
         mpi::barrier(comm_);
     }
diff --git a/arbor/connection.hpp b/arbor/connection.hpp
index 05be9556..0779fda6 100644
--- a/arbor/connection.hpp
+++ b/arbor/connection.hpp
@@ -10,11 +10,11 @@ namespace arb {
 class connection {
 public:
     connection() = default;
-    connection( cell_member_type src,
-                cell_lid_type dest,
-                float w,
-                float d,
-                cell_gid_type didx=cell_gid_type(-1)):
+    connection(cell_member_type src,
+               cell_lid_type dest,
+               float w,
+               float d,
+               cell_gid_type didx=cell_gid_type(-1)):
         source_(src),
         destination_(dest),
         weight_(w),
diff --git a/arbor/distributed_context.hpp b/arbor/distributed_context.hpp
index 1037e3f2..ab3ec658 100644
--- a/arbor/distributed_context.hpp
+++ b/arbor/distributed_context.hpp
@@ -7,6 +7,7 @@
 #include <arbor/util/pp_util.hpp>
 
 #include "communication/gathered_vector.hpp"
+#include "label_resolution.hpp"
 
 namespace arb {
 
@@ -63,6 +64,18 @@ public:
         return impl_->gather_gids(local_gids);
     }
 
+    cell_label_range gather_cell_label_range(const cell_label_range& local_ranges) const {
+        return impl_->gather_cell_label_range(local_ranges);
+    }
+
+    cell_labels_and_gids gather_cell_labels_and_gids(const cell_labels_and_gids& local_labels_and_gids) const {
+        return impl_->gather_cell_labels_and_gids(local_labels_and_gids);
+    }
+
+    std::vector<std::string> gather(std::string value, int root) const {
+        return impl_->gather(value, root);
+    }
+
     int id() const {
         return impl_->id();
     }
@@ -81,23 +94,24 @@ public:
 
     ARB_PP_FOREACH(ARB_PUBLIC_COLLECTIVES_, ARB_COLLECTIVE_TYPES_);
 
-    std::vector<std::string> gather(std::string value, int root) const {
-        return impl_->gather(value, root);
-    }
-
 private:
     struct interface {
         virtual gathered_vector<arb::spike>
             gather_spikes(const spike_vector& local_spikes) const = 0;
         virtual gathered_vector<cell_gid_type>
             gather_gids(const gid_vector& local_gids) const = 0;
+        virtual cell_label_range
+            gather_cell_label_range(const cell_label_range& local_ranges) const = 0;
+        virtual cell_labels_and_gids
+            gather_cell_labels_and_gids(const cell_labels_and_gids& local_labels_and_gids) const = 0;
+        virtual std::vector<std::string>
+            gather(std::string value, int root) const = 0;
         virtual int id() const = 0;
         virtual int size() const = 0;
         virtual void barrier() const = 0;
         virtual std::string name() const = 0;
 
         ARB_PP_FOREACH(ARB_INTERFACE_COLLECTIVES_, ARB_COLLECTIVE_TYPES_)
-        virtual std::vector<std::string> gather(std::string value, int root) const = 0;
 
         virtual ~interface() {}
     };
@@ -111,10 +125,22 @@ private:
         gather_spikes(const spike_vector& local_spikes) const override {
             return wrapped.gather_spikes(local_spikes);
         }
-        virtual gathered_vector<cell_gid_type>
+        gathered_vector<cell_gid_type>
         gather_gids(const gid_vector& local_gids) const override {
             return wrapped.gather_gids(local_gids);
         }
+        cell_label_range
+        gather_cell_label_range(const cell_label_range& local_ranges) const override {
+            return wrapped.gather_cell_label_range(local_ranges);
+        }
+        cell_labels_and_gids
+        gather_cell_labels_and_gids(const cell_labels_and_gids& local_labels_and_gids) const override {
+            return wrapped.gather_cell_labels_and_gids(local_labels_and_gids);
+        }
+        std::vector<std::string>
+        gather(std::string value, int root) const override {
+            return wrapped.gather(value, root);
+        }
         int id() const override {
             return wrapped.id();
         }
@@ -130,10 +156,6 @@ private:
 
         ARB_PP_FOREACH(ARB_WRAP_COLLECTIVES_, ARB_COLLECTIVE_TYPES_)
 
-        std::vector<std::string> gather(std::string value, int root) const override {
-            return wrapped.gather(value, root);
-        }
-
         Impl wrapped;
     };
 
@@ -157,6 +179,18 @@ struct local_context {
                 {0u, static_cast<count_type>(local_gids.size())}
         );
     }
+    cell_label_range
+    gather_cell_label_range(const cell_label_range& local_ranges) const {
+        return local_ranges;
+    }
+    cell_labels_and_gids
+    gather_cell_labels_and_gids(const cell_labels_and_gids& local_labels_and_gids) const {
+        return local_labels_and_gids;
+    }
+    template <typename T>
+    std::vector<T> gather(T value, int) const {
+        return {std::move(value)};
+    }
 
     int id() const { return 0; }
 
@@ -171,9 +205,6 @@ struct local_context {
     template <typename T>
     T sum(T value) const { return value; }
 
-    template <typename T>
-    std::vector<T> gather(T value, int) const { return {std::move(value)}; }
-
     void barrier() const {}
 
     std::string name() const { return "local"; }
diff --git a/arbor/fvm_layout.cpp b/arbor/fvm_layout.cpp
index 1ec58645..d8a4ea8b 100644
--- a/arbor/fvm_layout.cpp
+++ b/arbor/fvm_layout.cpp
@@ -24,7 +24,6 @@
 
 namespace arb {
 
-using util::append;
 using util::assign;
 using util::assign_by;
 using util::count_along;
@@ -208,7 +207,7 @@ cv_geometry cv_geometry_from_ends(const cable_cell& cell, const locset& lset) {
         }
 
         sort(cables);
-        append(geom.cv_cables, std::move(cables));
+        util::append(geom.cv_cables, std::move(cables));
         geom.cv_cables_divs.push_back(geom.cv_cables.size());
         ++cv_index;
     }
@@ -289,6 +288,7 @@ namespace impl {
 // Merge CV geometry lists in-place.
 
 cv_geometry& append(cv_geometry& geom, const cv_geometry& right) {
+    using util::append;
     using impl::tail;
     using impl::append_offset;
     using impl::append_divs;
@@ -322,6 +322,8 @@ cv_geometry& append(cv_geometry& geom, const cv_geometry& right) {
 // Combine two fvm_cv_geometry groups in-place.
 
 fvm_cv_discretization& append(fvm_cv_discretization& dczn, const fvm_cv_discretization& right) {
+    using util::append;
+
     append(dczn.geometry, right.geometry);
 
     append(dczn.face_conductance, right.face_conductance);
@@ -705,6 +707,7 @@ fvm_voltage_interpolant fvm_axial_current(const cable_cell& cell, const fvm_cv_d
 // Only target numbers need to be shifted.
 
 fvm_mechanism_data& append(fvm_mechanism_data& left, const fvm_mechanism_data& right) {
+    using util::append;
     using impl::append_offset;
     using impl::append_divs;
 
diff --git a/arbor/fvm_lowered_cell.hpp b/arbor/fvm_lowered_cell.hpp
index 3e857f8a..38550be2 100644
--- a/arbor/fvm_lowered_cell.hpp
+++ b/arbor/fvm_lowered_cell.hpp
@@ -193,17 +193,34 @@ struct probe_association_map {
     }
 };
 
+struct fvm_initialization_data {
+    // Map from gid to integration domain id
+    std::vector<fvm_index_type> cell_to_intdom;
+
+    // Handles for accessing lowered cell.
+    std::vector<target_handle> target_handles;
+
+    // Maps probe ids to probe handles and tags.
+    probe_association_map probe_map;
+
+    // Structs required for {gid, label} to lid resolution
+    cell_label_range source_data;
+    cell_label_range target_data;
+    cell_label_range gap_junction_data;
+
+    // Maps storing number of sources/targets per cell.
+    std::unordered_map<cell_gid_type, fvm_size_type> num_sources;
+    std::unordered_map<cell_gid_type, fvm_size_type> num_targets;
+};
+
 // Common base class for FVM implementation on host or gpu back-end.
 
 struct fvm_lowered_cell {
     virtual void reset() = 0;
 
-    virtual void initialize(
+    virtual fvm_initialization_data initialize(
         const std::vector<cell_gid_type>& gids,
-        const recipe& rec,
-        std::vector<fvm_index_type>& cell_to_intdom,
-        std::vector<target_handle>& target_handles,
-        probe_association_map& probe_map) = 0;
+        const recipe& rec) = 0;
 
     virtual fvm_integration_result integrate(
         fvm_value_type tfinal,
diff --git a/arbor/fvm_lowered_cell_impl.hpp b/arbor/fvm_lowered_cell_impl.hpp
index 8bdc287d..d427ad39 100644
--- a/arbor/fvm_lowered_cell_impl.hpp
+++ b/arbor/fvm_lowered_cell_impl.hpp
@@ -26,6 +26,7 @@
 #include "execution_context.hpp"
 #include "fvm_layout.hpp"
 #include "fvm_lowered_cell.hpp"
+#include "label_resolution.hpp"
 #include "matrix.hpp"
 #include "profile/profiler_macro.hpp"
 #include "sampler_map.hpp"
@@ -50,12 +51,9 @@ public:
 
     void reset() override;
 
-    void initialize(
+    fvm_initialization_data initialize(
         const std::vector<cell_gid_type>& gids,
-        const recipe& rec,
-        std::vector<fvm_index_type>& cell_to_intdom,
-        std::vector<target_handle>& target_handles,
-        probe_association_map& probe_map) override;
+        const recipe& rec) override;
 
     fvm_integration_result integrate(
         value_type tfinal,
@@ -66,6 +64,7 @@ public:
     std::vector<fvm_gap_junction> fvm_gap_junctions(
         const std::vector<cable_cell>& cells,
         const std::vector<cell_gid_type>& gids,
+        const cell_label_range& gj_data,
         const recipe& rec,
         const fvm_cv_discretization& D);
 
@@ -367,12 +366,9 @@ void fvm_lowered_cell_impl<Backend>::assert_voltage_bounded(fvm_value_type bound
 }
 
 template <typename Backend>
-void fvm_lowered_cell_impl<Backend>::initialize(
+fvm_initialization_data fvm_lowered_cell_impl<Backend>::initialize(
     const std::vector<cell_gid_type>& gids,
-    const recipe& rec,
-    std::vector<fvm_index_type>& cell_to_intdom,
-    std::vector<target_handle>& target_handles,
-    probe_association_map& probe_map)
+    const recipe& rec)
 {
     using std::any_cast;
     using util::count_along;
@@ -380,6 +376,8 @@ void fvm_lowered_cell_impl<Backend>::initialize(
     using util::value_by_key;
     using util::keys;
 
+    fvm_initialization_data fvm_info;
+
     set_gpu();
 
     std::vector<cable_cell> cells;
@@ -397,6 +395,34 @@ void fvm_lowered_cell_impl<Backend>::initialize(
                }
            });
 
+    // Populate source, target and gap_junction data vectors.
+    for (auto i : util::make_span(ncell)) {
+        auto gid = gids[i];
+        const auto& c = cells[i];
+
+        fvm_info.source_data.add_cell();
+        fvm_info.target_data.add_cell();
+        fvm_info.gap_junction_data.add_cell();
+
+        unsigned count = 0;
+        for (const auto& [label, range]: c.detector_ranges()) {
+            fvm_info.source_data.add_label(label, range);
+            count+=(range.end - range.begin);
+        }
+        fvm_info.num_sources[gid] = count;
+
+        count = 0;
+        for (const auto& [label, range]: c.synapse_ranges()) {
+            fvm_info.target_data.add_label(label, range);
+            count+=(range.end - range.begin);
+        }
+        fvm_info.num_targets[gid] = count;
+
+        for (const auto& [label, range]: c.gap_junction_ranges()) {
+            fvm_info.gap_junction_data.add_label(label, range);
+        }
+    }
+
     cable_cell_global_properties global_props;
     try {
         std::any rec_props = rec.get_global_properties(cell_kind::cable);
@@ -412,26 +438,6 @@ void fvm_lowered_cell_impl<Backend>::initialize(
     // (Throws cable_cell_error on failure.)
     check_global_properties(global_props);
 
-    // Sanity check recipe; find max num_sources and
-    // create a list of the global identifiers for the spike sources
-
-    std::vector<fvm_size_type> nsources;
-    for (auto cell_idx: make_span(ncell)) {
-        cell_gid_type gid = gids[cell_idx];
-        auto& cell = cells[cell_idx];
-
-        auto num_sources = rec.num_sources(gid);
-        nsources.push_back(num_sources);
-
-        if (num_sources != cell.detectors().size()) {
-            throw arb::bad_source_description(gid, num_sources, cell.detectors().size());
-        }
-        auto cell_targets = util::sum_by(cell.synapses(), [](auto& syn) { return syn.second.size(); });
-        if (rec.num_targets(gid) > cell_targets) {
-            throw arb::bad_target_description(gid, rec.num_targets(gid), cell_targets);
-        }
-    }
-
     const mechanism_catalogue* catalogue = global_props.catalogue;
 
     // Mechanism instantiator helper.
@@ -443,7 +449,7 @@ void fvm_lowered_cell_impl<Backend>::initialize(
 
     check_voltage_mV_ = global_props.membrane_voltage_limit_mV;
 
-    auto nintdom = fvm_intdom(rec, gids, cell_to_intdom);
+    auto nintdom = fvm_intdom(rec, gids, fvm_info.cell_to_intdom);
 
     // Discretize cells, build matrix.
 
@@ -451,11 +457,11 @@ void fvm_lowered_cell_impl<Backend>::initialize(
 
     std::vector<index_type> cv_to_intdom(D.size());
     std::transform(D.geometry.cv_to_cell.begin(), D.geometry.cv_to_cell.end(), cv_to_intdom.begin(),
-                   [&cell_to_intdom](index_type i){ return cell_to_intdom[i]; });
+                   [&fvm_info](index_type i){ return fvm_info.cell_to_intdom[i]; });
 
     arb_assert(D.n_cell() == ncell);
     matrix_ = matrix<backend>(D.geometry.cv_parent, D.geometry.cell_cv_divs,
-                              D.cv_capacitance, D.face_conductance, D.cv_area, cell_to_intdom);
+                              D.cv_capacitance, D.face_conductance, D.cv_area, fvm_info.cell_to_intdom);
     sample_events_ = sample_event_stream(nintdom);
 
     // Discretize mechanism data.
@@ -464,16 +470,20 @@ void fvm_lowered_cell_impl<Backend>::initialize(
 
     // Discretize and build gap junction info.
 
-    auto gj_vector = fvm_gap_junctions(cells, gids, rec, D);
+    auto gj_vector = fvm_gap_junctions(cells, gids, fvm_info.gap_junction_data, rec, D);
 
     // Fill src_to_spike and cv_to_cell vectors only if mechanisms with post_events implemented are present.
     post_events_ = mech_data.post_events;
-    auto max_detector = post_events_ ? util::max_value(nsources) : 0;
+    auto max_detector = 0;
+    if (post_events_) {
+        auto it = util::max_element_by(fvm_info.num_sources, [](auto elem) {return util::second(elem);});
+        max_detector = it->second;
+    }
     std::vector<fvm_index_type> src_to_spike, cv_to_cell;
 
     if (post_events_) {
         for (auto cell_idx: make_span(ncell)) {
-            for (auto lid: make_span(nsources[cell_idx])) {
+            for (auto lid: make_span(fvm_info.num_sources[gids[cell_idx]])) {
                 src_to_spike.push_back(cell_idx * max_detector + lid);
             }
         }
@@ -510,7 +520,7 @@ void fvm_lowered_cell_impl<Backend>::initialize(
         state_->configure_stimulus(mech_data.stimuli);
     }
 
-    target_handles.resize(mech_data.n_target);
+    fvm_info.target_handles.resize(mech_data.n_target);
 
     // Keep track of mechanisms by name for probe lookup.
     std::unordered_map<std::string, mechanism*> mechptr_by_name;
@@ -545,11 +555,11 @@ void fvm_lowered_cell_impl<Backend>::initialize(
 
                 target_handle handle(mech_id, i, cv_to_intdom[cv]);
                 if (config.multiplicity.empty()) {
-                    target_handles[config.target[i]] = handle;
+                    fvm_info.target_handles[config.target[i]] = handle;
                 }
                 else {
                     for (auto j: make_span(multiplicity_part[i])) {
-                        target_handles[config.target[j]] = handle;
+                        fvm_info.target_handles[config.target[j]] = handle;
                     }
                 }
             }
@@ -601,14 +611,14 @@ void fvm_lowered_cell_impl<Backend>::initialize(
         for (cell_lid_type i: count_along(rec_probes)) {
             probe_info& pi = rec_probes[i];
             resolve_probe_address(probe_data, cells, cell_idx, std::move(pi.address),
-                D, mech_data, target_handles, mechptr_by_name);
+                D, mech_data, fvm_info.target_handles, mechptr_by_name);
 
             if (!probe_data.empty()) {
                 cell_member_type probe_id{gid, i};
-                probe_map.tag[probe_id] = pi.tag;
+                fvm_info.probe_map.tag[probe_id] = pi.tag;
 
                 for (auto& data: probe_data) {
-                    probe_map.data.insert({probe_id, std::move(data)});
+                    fvm_info.probe_map.data.insert({probe_id, std::move(data)});
                 }
             }
         }
@@ -617,6 +627,8 @@ void fvm_lowered_cell_impl<Backend>::initialize(
     threshold_watcher_ = backend::voltage_watcher(*state_, detector_cv, detector_threshold, context_);
 
     reset();
+
+    return fvm_info;
 }
 
 // Get vector of gap_junctions
@@ -624,39 +636,40 @@ template <typename Backend>
 std::vector<fvm_gap_junction> fvm_lowered_cell_impl<Backend>::fvm_gap_junctions(
         const std::vector<cable_cell>& cells,
         const std::vector<cell_gid_type>& gids,
+        const cell_label_range& gap_junction_data,
         const recipe& rec, const fvm_cv_discretization& D) {
 
-    std::vector<fvm_gap_junction> v;
+    std::vector<fvm_gap_junction> gj_vec;
 
     std::unordered_map<cell_gid_type, std::vector<unsigned>> gid_to_cvs;
     for (auto cell_idx: util::make_span(0, D.n_cell())) {
-        if (!rec.num_gap_junction_sites(gids[cell_idx])) continue;
+        if (rec.gap_junctions_on(gids[cell_idx]).empty()) continue;
 
-        gid_to_cvs[gids[cell_idx]].reserve(rec.num_gap_junction_sites(gids[cell_idx]));
         const auto& cell_gj = cells[cell_idx].gap_junction_sites();
+        gid_to_cvs[gids[cell_idx]].reserve(cell_gj.size());
 
         for (auto gj : cell_gj) {
             auto cv = D.geometry.location_cv(cell_idx, gj.loc, cv_prefer::cv_nonempty);
             gid_to_cvs[gids[cell_idx]].push_back(cv);
         }
     }
-
+    label_resolution_map resolution_map({gap_junction_data, gids});
+    auto gj_resolver = resolver(&resolution_map);
     for (auto gid: gids) {
         auto gj_list = rec.gap_junctions_on(gid);
-        for (auto g: gj_list) {
-            if (g.local >= gid_to_cvs[gid].size()) {
-                throw arb::bad_gj_connection_lid(gid, {gid, g.local});
+        for (const auto& g: gj_list) {
+            if (g.local.policy != lid_selection_policy::assert_univalent) {
+                throw gj_unsupported_lid_selection_policy(gid, g.local.tag);
             }
-            if (g.peer.index >= gid_to_cvs[g.peer.gid].size()) {
-                throw arb::bad_gj_connection_lid(gid, g.peer);
+            if (g.peer.label.policy != lid_selection_policy::assert_univalent) {
+                throw gj_unsupported_lid_selection_policy(g.peer.gid, g.peer.label.tag);
             }
-            auto cv0 = gid_to_cvs[gid][g.local];
-            auto cv1 = gid_to_cvs[g.peer.gid][g.peer.index];
-            v.push_back(fvm_gap_junction(std::make_pair(cv0, cv1), g.ggap * 1e3 / D.cv_area[cv0]));
+            auto cv_local = gid_to_cvs[gid][gj_resolver.resolve({gid, g.local})];
+            auto cv_peer = gid_to_cvs[g.peer.gid][gj_resolver.resolve(g.peer)];
+            gj_vec.emplace_back(fvm_gap_junction(std::make_pair(cv_local, cv_peer), g.ggap * 1e3 / D.cv_area[cv_local]));
         }
     }
-
-    return v;
+    return gj_vec;
 }
 
 template <typename Backend>
diff --git a/arbor/include/arbor/arbexcept.hpp b/arbor/include/arbor/arbexcept.hpp
index 2cc0c5ed..2ba55753 100644
--- a/arbor/include/arbor/arbexcept.hpp
+++ b/arbor/include/arbor/arbexcept.hpp
@@ -42,43 +42,16 @@ struct bad_cell_description: arbor_exception {
     cell_kind kind;
 };
 
-struct bad_target_description: arbor_exception {
-    bad_target_description(cell_gid_type gid, cell_size_type rec_val, cell_size_type cell_val);
-    cell_gid_type gid;
-    cell_size_type rec_val, cell_val;
-};
-
-struct bad_source_description: arbor_exception {
-    bad_source_description(cell_gid_type gid, cell_size_type rec_val, cell_size_type cell_val);
-    cell_gid_type gid;
-    cell_size_type rec_val, cell_val;
-};
-
 struct bad_connection_source_gid: arbor_exception {
     bad_connection_source_gid(cell_gid_type gid, cell_gid_type src_gid, cell_size_type num_cells);
     cell_gid_type gid, src_gid;
     cell_size_type num_cells;
 };
 
-struct bad_connection_source_lid: arbor_exception {
-    bad_connection_source_lid(cell_gid_type gid, cell_lid_type src_lid, cell_size_type num_sources);
-    cell_gid_type gid;
-    cell_lid_type src_lid;
-    cell_size_type num_sources;
-};
-
-struct bad_connection_target_lid: arbor_exception {
-    bad_connection_target_lid(cell_gid_type gid, cell_lid_type tgt_lid, cell_size_type num_targets);
-    cell_gid_type gid;
-    cell_lid_type tgt_lid;
-    cell_size_type num_targets;
-};
-
-struct bad_event_generator_target_lid: arbor_exception {
-    bad_event_generator_target_lid(cell_gid_type gid, cell_lid_type tgt_lid, cell_size_type num_targets);
+struct bad_connection_label: arbor_exception {
+    bad_connection_label(cell_gid_type gid, const cell_tag_type& label, const std::string& msg);
     cell_gid_type gid;
-    cell_lid_type tgt_lid;
-    cell_size_type num_targets;
+    cell_tag_type label;
 };
 
 struct bad_global_property: arbor_exception {
@@ -96,10 +69,10 @@ struct gj_kind_mismatch: arbor_exception {
     cell_gid_type gid_0, gid_1;
 };
 
-struct bad_gj_connection_lid: arbor_exception {
-    bad_gj_connection_lid(cell_gid_type gid, cell_member_type site);
+struct gj_unsupported_lid_selection_policy: arbor_exception {
+    gj_unsupported_lid_selection_policy(cell_gid_type gid, cell_tag_type label);
     cell_gid_type gid;
-    cell_member_type site;
+    cell_tag_type label;
 };
 
 // Domain decomposition errors:
diff --git a/arbor/include/arbor/benchmark_cell.hpp b/arbor/include/arbor/benchmark_cell.hpp
index 44795027..49d93d9f 100644
--- a/arbor/include/arbor/benchmark_cell.hpp
+++ b/arbor/include/arbor/benchmark_cell.hpp
@@ -8,12 +8,19 @@ namespace arb {
 // recipe::cell_kind(gid) returning cell_kind::benchmark
 
 struct benchmark_cell {
+    cell_tag_type source; // Label of source.
+    cell_tag_type target; // Label of target.
+
     // Describes the time points at which spikes are to be generated.
     schedule time_sequence;
 
     // Time taken in ms to advance the cell one ms of simulation time.
     // If equal to 1, then a single cell can be advanced in realtime 
     double realtime_ratio;
+
+    benchmark_cell() = delete;
+    benchmark_cell(cell_tag_type source, cell_tag_type target, schedule seq, double ratio):
+        source(source), target(target), time_sequence(seq), realtime_ratio(ratio) {};
 };
 
 } // namespace arb
diff --git a/arbor/include/arbor/cable_cell.hpp b/arbor/include/arbor/cable_cell.hpp
index cf267d79..2eb12914 100644
--- a/arbor/include/arbor/cable_cell.hpp
+++ b/arbor/include/arbor/cable_cell.hpp
@@ -21,16 +21,6 @@
 
 namespace arb {
 
-// Pair of indexes that describe range of local indices.
-// Returned by cable_cell::place() calls, so that the caller can
-// refer to targets, detectors, etc on the cell.
-struct lid_range {
-    cell_lid_type begin;
-    cell_lid_type end;
-    lid_range(cell_lid_type b, cell_lid_type e):
-        begin(b), end(e) {}
-};
-
 // `cable_sample_range` is simply a pair of `const double*` pointers describing the sequence
 // of double values associated with the cell-wide sample.
 
@@ -299,9 +289,10 @@ public:
     // The default parameter and ion settings on the cell.
     const cable_cell_parameter_set& default_parameters() const;
 
-    // The range of lids assigned to the items with placement index idx, where
-    // the placement index is the value returned by calling decor::place().
-    lid_range placed_lid_range(unsigned idx) const;
+    // The labeled lid_ranges of sources, targets and gap_junctions on the cell;
+    const std::unordered_multimap<cell_tag_type, lid_range>& detector_ranges() const;
+    const std::unordered_multimap<cell_tag_type, lid_range>& synapse_ranges() const;
+    const std::unordered_multimap<cell_tag_type, lid_range>& gap_junction_ranges() const;
 
 private:
     std::unique_ptr<cable_cell_impl, void (*)(cable_cell_impl*)> impl_;
diff --git a/arbor/include/arbor/cable_cell_param.hpp b/arbor/include/arbor/cable_cell_param.hpp
index 3c53621b..af7e6671 100644
--- a/arbor/include/arbor/cable_cell_param.hpp
+++ b/arbor/include/arbor/cable_cell_param.hpp
@@ -247,7 +247,7 @@ struct cable_cell_parameter_set {
 // are to be applied to a morphology in a cable_cell.
 class decor {
     std::vector<std::pair<region, paintable>> paintings_;
-    std::vector<std::pair<locset, placeable>> placements_;
+    std::vector<std::tuple<locset, placeable, cell_tag_type>> placements_;
     cable_cell_parameter_set defaults_;
 
 public:
@@ -256,7 +256,7 @@ public:
     const auto& defaults()   const {return defaults_;   }
 
     void paint(region, paintable);
-    unsigned place(locset, placeable);
+    void place(locset, placeable, cell_tag_type);
     void set_default(defaultable);
 };
 
diff --git a/arbor/include/arbor/common_types.hpp b/arbor/include/arbor/common_types.hpp
index fb7a91e8..e9bacd12 100644
--- a/arbor/include/arbor/common_types.hpp
+++ b/arbor/include/arbor/common_types.hpp
@@ -10,6 +10,7 @@
 #include <functional>
 #include <limits>
 #include <iosfwd>
+#include <string>
 #include <type_traits>
 
 #include <arbor/util/lexcmp_def.hpp>
@@ -32,6 +33,9 @@ using cell_size_type = std::make_unsigned_t<cell_gid_type>;
 
 using cell_lid_type = std::uint32_t;
 
+// Local labels for items within a particular cell-local collection
+using cell_tag_type = std::string;
+
 // For counts of cell-local data.
 
 using cell_local_size_type = std::make_unsigned_t<cell_lid_type>;
@@ -51,7 +55,48 @@ struct cell_member_type {
     cell_lid_type index;
 };
 
+// Pair of indexes that describe range of local indices.
+
+struct lid_range {
+    cell_lid_type begin;
+    cell_lid_type end;
+    lid_range() {};
+    lid_range(cell_lid_type b, cell_lid_type e):
+        begin(b), end(e) {}
+};
+
+// Policy for selecting a cell_lid_type from a range of possible values.
+
+enum class lid_selection_policy {
+    round_robin,
+    assert_univalent // throw if the range of possible lids is wider than 1
+};
+
+// For referring to a labeled placement on an unspecified cell.
+// The placement may be associated with multiple locations, the policy
+// is used to select a specific location.
+
+struct cell_local_label_type {
+    cell_tag_type tag;
+    lid_selection_policy policy;
+
+    cell_local_label_type(cell_tag_type tag, lid_selection_policy policy=lid_selection_policy::assert_univalent):
+        tag(std::move(tag)), policy(policy) {}
+};
+
+// For referring to a labeled placement on a cell identified by gid.
+
+struct cell_global_label_type {
+    cell_gid_type gid;
+    cell_local_label_type label;
+
+    cell_global_label_type(cell_gid_type gid, cell_local_label_type label): gid(gid), label(std::move(label)) {}
+    cell_global_label_type(cell_gid_type gid, cell_tag_type tag): gid(gid), label(std::move(tag)) {}
+    cell_global_label_type(cell_gid_type gid, cell_tag_type tag, lid_selection_policy policy): gid(gid), label(std::move(tag), policy) {}
+};
+
 ARB_DEFINE_LEXICOGRAPHIC_ORDERING(cell_member_type,(a.gid,a.index),(b.gid,b.index))
+ARB_DEFINE_LEXICOGRAPHIC_ORDERING(lid_range,(a.begin, a.end),(b.begin,b.end))
 
 // For storing time values [ms]
 
@@ -91,6 +136,7 @@ enum class binning_kind {
     following, // => round times down to previous event if within binning interval.
 };
 
+std::ostream& operator<<(std::ostream& o, lid_selection_policy m);
 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);
diff --git a/arbor/include/arbor/event_generator.hpp b/arbor/include/arbor/event_generator.hpp
index 95a52452..42e5ac40 100644
--- a/arbor/include/arbor/event_generator.hpp
+++ b/arbor/include/arbor/event_generator.hpp
@@ -32,9 +32,11 @@ namespace arb {
 //     Provide a non-owning view on to the events in the time interval
 //     [to, from).
 //
-// `std::vector<cell_member_type> targets()`
+// `void resolve_label(resolution_function)`
 //
-//     Return a vector of all the targets of the generator.
+//     event_generators are constructed on cable_local_label_types comprising
+//     a label and a selection policy. These labels need to be resolved to a
+//     specific cell_lid_type. This is done using a resolution_function.
 //
 // The `event_seq` type is a pair of `spike_event` pointers that
 // provide a view onto an internally-maintained contiguous sequence
@@ -49,16 +51,21 @@ namespace arb {
 //
 // `event_generator` objects have value semantics, and use type erasure
 // to wrap implementation details. An `event_generator` can be constructed
-// from an onbject of an implementation class Impl that is copy-constructible
+// from an object of an implementation class Impl that is copy-constructable
 // and otherwise provides `reset` and `events` methods following the
 // API described above.
 //
 // Some pre-defined event generators are included:
 //  - `empty_generator`: produces no events
-//  - `schedule_generator`: events to a fixed target according to a time schedule
+//  - `schedule_generator`: produces events according to a time schedule.
+//    A target is selected using a label resolution function for every generated
+//    event.
+//  - `explicit_generator`: is constructed from a vector of {label, time, weight}
+//    objects. Explicit targets are generated from the labels using a resolution
+//    function before the first call to the `events` method.
 
 using event_seq = std::pair<const spike_event*, const spike_event*>;
-
+using resolution_function = std::function<cell_lid_type(const cell_local_label_type&)>;
 
 // The simplest possible generator that generates no events.
 // Declared ahead of event_generator so that it can be used as the default
@@ -68,9 +75,7 @@ struct empty_generator {
     event_seq events(time_type, time_type) {
         return {nullptr, nullptr};
     }
-    std::vector<cell_lid_type> targets() {
-        return {};
-    };
+    void resolve_label(resolution_function) {}
 };
 
 class event_generator {
@@ -102,15 +107,15 @@ public:
         return impl_->events(t0, t1);
     }
 
-    std::vector<cell_lid_type> targets() const {
-        return impl_->targets();
+    void resolve_label(resolution_function label_resolver) {
+        impl_->resolve_label(std::move(label_resolver));
     }
 
 private:
     struct interface {
         virtual void reset() = 0;
+        virtual void resolve_label(resolution_function) = 0;
         virtual event_seq events(time_type, time_type) = 0;
-        virtual std::vector<cell_lid_type> targets() = 0;
         virtual std::unique_ptr<interface> clone() = 0;
         virtual ~interface() {}
     };
@@ -126,14 +131,14 @@ private:
             return wrapped.events(t0, t1);
         }
 
-        std::vector<cell_lid_type> targets() override {
-            return wrapped.targets();
-        }
-
         void reset() override {
             wrapped.reset();
         }
 
+        void resolve_label(resolution_function label_resolver) override {
+            wrapped.resolve_label(std::move(label_resolver));
+        }
+
         std::unique_ptr<interface> clone() override {
             return std::unique_ptr<interface>(new wrap<Impl>(wrapped));
         }
@@ -148,10 +153,14 @@ private:
 // a provided time schedule.
 
 struct schedule_generator {
-    schedule_generator(cell_lid_type target, float weight, schedule sched):
-        target_(target), weight_(weight), sched_(std::move(sched))
+    schedule_generator(cell_local_label_type target, float weight, schedule sched):
+        target_(std::move(target)), weight_(weight), sched_(std::move(sched))
     {}
 
+    void resolve_label(resolution_function label_resolver) {
+        label_resolver_ = std::move(label_resolver);
+    }
+
     void reset() {
         sched_.reset();
     }
@@ -163,19 +172,16 @@ struct schedule_generator {
         events_.reserve(ts.second-ts.first);
 
         for (auto i = ts.first; i!=ts.second; ++i) {
-            events_.push_back(spike_event{target_, *i, weight_});
+            events_.push_back(spike_event{label_resolver_(target_), *i, weight_});
         }
 
         return {events_.data(), events_.data()+events_.size()};
     }
 
-    std::vector<cell_lid_type> targets() {
-        return {target_};
-    }
-
 private:
     pse_vector events_;
-    cell_lid_type target_;
+    cell_local_label_type target_;
+    resolution_function label_resolver_;
     float weight_;
     schedule sched_;
 };
@@ -183,43 +189,50 @@ private:
 // Generate events at integer multiples of dt that lie between tstart and tstop.
 
 inline event_generator regular_generator(
-    cell_lid_type target,
+    cell_local_label_type target,
     float weight,
     time_type tstart,
     time_type dt,
     time_type tstop=terminal_time)
 {
-    return schedule_generator(target, weight, regular_schedule(tstart, dt, tstop));
+    return schedule_generator(std::move(target), weight, regular_schedule(tstart, dt, tstop));
 }
 
 template <typename RNG>
 inline event_generator poisson_generator(
-    cell_lid_type target,
+    cell_local_label_type target,
     float weight,
     time_type tstart,
     time_type rate_kHz,
     const RNG& rng)
 {
-    return schedule_generator(target, weight, poisson_schedule(tstart, rate_kHz, rng));
+    return schedule_generator(std::move(target), weight, poisson_schedule(tstart, rate_kHz, rng));
 }
 
 
 // Generate events from a predefined sorted event sequence.
 
 struct explicit_generator {
+    struct labeled_synapse_event {
+        cell_local_label_type label;
+        time_type time;
+        float weight;
+    };
+
+    using lse_vector = std::vector<labeled_synapse_event>;
+
     explicit_generator() = default;
     explicit_generator(const explicit_generator&) = default;
     explicit_generator(explicit_generator&&) = default;
 
-    template <typename Seq>
-    explicit_generator(const Seq& events):
-        start_index_(0)
-    {
-        using std::begin;
-        using std::end;
+    explicit_generator(const lse_vector& events):
+        input_events_(events), start_index_(0) {}
 
-        events_ = pse_vector(begin(events), end(events));
-        arb_assert(std::is_sorted(events_.begin(), events_.end()));
+    void resolve_label(resolution_function label_resolver) {
+        for (const auto& e: input_events_) {
+            events_.push_back({label_resolver(e.label), e.time, e.weight});
+        }
+        std::sort(events_.begin(), events_.end());
     }
 
     void reset() {
@@ -237,13 +250,8 @@ struct explicit_generator {
         return {lb, ub};
     }
 
-    std::vector<cell_lid_type> targets() {
-        std::vector<cell_lid_type> tgts;
-        std::transform(events_.begin(), events_.end(), std::back_inserter(tgts), [](auto&& e){ return e.target;});
-        return tgts;
-    }
-
 private:
+    lse_vector input_events_;
     pse_vector events_;
     std::size_t start_index_ = 0;
 };
diff --git a/arbor/include/arbor/lif_cell.hpp b/arbor/include/arbor/lif_cell.hpp
index 68d694ca..35c2fa98 100644
--- a/arbor/include/arbor/lif_cell.hpp
+++ b/arbor/include/arbor/lif_cell.hpp
@@ -1,9 +1,14 @@
 #pragma once
 
+#include <arbor/common_types.hpp>
+
 namespace arb {
 
 // Model parameters of leaky integrate and fire neuron model.
 struct lif_cell {
+    cell_tag_type source; // Label of source.
+    cell_tag_type target; // Label of target.
+
     // Neuronal parameters.
     double tau_m = 10;    // Membrane potential decaying constant [ms].
     double V_th = 10;     // Firing threshold [mV].
@@ -12,6 +17,9 @@ struct lif_cell {
     double V_m = E_L;     // Initial value of the Membrane potential [mV].
     double V_reset = E_L; // Reset potential [mV].
     double t_ref = 2;     // Refractory period [ms].
+
+    lif_cell() = delete;
+    lif_cell(cell_tag_type source, cell_tag_type  target): source(std::move(source)), target(std::move(target)) {}
 };
 
 } // namespace arb
diff --git a/arbor/include/arbor/recipe.hpp b/arbor/include/arbor/recipe.hpp
index 63fe1a21..76be9ddc 100644
--- a/arbor/include/arbor/recipe.hpp
+++ b/arbor/include/arbor/recipe.hpp
@@ -41,24 +41,24 @@ struct cell_connection {
     // Connection end-points are represented by pairs
     // (cell index, source/target index on cell).
 
-    cell_member_type source;
-    cell_lid_type dest;
+    cell_global_label_type source;
+    cell_local_label_type dest;
 
     float weight;
     float delay;
 
-    cell_connection(cell_member_type src, cell_lid_type dst, float w, float d):
-        source(src), dest(dst), weight(w), delay(d)
-    {}
+    cell_connection(cell_global_label_type src, cell_local_label_type dst, float w, float d):
+        source(std::move(src)), dest(std::move(dst)), weight(w), delay(d) {}
 };
 
 struct gap_junction_connection {
-    cell_member_type peer;
-    cell_lid_type local;
+    cell_global_label_type peer;
+    cell_local_label_type local;
+
     double ggap;
 
-    gap_junction_connection(cell_member_type peer, cell_lid_type local, double g):
-        peer(peer), local(local), ggap(g) {}
+    gap_junction_connection(cell_global_label_type peer, cell_local_label_type local, double g):
+        peer(std::move(peer)), local(std::move(local)), ggap(g) {}
 };
 
 class recipe {
@@ -69,11 +69,6 @@ public:
     virtual util::unique_any get_cell_description(cell_gid_type gid) const = 0;
     virtual cell_kind get_cell_kind(cell_gid_type) const = 0;
 
-    virtual cell_size_type num_sources(cell_gid_type) const { return 0; }
-    virtual cell_size_type num_targets(cell_gid_type) const { return 0; }
-    virtual cell_size_type num_gap_junction_sites(cell_gid_type gid)  const {
-        return gap_junctions_on(gid).size();
-    }
     virtual std::vector<event_generator> event_generators(cell_gid_type) const {
         return {};
     }
diff --git a/arbor/include/arbor/spike_source_cell.hpp b/arbor/include/arbor/spike_source_cell.hpp
index 0e9bbf17..9d042827 100644
--- a/arbor/include/arbor/spike_source_cell.hpp
+++ b/arbor/include/arbor/spike_source_cell.hpp
@@ -1,5 +1,6 @@
 #pragma once
 
+#include <arbor/common_types.hpp>
 #include <arbor/schedule.hpp>
 
 namespace arb {
@@ -8,7 +9,11 @@ namespace arb {
 // recipe::cell_kind(gid) returning cell_kind::spike_source
 
 struct spike_source_cell {
+    cell_tag_type source; // Label of source.
     schedule seq;
+
+    spike_source_cell() = delete;
+    spike_source_cell(cell_tag_type source, schedule seq): source(std::move(source)), seq(std::move(seq)) {};
 };
 
 } // namespace arb
diff --git a/arbor/include/arbor/symmetric_recipe.hpp b/arbor/include/arbor/symmetric_recipe.hpp
index 81c8336e..7fdf8732 100644
--- a/arbor/include/arbor/symmetric_recipe.hpp
+++ b/arbor/include/arbor/symmetric_recipe.hpp
@@ -31,10 +31,6 @@ public:
 
     cell_kind get_cell_kind(cell_gid_type i) const override;
 
-    cell_size_type num_sources(cell_gid_type i) const override;
-
-    cell_size_type num_targets(cell_gid_type i) const override;
-
     std::vector<event_generator> event_generators(cell_gid_type i) const override;
 
     std::vector<cell_connection> connections_on(cell_gid_type i) const override;
diff --git a/arbor/label_resolution.cpp b/arbor/label_resolution.cpp
new file mode 100644
index 00000000..43c9a688
--- /dev/null
+++ b/arbor/label_resolution.cpp
@@ -0,0 +1,167 @@
+#include <iterator>
+#include <vector>
+
+#include <arbor/assert.hpp>
+#include <arbor/arbexcept.hpp>
+#include <arbor/common_types.hpp>
+#include <arbor/util/expected.hpp>
+
+#include "label_resolution.hpp"
+#include "util/partition.hpp"
+#include "util/rangeutil.hpp"
+#include "util/span.hpp"
+
+namespace arb {
+
+// cell_label_range methods
+cell_label_range::cell_label_range(std::vector<cell_size_type> size_vec,
+                                   std::vector<cell_tag_type> label_vec,
+                                   std::vector<lid_range> range_vec):
+    sizes_(std::move(size_vec)), labels_(std::move(label_vec)), ranges_(std::move(range_vec))
+{
+    arb_assert(check_invariant());
+};
+
+void cell_label_range::add_cell() {
+    sizes_.push_back(0);
+}
+
+void cell_label_range::add_label(cell_tag_type label, lid_range range) {
+    if (sizes_.empty()) throw arbor_internal_error("adding label to cell_label_range without cell");
+    ++sizes_.back();
+    labels_.push_back(std::move(label));
+    ranges_.push_back(std::move(range));
+}
+
+void cell_label_range::append(cell_label_range other) {
+    using std::make_move_iterator;
+    sizes_.insert(sizes_.end(), make_move_iterator(other.sizes_.begin()), make_move_iterator(other.sizes_.end()));
+    labels_.insert(labels_.end(), make_move_iterator(other.labels_.begin()), make_move_iterator(other.labels_.end()));
+    ranges_.insert(ranges_.end(), make_move_iterator(other.ranges_.begin()), make_move_iterator(other.ranges_.end()));
+}
+
+bool cell_label_range::check_invariant() const {
+    const cell_size_type count = std::accumulate(sizes_.begin(), sizes_.end(), cell_size_type(0));
+    return count==labels_.size() && count==ranges_.size();
+}
+
+// cell_labels_and_gids methods
+cell_labels_and_gids::cell_labels_and_gids(cell_label_range lr, std::vector<cell_gid_type> gid):
+    label_range(std::move(lr)), gids(std::move(gid))
+{
+    if (label_range.sizes().size()!=gids.size()) throw arbor_internal_error("cell_label_range and gid count mismatch");
+}
+
+void cell_labels_and_gids::append(cell_labels_and_gids other) {
+    label_range.append(other.label_range);
+    gids.insert(gids.end(), make_move_iterator(other.gids.begin()), make_move_iterator(other.gids.end()));
+}
+
+bool cell_labels_and_gids::check_invariant() const {
+    return label_range.check_invariant() && label_range.sizes().size()==gids.size();
+}
+
+// label_resolution_map methods
+cell_size_type label_resolution_map::range_set::size() const {
+    return ranges_partition.back();
+}
+
+lid_hopefully label_resolution_map::range_set::at(unsigned idx) const {
+    if (size() < 1) return util::unexpected("no valid lids");
+    auto part = util::partition_view(ranges_partition);
+    // Index of the range containing idx.
+    auto ridx = part.index(idx);
+
+    // First element of the range containing idx.
+    const auto& start = ranges.at(ridx).begin;
+
+    // Offset into the range containing idx.
+    const auto& range_part = part.at(ridx);
+    auto offset = idx - range_part.first;
+
+    return start + offset;
+}
+
+const label_resolution_map::range_set& label_resolution_map::at(const cell_gid_type& gid, const cell_tag_type& tag) const {
+    return map.at(gid).at(tag);
+}
+
+std::size_t label_resolution_map::count(const cell_gid_type& gid, const cell_tag_type& tag) const {
+    if (!map.count(gid)) return 0u;
+    return map.at(gid).count(tag);
+}
+
+label_resolution_map::label_resolution_map(const cell_labels_and_gids& clg) {
+    arb_assert(clg.label_range.check_invariant());
+    const auto& gids = clg.gids;
+    const auto& labels = clg.label_range.labels();
+    const auto& ranges = clg.label_range.ranges();
+    const auto& sizes = clg.label_range.sizes();
+
+    std::vector<cell_size_type> label_divs;
+    auto partn = util::make_partition(label_divs, sizes);
+    for (auto i: util::count_along(partn)) {
+        auto gid = gids[i];
+
+        std::unordered_map<cell_tag_type, range_set> m;
+        for (auto label_idx: util::make_span(partn[i])) {
+            const auto range = ranges[label_idx];
+            auto size = int(range.end - range.begin);
+            if (size < 0) {
+                throw arb::arbor_internal_error("label_resolution_map: invalid lid_range");
+            }
+            auto& range_set = m[labels[label_idx]];
+            range_set.ranges.push_back(range);
+            range_set.ranges_partition.push_back(range_set.ranges_partition.back() + size);
+        }
+        if (!map.insert({gid, std::move(m)}).second) {
+            throw arb::arbor_internal_error("label_resolution_map: duplicate gid");
+        }
+    }
+}
+// variant state methods
+lid_hopefully round_robin_state::update(const label_resolution_map::range_set& range_set) {
+    auto lid = range_set.at(state);
+    if (lid) state = (state+1) % range_set.size();
+    return lid;
+}
+
+lid_hopefully assert_univalent_state::update(const label_resolution_map::range_set& range_set) {
+    if (range_set.size() != 1) {
+        return util::unexpected("range is not univalent");
+    }
+    // Get the lid of the only element.
+    return range_set.at(0);
+}
+
+// resolver methods
+resolver::state_variant resolver::construct_state(lid_selection_policy pol) {
+    switch (pol) {
+    case lid_selection_policy::round_robin:
+        return round_robin_state();
+    case lid_selection_policy::assert_univalent:
+       return assert_univalent_state();
+    default: return assert_univalent_state();
+    }
+}
+
+cell_lid_type resolver::resolve(const cell_global_label_type& iden) {
+    if (!label_map_->count(iden.gid, iden.label.tag)) {
+        throw arb::bad_connection_label(iden.gid, iden.label.tag, "label does not exist");
+    }
+    const auto& range_set = label_map_->at(iden.gid, iden.label.tag);
+
+    // Construct state if if doesn't exist
+    if (!state_map_[iden.gid][iden.label.tag].count(iden.label.policy)) {
+        state_map_[iden.gid][iden.label.tag][iden.label.policy] = construct_state(iden.label.policy);
+    }
+
+    auto lid = std::visit([range_set](auto& state) { return state.update(range_set); }, state_map_[iden.gid][iden.label.tag][iden.label.policy]);
+    if (!lid) {
+        throw arb::bad_connection_label(iden.gid, iden.label.tag, lid.error());
+    }
+    return lid.value();
+}
+
+} // namespace arb
+
diff --git a/arbor/label_resolution.hpp b/arbor/label_resolution.hpp
new file mode 100644
index 00000000..5912672e
--- /dev/null
+++ b/arbor/label_resolution.hpp
@@ -0,0 +1,114 @@
+#pragma once
+
+#include <unordered_map>
+#include <vector>
+
+#include <arbor/arbexcept.hpp>
+#include <arbor/common_types.hpp>
+#include <arbor/util/expected.hpp>
+
+#include "util/partition.hpp"
+
+namespace arb {
+
+using lid_hopefully = arb::util::expected<cell_lid_type, std::string>;
+
+// class containing the data required for {cell, label} to lid resolution.
+// `sizes` is a partitioning vector for associating a cell with a set of
+// (label, range) pairs in `labels`, `ranges`.
+// gids of the cells are unknown.
+class cell_label_range {
+public:
+    cell_label_range() = default;
+    cell_label_range(cell_label_range&&) = default;
+    cell_label_range(const cell_label_range&) = default;
+    cell_label_range& operator=(const cell_label_range&) = default;
+    cell_label_range& operator=(cell_label_range&&) = default;
+
+    cell_label_range(std::vector<cell_size_type> size_vec, std::vector<cell_tag_type> label_vec, std::vector<lid_range> range_vec);
+
+    void add_cell();
+
+    void add_label(cell_tag_type label, lid_range range);
+
+    void append(cell_label_range other);
+
+    bool check_invariant() const;
+
+    const auto& sizes() const { return sizes_; }
+    const auto& labels() const { return labels_; }
+    const auto& ranges() const { return ranges_; }
+
+
+private:
+    // The number of labels associated with each cell.
+    std::vector<cell_size_type> sizes_;
+
+    // The labels corresponding to each cell, partitioned according to sizes_.
+    std::vector<cell_tag_type> labels_;
+
+    // The lid_range corresponding to each label.
+    std::vector<lid_range> ranges_;
+};
+
+// Struct for associating each cell of `cell_label_range` with a gid.
+struct cell_labels_and_gids {
+    cell_labels_and_gids() = default;
+    cell_labels_and_gids(cell_label_range lr, std::vector<cell_gid_type> gids);
+
+    void append(cell_labels_and_gids other);
+
+    bool check_invariant() const;
+
+    cell_label_range label_range;
+    std::vector<cell_gid_type> gids;
+};
+
+// Class constructed from `cell_labels_and_ranges`:
+// Represents the information in the object in a more
+// structured manner for lid resolution in `resolver`
+class label_resolution_map {
+public:
+    struct range_set {
+        std::vector<lid_range> ranges;
+        std::vector<unsigned> ranges_partition = {0};
+        cell_size_type size() const;
+        lid_hopefully at(unsigned idx) const;
+    };
+
+    label_resolution_map() = delete;
+    explicit label_resolution_map(const cell_labels_and_gids&);
+
+    const range_set& at(const cell_gid_type& gid, const cell_tag_type& tag) const;
+    std::size_t count(const cell_gid_type& gid, const cell_tag_type& tag) const;
+
+private:
+    std::unordered_map<cell_gid_type, std::unordered_map<cell_tag_type, range_set>> map;
+};
+
+struct round_robin_state {
+    cell_size_type state = 0;
+    round_robin_state() : state(0) {};
+    round_robin_state(cell_lid_type state) : state(state) {};
+    lid_hopefully update(const label_resolution_map::range_set& range);
+};
+
+struct assert_univalent_state {
+    lid_hopefully update(const label_resolution_map::range_set& range);
+};
+
+// Struct used for resolving the lid of a (gid, label, lid_selection_policy) input.
+// Requires a `label_resolution_map` which stores the constant mapping of (gid, label) pairs to lid sets.
+struct resolver {
+    resolver(const label_resolution_map* label_map): label_map_(label_map) {}
+    cell_lid_type resolve(const cell_global_label_type& iden);
+
+private:
+    using state_variant = std::variant<round_robin_state, assert_univalent_state>;
+
+    state_variant construct_state(lid_selection_policy pol);
+
+    const label_resolution_map* label_map_;
+    std::unordered_map<cell_gid_type, std::unordered_map<cell_tag_type, std::unordered_map <lid_selection_policy, state_variant>>> state_map_;
+};
+} // namespace arb
diff --git a/arbor/lif_cell_group.cpp b/arbor/lif_cell_group.cpp
index d3f84f8a..99182f3e 100644
--- a/arbor/lif_cell_group.cpp
+++ b/arbor/lif_cell_group.cpp
@@ -1,7 +1,7 @@
 #include <arbor/arbexcept.hpp>
 
-#include <lif_cell_group.hpp>
-
+#include "label_resolution.hpp"
+#include "lif_cell_group.hpp"
 #include "profile/profiler_macro.hpp"
 #include "util/rangeutil.hpp"
 #include "util/span.hpp"
@@ -9,7 +9,7 @@
 using namespace arb;
 
 // Constructor containing gid of first cell in a group and a container of all cells.
-lif_cell_group::lif_cell_group(const std::vector<cell_gid_type>& gids, const recipe& rec):
+lif_cell_group::lif_cell_group(const std::vector<cell_gid_type>& gids, const recipe& rec, cell_label_range& cg_sources, cell_label_range& cg_targets):
     gids_(gids)
 {
     for (auto gid: gids_) {
@@ -26,6 +26,13 @@ lif_cell_group::lif_cell_group(const std::vector<cell_gid_type>& gids, const rec
     for (auto lid: util::make_span(gids_.size())) {
         cells_.push_back(util::any_cast<lif_cell>(rec.get_cell_description(gids_[lid])));
     }
+
+    for (const auto& c: cells_) {
+        cg_sources.add_cell();
+        cg_targets.add_cell();
+        cg_sources.add_label(c.source, {0, 1});
+        cg_targets.add_label(c.target, {0, 1});
+    }
 }
 
 cell_kind lif_cell_group::get_cell_kind() const {
@@ -112,4 +119,4 @@ void lif_cell_group::advance_cell(time_type tfinal, time_type dt, cell_gid_type
 
     // This is the last time a cell was updated.
     last_time_updated_[lid] = t;
-}
+}
\ No newline at end of file
diff --git a/arbor/lif_cell_group.hpp b/arbor/lif_cell_group.hpp
index 034f3e34..feda74c9 100644
--- a/arbor/lif_cell_group.hpp
+++ b/arbor/lif_cell_group.hpp
@@ -9,6 +9,7 @@
 #include <arbor/spike.hpp>
 
 #include "cell_group.hpp"
+#include "label_resolution.hpp"
 
 namespace arb {
 
@@ -19,7 +20,7 @@ public:
     lif_cell_group() = default;
 
     // Constructor containing gid of first cell in a group and a container of all cells.
-    lif_cell_group(const std::vector<cell_gid_type>& gids, const recipe& rec);
+    lif_cell_group(const std::vector<cell_gid_type>& gids, const recipe& rec, cell_label_range& cg_sources, cell_label_range& cg_targets);
 
     virtual cell_kind get_cell_kind() const override;
     virtual void reset() override;
diff --git a/arbor/mc_cell_group.cpp b/arbor/mc_cell_group.cpp
index 3a0a27ed..78d2d791 100644
--- a/arbor/mc_cell_group.cpp
+++ b/arbor/mc_cell_group.cpp
@@ -15,6 +15,7 @@
 #include "cell_group.hpp"
 #include "event_binner.hpp"
 #include "fvm_lowered_cell.hpp"
+#include "label_resolution.hpp"
 #include "mc_cell_group.hpp"
 #include "profile/profiler_macro.hpp"
 #include "sampler_map.hpp"
@@ -29,7 +30,11 @@ namespace arb {
 ARB_DEFINE_LEXICOGRAPHIC_ORDERING(arb::target_handle,(a.mech_id,a.mech_index,a.intdom_index),(b.mech_id,b.mech_index,b.intdom_index))
 ARB_DEFINE_LEXICOGRAPHIC_ORDERING(arb::deliverable_event,(a.time,a.handle,a.weight),(b.time,b.handle,b.weight))
 
-mc_cell_group::mc_cell_group(const std::vector<cell_gid_type>& gids, const recipe& rec, fvm_lowered_cell_ptr lowered):
+mc_cell_group::mc_cell_group(const std::vector<cell_gid_type>& gids,
+                             const recipe& rec,
+                             cell_label_range& cg_sources,
+                             cell_label_range& cg_targets,
+                             fvm_lowered_cell_ptr lowered):
     gids_(gids), lowered_(std::move(lowered))
 {
     // Default to no binning of events
@@ -40,20 +45,25 @@ mc_cell_group::mc_cell_group(const std::vector<cell_gid_type>& gids, const recip
         gid_index_map_[gids_[i]] = i;
     }
 
-    // Create lookup structure for target ids.
-    util::make_partition(target_handle_divisions_,
-            util::transform_view(gids_, [&rec](cell_gid_type i) { return rec.num_targets(i); }));
-    std::size_t n_targets = target_handle_divisions_.back();
+    // Construct cell implementation, retrieving handles and maps.
+    auto fvm_info = lowered_->initialize(gids_, rec);
+
+    // Propagate source and target ranges to the simulator object
+    cg_sources = std::move(fvm_info.source_data);
+    cg_targets = std::move(fvm_info.target_data);
 
-    // Pre-allocate space to store handles.
-    target_handles_.reserve(n_targets);
+    // Store consistent data from fvm_lowered_cell
+    target_handles_ = std::move(fvm_info.target_handles);
+    cell_to_intdom_ = std::move(fvm_info.cell_to_intdom);
+    probe_map_ = std::move(fvm_info.probe_map);
 
-    // Construct cell implementation, retrieving handles and maps. 
-    lowered_->initialize(gids_, rec, cell_to_intdom_, target_handles_, probe_map_);
+    // Create lookup structure for target ids.
+    util::make_partition(target_handle_divisions_,
+        util::transform_view(gids_, [&](cell_gid_type i) { return fvm_info.num_targets[i]; }));
 
     // Create a list of the global identifiers for the spike sources
     for (auto source_gid: gids_) {
-        for (cell_lid_type lid = 0; lid<rec.num_sources(source_gid); ++lid) {
+        for (cell_lid_type lid = 0; lid<fvm_info.num_sources[source_gid]; ++lid) {
             spike_sources_.push_back({source_gid, lid});
         }
     }
@@ -582,5 +592,4 @@ std::vector<probe_metadata> mc_cell_group::get_probe_metadata(cell_member_type p
 
     return result;
 }
-
 } // namespace arb
diff --git a/arbor/mc_cell_group.hpp b/arbor/mc_cell_group.hpp
index f93e4ebb..173aae9a 100644
--- a/arbor/mc_cell_group.hpp
+++ b/arbor/mc_cell_group.hpp
@@ -18,6 +18,7 @@
 #include "event_binner.hpp"
 #include "event_queue.hpp"
 #include "fvm_lowered_cell.hpp"
+#include "label_resolution.hpp"
 #include "sampler_map.hpp"
 
 namespace arb {
@@ -26,7 +27,11 @@ class mc_cell_group: public cell_group {
 public:
     mc_cell_group() = default;
 
-    mc_cell_group(const std::vector<cell_gid_type>& gids, const recipe& rec, fvm_lowered_cell_ptr lowered);
+    mc_cell_group(const std::vector<cell_gid_type>& gids,
+                  const recipe& rec,
+                  cell_label_range& cg_sources,
+                  cell_label_range& cg_targets,
+                  fvm_lowered_cell_ptr lowered);
 
     cell_kind get_cell_kind() const override {
         return cell_kind::cable;
diff --git a/arbor/simulation.cpp b/arbor/simulation.cpp
index 231c7d9a..7284a3c4 100644
--- a/arbor/simulation.cpp
+++ b/arbor/simulation.cpp
@@ -183,10 +183,36 @@ simulation_state::simulation_state(
         const domain_decomposition& decomp,
         execution_context ctx
     ):
-    communicator_(rec, decomp, ctx),
     task_system_(ctx.thread_pool),
     local_spikes_({thread_private_spike_store(ctx.thread_pool), thread_private_spike_store(ctx.thread_pool)})
 {
+    // Generate the cell groups in parallel, with one task per cell group.
+    cell_groups_.resize(decomp.groups.size());
+    std::vector<cell_labels_and_gids> cg_sources(cell_groups_.size());
+    std::vector<cell_labels_and_gids> cg_targets(cell_groups_.size());
+    foreach_group_index(
+        [&](cell_group_ptr& group, int i) {
+          const auto& group_info = decomp.groups[i];
+          cell_label_range sources, targets;
+          auto factory = cell_kind_implementation(group_info.kind, group_info.backend, ctx);
+          group = factory(group_info.gids, rec, sources, targets);
+
+          cg_sources[i] = cell_labels_and_gids(std::move(sources), group_info.gids);
+          cg_targets[i] = cell_labels_and_gids(std::move(targets), group_info.gids);
+        });
+
+    cell_labels_and_gids local_sources, local_targets;
+    for(const auto& i: util::make_span(cell_groups_.size())) {
+        local_sources.append(cg_sources.at(i));
+        local_targets.append(cg_targets.at(i));
+    }
+    auto global_sources = ctx.distributed->gather_cell_labels_and_gids(local_sources);
+
+    auto source_resolution_map = label_resolution_map(std::move(global_sources));
+    auto target_resolution_map = label_resolution_map(std::move(local_targets));
+
+    communicator_ = arb::communicator(rec, decomp, source_resolution_map, target_resolution_map, ctx);
+
     const auto num_local_cells = communicator_.num_local_cells();
 
     // Use half minimum delay of the network for max integration interval.
@@ -198,20 +224,21 @@ simulation_state::simulation_state(
     event_generators_.resize(num_local_cells);
     cell_size_type lidx = 0;
     cell_size_type grpidx = 0;
+
+    auto target_resolution_map_ptr = std::make_shared<label_resolution_map>(std::move(target_resolution_map));
     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] = gid_local_info{lidx, grpidx};
 
-            // Check validity of event_generator targets
+            // Resolve event_generator targets.
+            // Each event generator gets their own resolver state.
             auto event_gens = rec.event_generators(gid);
-            auto num_targets = rec.num_targets(gid);
-            for (const auto& g: event_gens) {
-                for (const auto& t: g.targets()) {
-                    if (t >= num_targets) {
-                        throw arb::bad_event_generator_target_lid(gid, t, num_targets);
-                    }
-                }
+            for (auto& g: event_gens) {
+                g.resolve_label([target_resolution_map_ptr, event_resolver=resolver(target_resolution_map_ptr.get()), gid]
+                    (const cell_local_label_type& label) mutable {
+                        return event_resolver.resolve({gid, label});
+                    });
             }
 
             // Set up the event generators for cell gid.
@@ -222,15 +249,6 @@ simulation_state::simulation_state(
         ++grpidx;
     }
 
-    // Generate the cell groups in parallel, with one task per cell group.
-    cell_groups_.resize(decomp.groups.size());
-    foreach_group_index(
-        [&](cell_group_ptr& group, int i) {
-            const auto& group_info = decomp.groups[i];
-            auto factory = cell_kind_implementation(group_info.kind, group_info.backend, ctx);
-            group = factory(group_info.gids, rec);
-        });
-
     // Create event lane buffers.
     // One buffer is consumed by cell group updates while the other is filled with events for
     // the following epoch. In each buffer there is one lane for each local cell.
diff --git a/arbor/spike_source_cell_group.cpp b/arbor/spike_source_cell_group.cpp
index 856eec81..0b61bdb9 100644
--- a/arbor/spike_source_cell_group.cpp
+++ b/arbor/spike_source_cell_group.cpp
@@ -6,13 +6,18 @@
 #include <arbor/schedule.hpp>
 
 #include "cell_group.hpp"
+#include "label_resolution.hpp"
 #include "profile/profiler_macro.hpp"
 #include "spike_source_cell_group.hpp"
 #include "util/span.hpp"
 
 namespace arb {
 
-spike_source_cell_group::spike_source_cell_group(const std::vector<cell_gid_type>& gids, const recipe& rec):
+spike_source_cell_group::spike_source_cell_group(
+    const std::vector<cell_gid_type>& gids,
+    const recipe& rec,
+    cell_label_range& cg_sources,
+    cell_label_range& cg_targets):
     gids_(gids)
 {
     for (auto gid: gids_) {
@@ -23,9 +28,12 @@ spike_source_cell_group::spike_source_cell_group(const std::vector<cell_gid_type
 
     time_sequences_.reserve(gids_.size());
     for (auto gid: gids_) {
+        cg_sources.add_cell();
+        cg_targets.add_cell();
         try {
             auto cell = util::any_cast<spike_source_cell>(rec.get_cell_description(gid));
             time_sequences_.push_back(std::move(cell.seq));
+            cg_sources.add_label(cell.source, {0, 1});
         }
         catch (std::bad_any_cast& e) {
             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 3d36a376..a2077225 100644
--- a/arbor/spike_source_cell_group.hpp
+++ b/arbor/spike_source_cell_group.hpp
@@ -10,12 +10,13 @@
 
 #include "cell_group.hpp"
 #include "epoch.hpp"
+#include "label_resolution.hpp"
 
 namespace arb {
 
 class spike_source_cell_group: public cell_group {
 public:
-    spike_source_cell_group(const std::vector<cell_gid_type>& gids, const recipe& rec);
+    spike_source_cell_group(const std::vector<cell_gid_type>& gids, const recipe& rec, cell_label_range& cg_sources, cell_label_range& cg_targets);
 
     cell_kind get_cell_kind() const override;
 
diff --git a/arbor/symmetric_recipe.cpp b/arbor/symmetric_recipe.cpp
index ccf9dec5..4e7f48d5 100644
--- a/arbor/symmetric_recipe.cpp
+++ b/arbor/symmetric_recipe.cpp
@@ -14,14 +14,6 @@ cell_kind symmetric_recipe::get_cell_kind(cell_gid_type i) const {
     return tiled_recipe_->get_cell_kind(i % tiled_recipe_->num_cells());
 }
 
-cell_size_type symmetric_recipe::num_sources(cell_gid_type i) const {
-    return tiled_recipe_->num_sources(i % tiled_recipe_->num_cells());
-}
-
-cell_size_type symmetric_recipe::num_targets(cell_gid_type i) const {
-    return tiled_recipe_->num_targets(i % tiled_recipe_->num_cells());
-}
-
 // Only function that calls the underlying tile's function on the same gid.
 // This is because applying transformations to event generators is not straightforward.
 std::vector<event_generator> symmetric_recipe::event_generators(cell_gid_type i) const {
diff --git a/arborio/cableio.cpp b/arborio/cableio.cpp
index c883bfef..998dff37 100644
--- a/arborio/cableio.cpp
+++ b/arborio/cableio.cpp
@@ -105,7 +105,7 @@ s_expr mksexp(const decor& d) {
     }
     for (const auto& p: d.placements()) {
         decorations.push_back(std::visit([&](auto& x)
-            { return slist("place"_symbol, round_trip(p.first), mksexp(x)); }, p.second));
+            { return slist("place"_symbol, round_trip(std::get<0>(p)), mksexp(x), s_expr(std::get<2>(p))); }, std::get<1>(p)));
     }
     return {"decor"_symbol, slist_range(decorations)};
 }
@@ -252,10 +252,10 @@ arb::ion_reversal_potential_method make_ion_reversal_potential_method(const std:
 #undef ARBIO_DEFINE_DOUBLE_ARG
 
 // Define makers for placeable pairs, paintable pairs, defaultables and decors
-using place_pair = std::pair<arb::locset, arb::placeable>;
+using place_tuple = std::tuple<arb::locset, arb::placeable, std::string>;
 using paint_pair = std::pair<arb::region, arb::paintable>;
-place_pair make_place(locset where, placeable what) {
-    return place_pair{where, what};
+place_tuple make_place(locset where, placeable what, std::string name) {
+    return place_tuple{where, what, name};
 }
 paint_pair make_paint(region where, paintable what) {
     return paint_pair{where, what};
@@ -263,11 +263,11 @@ paint_pair make_paint(region where, paintable what) {
 defaultable make_default(defaultable what) {
     return what;
 }
-decor make_decor(const std::vector<std::variant<place_pair, paint_pair, defaultable>>& args) {
+decor make_decor(const std::vector<std::variant<place_tuple, paint_pair, defaultable>>& args) {
     decor d;
     for(const auto& a: args) {
         auto decor_visitor = arb::util::overload(
-            [&](const place_pair & p) { d.place(p.first, p.second); },
+            [&](const place_tuple & p) { d.place(std::get<0>(p), std::get<1>(p), std::get<2>(p)); },
             [&](const paint_pair & p) { d.paint(p.first, p.second); },
             [&](const defaultable & p){ d.set_default(p); });
         std::visit(decor_visitor, a);
@@ -769,28 +769,28 @@ eval_map named_evals{
     {"mechanism", make_mech_call("'mechanism' with a name argument, and 0 or more parameter settings"
                                       "(name:string (param:string val:real))")},
 
-    {"place", make_call<locset, gap_junction_site>(make_place, "'place' with 2 arguments (locset gap-junction-site)")},
-    {"place", make_call<locset, i_clamp>(make_place, "'place' with 2 arguments (locset current-clamp)")},
-    {"place", make_call<locset, threshold_detector>(make_place, "'place' with 2 arguments (locset threshold-detector)")},
-    {"place", make_call<locset, mechanism_desc>(make_place, "'place' with 2 arguments (locset mechanism)")},
-
-    {"paint", make_call<region, init_membrane_potential>(make_paint, "'paint' with 2 arguments (region membrane-potential)")},
-    {"paint", make_call<region, temperature_K>(make_paint, "'paint' with 2 arguments (region temperature-kelvin)")},
-    {"paint", make_call<region, membrane_capacitance>(make_paint, "'paint' with 2 arguments (region membrane-capacitance)")},
-    {"paint", make_call<region, axial_resistivity>(make_paint, "'paint' with 2 arguments (region axial-resistivity)")},
-    {"paint", make_call<region, init_int_concentration>(make_paint, "'paint' with 2 arguments (region ion-internal-concentration)")},
-    {"paint", make_call<region, init_ext_concentration>(make_paint, "'paint' with 2 arguments (region ion-external-concentration)")},
-    {"paint", make_call<region, init_reversal_potential>(make_paint, "'paint' with 2 arguments (region ion-reversal-potential)")},
-    {"paint", make_call<region, mechanism_desc>(make_paint, "'paint' with 2 arguments (region mechanism)")},
-
-    {"default", make_call<init_membrane_potential>(make_default, "'default' with 1 argument (membrane-potential)")},
-    {"default", make_call<temperature_K>(make_default, "'default' with 1 argument (temperature-kelvin)")},
-    {"default", make_call<membrane_capacitance>(make_default, "'default' with 1 argument (membrane-capacitance)")},
-    {"default", make_call<axial_resistivity>(make_default, "'default' with 1 argument (axial-resistivity)")},
-    {"default", make_call<init_int_concentration>(make_default, "'default' with 1 argument (ion-internal-concentration)")},
-    {"default", make_call<init_ext_concentration>(make_default, "'default' with 1 argument (ion-external-concentration)")},
-    {"default", make_call<init_reversal_potential>(make_default, "'default' with 1 argument (ion-reversal-potential)")},
-    {"default", make_call<ion_reversal_potential_method>(make_default, "'default' with 1 argument (ion-reversal-potential-method)")},
+    {"place", make_call<locset, gap_junction_site, std::string>(make_place, "'place' with 3 arguments (ls:locset gj:gap-junction-site name:string)")},
+    {"place", make_call<locset, i_clamp, std::string>(make_place, "'place' with 3 arguments (ls:locset c:current-clamp name:string)")},
+    {"place", make_call<locset, threshold_detector, std::string>(make_place, "'place' with 3 arguments (ls:locset t:threshold-detector name:string)")},
+    {"place", make_call<locset, mechanism_desc, std::string>(make_place, "'place' with 3 arguments (ls:locset mech:mechanism name:string)")},
+
+    {"paint", make_call<region, init_membrane_potential>(make_paint, "'paint' with 2 arguments (reg:region v:membrane-potential)")},
+    {"paint", make_call<region, temperature_K>(make_paint, "'paint' with 2 arguments (reg:region v:temperature-kelvin)")},
+    {"paint", make_call<region, membrane_capacitance>(make_paint, "'paint' with 2 arguments (reg:region v:membrane-capacitance)")},
+    {"paint", make_call<region, axial_resistivity>(make_paint, "'paint' with 2 arguments (reg:region v:axial-resistivity)")},
+    {"paint", make_call<region, init_int_concentration>(make_paint, "'paint' with 2 arguments (reg:region v:ion-internal-concentration)")},
+    {"paint", make_call<region, init_ext_concentration>(make_paint, "'paint' with 2 arguments (reg:region v:ion-external-concentration)")},
+    {"paint", make_call<region, init_reversal_potential>(make_paint, "'paint' with 2 arguments (reg:region v:ion-reversal-potential)")},
+    {"paint", make_call<region, mechanism_desc>(make_paint, "'paint' with 2 arguments (reg:region v:mechanism)")},
+
+    {"default", make_call<init_membrane_potential>(make_default, "'default' with 1 argument (v:membrane-potential)")},
+    {"default", make_call<temperature_K>(make_default, "'default' with 1 argument (v:temperature-kelvin)")},
+    {"default", make_call<membrane_capacitance>(make_default, "'default' with 1 argument (v:membrane-capacitance)")},
+    {"default", make_call<axial_resistivity>(make_default, "'default' with 1 argument (v:axial-resistivity)")},
+    {"default", make_call<init_int_concentration>(make_default, "'default' with 1 argument (v:ion-internal-concentration)")},
+    {"default", make_call<init_ext_concentration>(make_default, "'default' with 1 argument (v:ion-external-concentration)")},
+    {"default", make_call<init_reversal_potential>(make_default, "'default' with 1 argument (v:ion-reversal-potential)")},
+    {"default", make_call<ion_reversal_potential_method>(make_default, "'default' with 1 argument (v:ion-reversal-potential-method)")},
 
     {"locset-def", make_call<std::string, locset>(make_locset_pair,
                        "'locset-def' with 2 arguments (name:string ls:locset)")},
@@ -804,7 +804,7 @@ eval_map named_evals{
     {"branch",  make_branch_call(
                     "'branch' with 2 integers and 1 or more segment arguments (id:int parent:int s0:segment s1:segment ..)")},
 
-    {"decor", make_arg_vec_call<place_pair, paint_pair, defaultable>(make_decor,
+    {"decor", make_arg_vec_call<place_tuple, paint_pair, defaultable>(make_decor,
                   "'decor' with 1 or more `paint`, `place` or `default` arguments")},
     {"label-dict", make_arg_vec_call<locset_pair, region_pair>(make_label_dict,
                        "'label-dict' with 1 or more `locset-def` or `region-def` arguments")},
diff --git a/doc/concepts/cell.rst b/doc/concepts/cell.rst
index 174d6281..eaef2c43 100644
--- a/doc/concepts/cell.rst
+++ b/doc/concepts/cell.rst
@@ -7,17 +7,21 @@ The basic unit of abstraction in an Arbor model is a cell.
 A cell represents the smallest model that can be simulated.
 Cells interact with each other via spike exchange and gap junctions.
 
-.. table:: Identifiers used to uniquely refer to cells and objects like synapses on cells.
-
-    ========================  ================================  ===========================================================
-    Identifier                Type                              Description
-    ========================  ================================  ===========================================================
-    .. generic:: gid          integral                          The unique global identifier of a cell.
-    .. generic:: index        integral                          The index of an item in a cell-local collection.
-                                                                For example the 7th synapse on a cell.
-    .. generic:: cell_member  tuple (:gen:`gid`, :gen:`index`)  The global identification of a cell-local item with `index`
-                                                                into a cell-local collection on the cell identified by `gid`.
-    ========================  ================================  ===========================================================
+.. table:: Identifiers used to refer to cells and objects like synapses on cells.
+
+    =============================  ===========================================  ===========================================================
+    Identifier                     Type                                         Description
+    =============================  ===========================================  ===========================================================
+    .. generic:: gid               integral                                     The unique global identifier of a cell.
+    .. generic:: tag               string                                       The label of a group of items in a cell-local collection.
+                                                                                For example the synapses "syns_0" on a cell.
+    .. generic:: selection_policy  enum                                         The policy for selecting a single item out of a group
+                                                                                identified by its label.
+    .. generic:: local_label       tuple (:gen:`tag`, :gen:`selection_policy`)  The local identification of an cell-local item from a
+                                                                                cell-local collection on an unspecified cell.
+    .. generic:: global_label      tuple (:gen:`gid`, :gen:`local_lable`)       The global identification of a cell-local item from a
+                                                                                cell-local collection on the cell identified by `gid`.
+    =============================  ===========================================  ===========================================================
 
 Cell interactions via :ref:`connections <modelconnections>` and :ref:`gap junctions <modelgapjunctions>` occur
 between **source**, **target** and **gap junction site** locations on a cell. Connections are formed from sources
@@ -25,18 +29,22 @@ to targets. Gap junctions are formed between two gap junction sites. An example
 :ref:`cable cell<modelcablecell>` is a :ref:`threshold detector <cablecell-threshold-detectors>` (spike detector);
 an example of a target on a cable cell is a :ref:`synapse <cablecell-synapses>`.
 
-Each cell has a global identifier :gen:`gid`, and each **source**, **target** and **gap junction site** has a
-global identifier :gen:`cell_member`. These are used to refer to them in :ref:`recipes <modelrecipe>`.
+**Sources**, **targets** and **gap junction sites** are placed on sets of one or more locations on a cell.
+The number of locations in each set (and hence the number of sources/targets/gap junctions), depends on the cell
+description. For example, a user may choose to place a synapse at the end of every branch of a cell: the number of
+synapses in this case depends on the underlying morphology.
 
-A cell can have multiple sources, targets and gap junction site objects. Each object is ordered relative to other
-objects of the same type on that cell. The unique :gen:`cell_member` (:gen:`gid`, :gen:`index`) identifies an object
-according to the :gen:`gid` of the cell it is placed on, and its :gen:`index` on the cell enumerated according to the
-order of insertion on the cell relative to other objects of the same type.
+A set of one or more items of the same type (source/target/gap junction) are grouped under a label which can
+be when used when forming connections in a network. However, connections are one-to-one, so a :gen:`selection_policy`
+is needed to select an item of the group, for both ends of a connection or gap junction.
 
-The :gen:`gid` of a cell is used to determine its cell :ref:`kind <modelcellkind>` and
-:ref:`description <modelcelldesc>` in the :ref:`recipe <modelrecipe>`. The :gen:`cell_member` of a source,
-target or gap junction site is used to form :ref:`connections <modelconnections>` and
-:ref:`gap junctions <modelgapjunctions>` in the recipe.
+The combination of :gen:`tag` and :gen:`selection_policy` forms a :gen:`local_label`. When the global identifier of
+the cell :gen:`gid` is added, a :gen:`global_label` is formed, capable of globally identifying a source, target or
+gap junction site in the network. These :gen:`global_labels` are used to form connections and gap junctions in the
+:ref:`recipe <modelrecipe>`.
+
+The :gen:`gid` of a cell is also used to determine its cell :ref:`kind <modelcellkind>` and
+:ref:`description <modelcelldesc>` in the :ref:`recipe <modelrecipe>`.
 
 .. _modelcellkind:
 
diff --git a/doc/concepts/decor.rst b/doc/concepts/decor.rst
index ad4b7b78..e1ec4bb2 100644
--- a/doc/concepts/decor.rst
+++ b/doc/concepts/decor.rst
@@ -261,21 +261,24 @@ Placed dynamics
 ---------------
 
 Placed dynamics are discrete countable items that affect or record the dynamics of a cell,
-and are assigned to specific locations.
+and are assigned to :term:`locsets <locset>`. Because locsets can contain multiple locations
+on the cell, and the exact number of these locations can not be known until the model is built,
+each placed dynamic is given a string label, used to refer to the group of items on the underlying
+locset.
 
 .. _cablecell-synapses:
 
 1. Connection sites
 ~~~~~~~~~~~~~~~~~~~
 
-Connections (synapses) are instances of NMODL POINT mechanisms. See also :ref:`modelconnections`.
+Connections (synapses) are instances of NMODL POINT mechanisms. See also :term:`connection`.
 
 .. _cablecell-gj-sites:
 
 2. Gap junction sites
 ~~~~~~~~~~~~~~~~~~~~~
 
-See :ref:`modelgapjunctions`.
+See :term:`gap junction`.
 
 .. _cablecell-threshold-detectors:
 
@@ -306,17 +309,17 @@ constant stimuli and constant amplitude stimuli restricted to a fixed time inter
 .. code-block:: Python
 
     # Constant stimulus, amplitude 10 nA.
-    decor.place('(root)', arbor.iclamp(10))
+    decor.place('(root)', arbor.iclamp(10), "iclamp0")
 
     # Constant amplitude 10 nA stimulus at 20 Hz, with initial phase of π/4 radians.
-    decor.place('(root)', arbor.iclamp(10, frequency=0.020, phasce=math.pi/4))
+    decor.place('(root)', arbor.iclamp(10, frequency=0.020, phasce=math.pi/4), "iclamp1")
 
     # Stimulus at 1 kHz, amplitude 10 nA, for 40 ms starting at t = 30 ms.
-    decor.place('(root)', arbor.iclamp(30, 40, 10, frequency=1))
+    decor.place('(root)', arbor.iclamp(30, 40, 10, frequency=1), "iclamp2")
 
     # Piecewise linear stimulus with amplitude ranging from 0 nA to 10 nA,
     # starting at t = 30 ms and stopping at t = 50 ms.
-    decor.place('(root)', arbor.iclamp([(30, 0), (37, 10), (43, 8), (50, 0)])
+    decor.place('(root)', arbor.iclamp([(30, 0), (37, 10), (43, 8), (50, 0)], "iclamp3")
 
 
 .. _cablecell-probes:
diff --git a/doc/concepts/interconnectivity.rst b/doc/concepts/interconnectivity.rst
index 73f9ca9c..cdc2c8ea 100644
--- a/doc/concepts/interconnectivity.rst
+++ b/doc/concepts/interconnectivity.rst
@@ -3,10 +3,10 @@
 Interconnectivity
 =================
 
-Networks can be regarded as graph, where the nodes are cells and the edges
+Networks can be regarded as graphs, where the nodes are locations on cells and the edges
 describe the communications between them. In Arbor, two sorts of edges are modelled: a
 :term:`connection` abstracts the propagation of action potentials (:term:`spikes <spike>`) through the network,
-while a :term:`gap junction` is used to describe a direct electrical connection between two cells.
+while a :term:`gap junction` is used to describe a direct electrical connection between two locations on two cells.
 Connections only capture the propagation delay and attenuation associated with spike
 connectivity: the biophysical modelling of the chemical synapses themselves is the
 responsibility of the target cell model.
@@ -21,25 +21,31 @@ A recipe lets you define which sites are connected to which.
 
    connection
       Connections implement chemical synapses between **source** and **target** cells and are characterized
-      by having a transmission delay. On a cell, sources and targets are separately indexed.
+      by having a transmission delay.
 
       Connections in Arbor are defined in two steps:
 
-      1. Create **source** and **target** on two separate cells as part of their
+      1. Create labeled **source** and **target** on two separate cells as part of their
          :ref:`cell descriptions <modelcelldesc>` in the :ref:`recipe <modelrecipe>`. Sources typically
          generate spikes. Targets are typically synapses with associated biophysical model descriptions.
-      2. Declare the connection in the recipe: with the source and target identified using :gen:`cell_member`,
-         a connection delay and a connection weight. The connection should be declared on the target cell.
+         Each labeled group of sources or targets may contain multiple items on possibly multiple locations
+         on the cell.
+      2. Declare the connection in the recipe *on the target cell*:  from a source identified using
+         a :gen:`global_label`; a target identified using a :gen:`local_label` (:gen:`gid` of target is
+         the argument of the recipe method); a connection delay and a connection weight.
 
    spike
    action potential
       Spikes travel over :term:`connections <connection>`. In a synapse, they generate an event.
 
    event
-      In a synapse :term:`spikes <spike>` generate events, which constitute stimulation of the synapse mechanism and the transmission of a signal. A synapse may receive events directly from an :term:`event generator`.
+      In a synapse :term:`spikes <spike>` generate events, which constitute stimulation of the synapse
+      mechanism and the transmission of a signal. A synapse may receive events directly from an
+      :term:`event generator`.
 
    event generator
-      Externally stimulate a synapse. Event can be delivered on a schedule, one time, etc. See :py:class:`arbor.event_generator` for options.
+      Externally stimulate a synapse. Events can be delivered on a schedule.
+      See :py:class:`arbor.event_generator` for details.
 
 .. _modelgapjunctions:
 
@@ -51,11 +57,14 @@ A recipe lets you define which sites are connected to which.
 
       Similarly to `Connections`, Gap Junctions in Arbor are defined in two steps:
 
-      1. Create a **gap junction site** on two separate cells as part of their
+      1. Create labeled **gap junction sites** on two separate cells as part of their
          :ref:`cell descriptions <modelcelldesc>` in the :ref:`recipe <modelrecipe>`.
-      2. Declare the Gap Junction connections in the recipe: between a peer and local **gap junction site**
-         identified using :gen:`cell_member` and a conductance in μS. Two of these connections are needed,
-         on each of the peer and local cells.
+         Each labeled group of gap junctions may contain multiple items on possibly multiple locations
+         on the cell.
+      2. Declare the Gap Junction connections in the recipe *on the local cell*: from a peer **gap junction site**
+         identified using a :gen:`global_label`; to a local **gap junction site** identified using a
+         :gen:`local_label` (:gen:`gid` of the site is implicitly known); and a conductance in μS.
+         Two of these connections are needed, on each of the peer and local cells.
 
    .. Note::
       Only cable cells support gap junctions as of now.
diff --git a/doc/concepts/lif_cell.rst b/doc/concepts/lif_cell.rst
index e611f0ca..c965c967 100644
--- a/doc/concepts/lif_cell.rst
+++ b/doc/concepts/lif_cell.rst
@@ -13,8 +13,9 @@ The description of a LIF cell is used to control the leaky integrate-and-fire dy
 * Firing threshold.
 * Refractory period.
 
-The morphology of a LIF cell is automatically modelled as a single :term:`compartment <control volume>`; each cell has one built-in
-**source** and one built-in **target** which do not need to be explicitly added in the cell description.
+The morphology of a LIF cell is automatically modelled as a single :term:`compartment <control volume>`;
+each cell has one built-in **source** and one built-in **target** which need to be given labels when the
+cell is created. The labels are used to form connections to and from the cell.
 LIF cells do not support adding additional **sources** or **targets** to the description. They do not support
 **gap junctions**. They do not support adding density or point mechanisms.
 
diff --git a/doc/concepts/recipe.rst b/doc/concepts/recipe.rst
index 58158066..ca1b4d45 100644
--- a/doc/concepts/recipe.rst
+++ b/doc/concepts/recipe.rst
@@ -10,9 +10,6 @@ building phase to provide information about individual cells in the model, such
 * The **kind** of each cell.
 * The **description** of each cell, e.g. with morphology, dynamics, synapses, detectors,
   stimuli etc.
-* The number of **spike targets** on each cell.
-* The number of **spike sources** on each cell.
-* The number of **gap junction sites** on each cell.
 * Incoming **network connections** from other cells terminating on a cell.
 * **Gap junction connections** on each cell.
 * **Probes** on each cell.
@@ -26,42 +23,31 @@ which helps make Arbor fast and easily distributable over many nodes.
 To better illustrate the content of a recipe, let's consider the following network of
 three cells:
 
-- ``Cell 0``: Is a single soma, with ``hh`` (Hodgkin-huxley) dynamics. In the middle
-  of the soma, a spike detector is attached, it generates a spiking event when the
-  voltage goes above 10 mV. In the same spot on the soma, a current clamp is also
-  attached, with the intention of triggering some spikes. All of the preceding info:
-  the morphology, dynamics, spike detector and current clamp are what is referred to in
-  Arbor as the **description** of the cell.
+- ``Cell 0``: Is a single soma, with ``hh`` (Hodgkin-huxley) dynamics. A spike detector
+  labelled "detector_0" is attached to the middle of the soma. The detector will generate a
+  spike event when the voltage goes above 10 mV. At the same spot on the soma, a current clamp
+  is also attached, with the intention of triggering some spikes. All of the preceding info —
+  the morphology, dynamics, spike detector and current clamp — constitute what is termed the
+  **description** of the cell.
   ``Cell 0`` should be modelled as a :ref:`cable cell<modelcablecell>`,
   (because cable cells allow complex dynamics such as ``hh``). This is referred to as
   the **kind** of the cell.
-  It's quite expensive to build cable cells, so we don't want to do this too often.
-  But when the simulation is first set up, it needs to know how cells interact with
-  one another in order to distribute the simulation over the available computational
-  resources. This is why the number of **targets**, **sources** and **gap junction sites**
-  is needed separately from the cell description: with them, the simulation can tell
-  that ``cell 0`` has 1 **spike source** (the detector), 0 **spike targets**, and 0
-  **gap junction sites**, without having to build the cell.
 - ``Cell 1``: Is a soma and a single dendrite, with ``passive`` dynamics everywhere.
-  It has a single synapse at the end of the dendrite and a gap junction site in the
-  middle of the soma. This is the **description** of the cell.
-  It's also a cable cell, which is its **cell kind**. It has 0 **spike sources**, 1
-  **spike target** (the synapse) and 1 **gap junction site**.
+  It has a single synapse at the end of the dendrite labeled "syanpse_1" and a gap
+  junction site in the middle of the soma labeled "gap_junction_1".
+  This is the **description** of the cell. It's also a cable cell, which is its **cell kind**.
 - ``Cell 2``: Is a soma and a single dendrite, with ``passive`` dynamics everywhere.
-  It has a gap junction site in the middle of the soma. This is the **description**
-  of the cell. It's also a cable cell, which is its **cell kind**. It has 0
-  **spike sources**, 0 **spike targets** and 1 **gap junction site**.
+  It has a gap junction site in the middle of the soma labeled "gap_junction_2".
+  This is the **description** of the cell. It's also a cable cell, which is its **cell kind**.
 
-The total **number of cells** in the model is 3. The **kind**, **description** and
-number of **spike sources**, **spike targets** and **gap junction sites** on each cell
+The total **number of cells** in the model is 3. The **kind**, and **description** of each cell
 is known and can be registered in the recipe. Next is the cell interaction.
 
-The model is designed such that ``cell 0`` has a spike source, ``cell 1`` has
-a spike target and gap junction site, and ``cell 2`` has a gap junction site. A
-**network connection** can be formed from ``cell 0`` to ``cell 1``; and a
-**gap junction connection** from ``cell 1`` to ``cell 2``. If ``cell 0`` spikes,
-a spike should be observed on ``cell 2`` after some delay. To monitor
-the voltage on ``cell 2`` and record the spike, a **probe** can be set up
+The model is designed such that each cell has labeled source, target and gap junction sites.
+A **network connection** can be formed from ``detector_0`` to ``synpase_1``; and a
+**gap junction connection** between ``gap_junction_1`` and ``gap_junction_2``.
+If ``detector_0`` spikes, a spike should be observed on ``gap_junction_2`` after some delay.
+To monitor the voltage on ``gap_junction_2`` and record the spike, a **probe** can be set up
 on ``cell 2``. All this information is also registered via the recipe.
 
 There are additional docs on :ref:`cell kinds <modelcellkind>`;
@@ -78,11 +64,11 @@ Are recipes always necessary?
 Yes. However, we provide a python :class:`single_cell_model <py_single_cell_model>`
 that abstracts away the details of a recipe for simulations of  single, stand-alone
 :ref:`cable cells<modelcablecell>`, which absolves the users from having to create the
-recipe themselves. This is possible because the number of cells, spike targets, spike sources
-and gap junction sites is fixed and known, as well as the fact that there can be no connections
-or gap junctions on a single cell. The single cell model is able to fill out the details of the
-recipe under the hood, and the user need only provide the cell description, and any probes they
-wish to place on the cell.
+recipe themselves. This is possible because the number of cells is fixed and known,
+and it is guaranteed that there can be no connections or gap junctions in a model of a
+single cell. The single cell model is able to fill out the details of the recipe under
+the hood, and the user need only provide the cell description, and any probes they wish
+to place on the cell.
 
 Why recipes?
 ------------
diff --git a/doc/concepts/spike_source_cell.rst b/doc/concepts/spike_source_cell.rst
index 0cf3955b..87e8ff1e 100644
--- a/doc/concepts/spike_source_cell.rst
+++ b/doc/concepts/spike_source_cell.rst
@@ -9,8 +9,7 @@ They are typically used as stimuli in a network of more complex cells.
 A spike source cell:
 
 * has its morphology is automatically modelled as a single :term:`compartment <control volume>`;
-* has one built-in **source**, which does not need to
-  be explicitly added in the cell description;
+* has one built-in **source**, which needs to be given a label to be used when forming connections from the cell;
 * has no **targets**;
 * does not support adding additional **sources** or **targets**;
 * does not support **gap junctions**;
diff --git a/doc/cpp/cable_cell.rst b/doc/cpp/cable_cell.rst
index ae46477f..aa2253a0 100644
--- a/doc/cpp/cable_cell.rst
+++ b/doc/cpp/cable_cell.rst
@@ -93,9 +93,10 @@ Density mechanisms are associated with a cable cell object with:
 .. cpp:function:: void cable_cell::paint(const region&, mechanism_desc)
 
 Point mechanisms, which are associated with connection end points on a
-cable cell, are attached to a cell with:
+cable cell, are placed on a set of locations given by a locset. The group
+of generated items requires a label. They are attached to a cell with:
 
-.. cpp:function:: void cable_cell::place(const locset&, mechanism_desc)
+.. cpp:function:: void cable_cell::place(const locset&, mechanism_desc, cell_tag_type label)
 
 .. todo::
 
diff --git a/doc/cpp/cell.rst b/doc/cpp/cell.rst
index b49f5245..56e34c7c 100644
--- a/doc/cpp/cell.rst
+++ b/doc/cpp/cell.rst
@@ -14,7 +14,8 @@ cells and members of cell-local collections.
 .. Note::
     Arbor uses ``std::unit32_t`` for :cpp:type:`cell_gid_type`,
     :cpp:type:`cell_size_type`, :cpp:type:`cell_lid_type`, and
-    :cpp:type:`cell_local_size_type` at the time of writing, however
+    :cpp:type:`cell_local_size_type`; and uses ``std::string`` for
+    :cpp:type:`cell_tag_type` at the time of writing. However,
     this could change, e.g. to handle models that cell gid that don't
     fit into a 32 bit unsigned integer.
     It is thus recommended that these type aliases be used whenever identifying
@@ -35,15 +36,61 @@ cells and members of cell-local collections.
 .. cpp:type::  cell_lid_type
 
     For indexes into cell-local data.
-    Local indices for items within a particular cell-local collection should be
-    zero-based and numbered contiguously.
 
+.. cpp:type::  cell_tag_type
+
+    For labels of cell-local data.
+    Local labels are used for groups of items within a particular cell-local collection.
+    Each label is associated with a range of :cpp:type:`cell_lid_type` indexing the individual
+    items on the cell. The range is generated when the model is built and is not directly
+    available to the user.
+
+.. cpp:enum::  lid_selection_policy
+
+   For selecting an individual item from a group of items sharing the
+   same :cpp:type:`cell_tag_type` label.
+
+   .. cpp:enumerator:: round_robin
+
+      Iterate over the items of the group in a round-robin fashion.
+
+   .. cpp:enumerator:: assert_univalent
+
+      Assert that ony one item is available in the group. Throws an exception if the assertion
+      fails.
+
+.. cpp:class::  cell_local_label_type
+
+   For local identification of an item on an unspecified cell.
+   This is used for selecting the target of a connection or the local site of a gap junction
+   connection. The cell ``gid`` is implicitly known from the recipe.
+
+   .. cpp:member:: cell_tag_type  tag
+
+      Identifier of a group of items in a cell-local collection.
+
+   .. cpp:member:: lid_selection_policy   policy
+
+      Policy used for selecting a single item of the tagged group.
+
+.. cpp:class::  cell_global_label_type
+
+   For global identification of an item on a cell.
+   This is used for selecting the source of a connection or the peer site of a gap junction
+   connection.
+
+   .. cpp:member:: cell_gid_type   gid
+
+      Global identifier of the cell associated with the item.
+
+   .. cpp:member:: cell_local_label_type label
+
+      Identifier of a single item on the cell.
 
 .. cpp:type::  cell_local_size_type
 
     An unsigned integer for for counts of cell-local data.
 
-
 .. cpp:class:: cell_member_type
 
     For global identification of an item of cell local data.
@@ -54,9 +101,9 @@ cells and members of cell-local collections.
         * identify an item within a cell-local collection by the member
           :cpp:member:`index`.
 
-    An example is uniquely identifying a synapse in the model.
-    Each synapse has a post-synaptic cell (:cpp:member:`gid`), and an index
-    (:cpp:member:`index`) into the set of synapses on the post-synaptic cell.
+    An example is uniquely identifying a probe description in the model.
+    Each probe has a cell id (:cpp:member:`gid`), and an index
+    (:cpp:member:`index`) into the set of probes on the cell.
 
     Lexicographically ordered by :cpp:member:`gid`,
     then :cpp:member:`index`.
@@ -69,7 +116,6 @@ cells and members of cell-local collections.
 
         The index of the item in a cell-local collection.
 
-
 .. cpp:enum-class:: cell_kind
 
     Enumeration used to identify the cell type/kind, used by the model to
diff --git a/doc/cpp/interconnectivity.rst b/doc/cpp/interconnectivity.rst
index 3ac0dc43..947498f4 100644
--- a/doc/cpp/interconnectivity.rst
+++ b/doc/cpp/interconnectivity.rst
@@ -1,5 +1,7 @@
 .. _cppinterconnectivity:
 
+.. cpp:namespace:: arb
+
 Interconnectivity
 #################
 
@@ -13,13 +15,16 @@ Interconnectivity
     :cpp:class:`cell_connection` is bound to the destination cell which means that the gid
     is implicitly known.
 
-    .. cpp:member:: cell_member_type source
+    .. cpp:member:: cell_global_label_type source
 
-        Source end point, represented by the pair (cell gid, source index on the cell)
+        Source end point, represented by a :cpp:type:`cell_global_label_type` which packages
+        a cell gid, label of a group of sources on the cell, and source selection policy.
 
-    .. cpp:member:: cell_lid_type dest
+    .. cpp:member:: cell_local_label_type dest
 
-        Destination target index on the cell, target cell's gid is implicitly known.
+        Destination end point on the cell, represented by a :cpp:type:`cell_local_label_type`
+        which packages a label of a group of targets on the cell and a selection policy.
+        The target cell's gid is implicitly known.
 
     .. cpp:member:: float weight
 
@@ -47,13 +52,16 @@ Interconnectivity
        :cpp:member:`local` site, and ``c0`` is the :cpp:member:`peer` site. If :cpp:member:`ggap` is equal
        in both connections, a symmetric gap-junction is formed, other wise the gap-junction is asymmetric.
 
-    .. cpp:member:: cell_member_type peer
+    .. cpp:member:: cell_global_label_type peer
 
-        Peer gap junction site, represented by the pair (cell gid, gap junction site index on the cell)
+        Peer gap junction site, represented by a :cpp:type:`cell_local_label_type` which packages a cell gid,
+        a label of a group of gap junction sites on the cell, and a site selection policy.
 
-    .. cpp:member:: cell_lid_type local
+    .. cpp:member:: cell_local_label_type local
 
-        Local gap junction site index on the cell, the gid of the local site's cell is implicitly known.
+        Local gap junction site on the cell, represented by a :cpp:type:`cell_local_label_type`
+        which packages a label of a group of gap junction sites on the cell and a selection policy.
+        The gid of the local site's cell is implicitly known.
 
     .. cpp:member:: float ggap
 
diff --git a/doc/cpp/recipe.rst b/doc/cpp/recipe.rst
index 5660894e..5eb4f589 100644
--- a/doc/cpp/recipe.rst
+++ b/doc/cpp/recipe.rst
@@ -74,8 +74,8 @@ Recipe
     .. cpp:function:: virtual std::vector<cell_connection> connections_on(cell_gid_type gid) const
 
         Returns a list of all the **incoming** connections for `gid` .
-        Each connection ``con`` should have a valid synapse id ``con.dest`` on the post-synaptic target `gid`,
-        and a valid source id ``con.source.index`` on the pre-synaptic source ``con.source.gid``.
+        Each connection ``con`` should have a valid synapse label ``con.dest`` on the post-synaptic target `gid`,
+        and a valid source label ``con.source.label`` on the pre-synaptic source ``con.source.gid``.
         See :cpp:type:`cell_connection`.
 
         By default returns an empty list.
@@ -83,8 +83,8 @@ Recipe
     .. cpp:function:: virtual std::vector<gap_junction_connection> gap_junctions_on(cell_gid_type gid) const
 
         Returns a list of all the gap junctions connected to `gid`.
-        Each gap junction ``gj`` should have a valid gap junction site id ``gj.local`` on ``gid``,
-        and a valid gap junction site id ``gj.peer.index`` on ``gj.peer.gid``.
+        Each gap junction ``gj`` should have a valid gap junction site label ``gj.local`` on ``gid``,
+        and a valid gap junction site label ``gj.peer.label`` on ``gj.peer.gid``.
         See :cpp:type:`gap_junction_connection`.
 
         By default returns an empty list.
@@ -95,28 +95,6 @@ Recipe
 
         By default returns an empty list.
 
-    .. cpp:function:: virtual cell_size_type num_sources(cell_gid_type gid) const
-
-        Returns the number of spike sources on `gid`. This corresponds to the number
-        of spike detectors on a multi-compartment cell. Typically there is one detector
-        at the soma of the cell, however it is possible to attache multiple detectors
-        at arbitrary locations.
-
-        By default returns 0.
-
-    .. cpp:function:: virtual cell_size_type num_targets(cell_gid_type gid) const
-
-        The number of post-synaptic sites on `gid`, which corresponds to the number
-        of synapses.
-
-        By default returns 0.
-
-    .. cpp:function:: virtual cell_size_type num_gap_junction_sites(cell_gid_type gid) const
-
-        Returns the number of gap junction sites on `gid`.
-
-        By default returns 0.
-
     .. cpp:function:: virtual std::vector<probe_info> get_probes(cell_gid_type gid) const
 
         Intended for use by cell group implementations to set up sampling data
diff --git a/doc/fileformat/cable_cell.rst b/doc/fileformat/cable_cell.rst
index 17f56450..95aebc30 100644
--- a/doc/fileformat/cable_cell.rst
+++ b/doc/fileformat/cable_cell.rst
@@ -201,18 +201,22 @@ The various properties and dynamics of the decor are described as follows:
    This expression sets the membrane capacitance of the region tagged ``1`` to 0.02 F/m².
 
 
-.. label:: (place ls:locset prop:placeable)
+.. label:: (place ls:locset prop:placeable label:string)
 
-   This places the property ``prop`` on locset ``ls``.
-   For example:
+   This places the property ``prop`` on locset ``ls`` and labels the group of items on the
+   locset with ``label``. For example:
 
    .. code:: lisp
 
-      (place (locset "mylocset") (threshold-detector 10))
+      (place (locset "mylocset") (threshold-detector 10) "mydetectors")
+
+   This expression places 10 mV threshold detectors on the locset labeled ``mylocset``,
+   and labels the detectors "mydetectors". The definition of ``mylocset`` should be provided
+   in a label dictionary associated with the decor.
 
-   This expression places a 10 mV threshold detector on the locset labeled ``mylocset``.
-   (The definition of ``mylocset`` should be provided in a label dictionary associated
-   with the decor).
+   The number of detectors placed depends on the number of locations in the "mylocset" locset.
+   The placed detectors can be referred to (in the recipe for example) using the label
+   "mydetectors".
 
 .. label:: (default prop:defaultable)
 
@@ -241,8 +245,8 @@ Any number of paint, place and default expressions can be used to create a decor
         (paint (region "soma") (membrane-potential -50.000000))
         (paint (all) (mechanism "pas"))
         (paint (tag 4) (mechanism "Ih" ("gbar" 0.001)))
-        (place (locset "root") (mechanism "expsyn"))
-        (place (terminal) (gap-junction-site)))
+        (place (locset "root") (mechanism "expsyn") "root_synapse")
+        (place (terminal) (gap-junction-site) "terminal_gj"))
 
 Morphology
 ----------
@@ -334,8 +338,8 @@ expressions.
           (paint (region "my_soma") (temperature-kelvin 270))
           (paint (region "my_region") (membrane-potential -50.000000))
           (paint (tag 4) (mechanism "Ih" ("gbar" 0.001)))
-          (place (locset "root") (mechanism "expsyn"))
-          (place (location 1 0.2) (gap-junction-site)))
+          (place (locset "root") (mechanism "expsyn") "root_synapse")
+          (place (location 1 0.2) (gap-junction-site) "terminal_gj"))
         (morphology
           (branch 0 -1
             (segment 0 (point 0 0 0 2) (point 4 0 0 2) 1)
@@ -401,7 +405,7 @@ Decoration
      (meta-data (version "0.1-dev"))
      (decor
        (default (membrane-potential -55.000000))
-       (place (locset "root") (mechanism "expsyn"))
+       (place (locset "root") (mechanism "expsyn") "root_synapse")
        (paint (region "my_soma") (temperature-kelvin 270))))
 
 Morphology
@@ -430,7 +434,7 @@ Cable-cell
          (locset-def "root" (root)))
        (decor
          (default (membrane-potential -55.000000))
-         (place (locset "root") (mechanism "expsyn"))
+         (place (locset "root") (mechanism "expsyn") "root_synapse")
          (paint (region "my_soma") (temperature-kelvin 270)))
        (morphology
           (branch 0 -1
diff --git a/doc/python/benchmark_cell.rst b/doc/python/benchmark_cell.rst
index aad447f2..2e16e136 100644
--- a/doc/python/benchmark_cell.rst
+++ b/doc/python/benchmark_cell.rst
@@ -9,7 +9,11 @@ Benchmark cells
 
     A benchmarking cell, used by Arbor developers to test communication performance.
 
-    .. function:: benchmark_cell(schedule, realtime_ratio)
+    .. function:: benchmark_cell(source, target, schedule, realtime_ratio)
+
+        Construct a benchmark cell with a single built-in source with label ``source``; and a
+        single built-in target with label ``target``. The labels can be used for forming connections from/to
+        the cell in the :py:class:`arbor.recipe` by creating a :py:class:`arbor.connection`.
 
         A benchmark cell generates spikes at a user-defined sequence of time points:
 
@@ -19,6 +23,10 @@ Benchmark cells
 
         and the time taken to integrate a cell can be tuned by setting the parameter ``realtime_ratio``.
 
+        :param source: label of the source on the cell.
+
+        :param target: label of the target on the cell.
+
         :param schedule: User-defined sequence of time points (choose from :class:`arbor.regular_schedule`, :class:`arbor.explicit_schedule`, or :class:`arbor.poisson_schedule`).
 
         :param realtime_ratio: Time taken to integrate a cell, for example if ``realtime_ratio`` = 2, a cell will take 2 seconds of CPU time to simulate 1 second.
diff --git a/doc/python/cell.rst b/doc/python/cell.rst
index e89d472f..7f19f603 100644
--- a/doc/python/cell.rst
+++ b/doc/python/cell.rst
@@ -9,19 +9,105 @@ The types defined below are used as identifiers for cells and members of cell-lo
 
 .. module:: arbor
 
+.. class:: selection_policy
+
+   Enumeration used for selecting an individual item from a group of items sharing the
+   same label.
+
+   .. attribute:: round_robin
+
+      Iterate over the items of the group in a round-robin fashion.
+
+   .. attribute:: univalent
+
+      Assert that only one item is available in the group. Throws an exception if the assertion
+      fails.
+
+.. class:: cell_local_label
+
+   For local identification of an item on an unspecified cell.
+
+   A local string label :attr:`tag` is used to identify a group of items within a particular
+   cell-local collection. Each label is associated with a set of items distributed over various
+   locations on the cell. The exact number of items associated to a label can only be known when the
+   model is built and is therefore not directly available to the user.
+
+   Because each label can be mapped to any of the items in its group, a :attr:`selection_policy`
+   is needed to select one of the items of the group. If the policy is not supplied, the default
+   :attr:`selection_policy.univalent` is selected.
+
+   :class:`cell_local_label` is used for selecting the target of a connection or the
+   local site of a gap junction connection. The cell ``gid`` of the item is implicitly known in the
+   recipe.
+
+   .. attribute:: tag
+
+      Identifier of a group of items in a cell-local collection.
+
+   .. attribute:: selection_policy
+
+      Policy used for selecting a single item of the tagged group.
+
+   An example of a cell member construction reads as follows:
+
+   .. container:: example-code
+
+       .. code-block:: python
+
+           import arbor
+
+           # Create the policy
+           policy = arbor.selection_policy.univalent
+
+           # Create the local label referring to the group of items labeled "syn0".
+           # The group is expected to only contain 1 item.
+           local_label = arbor.cell_local_label("syn0", policy)
+
+.. class:: cell_global_label
+
+   For global identification of an item on a cell.
+   This is used for selecting the source of a connection or the peer site of a gap junction connection.
+   The :attr:`label` expects a :class:`cell_local_label` type.
+
+   .. attribute:: gid
+
+      Global identifier of the cell associated with the item.
+
+   .. attribute:: label
+
+      Identifier of a single item on the cell.
+
+   .. container:: example-code
+
+       .. code-block:: python
+
+           import arbor
+
+           # Create the policy
+           policy = arbor.selection_policy.univalent
+
+           # Creat the local label referring to the group of items labeled "syn0".
+           # The group is expected to only contain 1 item.
+           local_label = arbor.cell_local_label("syn0", policy)
+
+           # Create the global label referring to the group of items labeled "syn0"
+           # on cell 5
+           global_label = arbor.cell_global_label(5, local_label)
 .. class:: cell_member
 
     .. function:: cell_member(gid, index)
 
-        Construct a ``cell_member`` object with parameters :attr:`gid` and :attr:`index` for global identification of a cell-local item.
+        Construct a ``cell_member`` object with parameters :attr:`gid` and :attr:`index` for
+        global identification of a cell-local item.
 
         Items of type :class:`cell_member` must:
 
         * be associated with a unique cell, identified by the member :attr:`gid`;
         * identify an item within a cell-local collection by the member :attr:`index`.
 
-        An example is uniquely identifying a synapse in the model.
-        Each synapse has a post-synaptic cell (with :attr:`gid`), and an :attr:`index` into the set of synapses on the post-synaptic cell.
+        An example is uniquely identifying a probe description in the model.
+        Each probe description has a cell (with :attr:`gid`), and an :attr:`index` into
+        the set of probe descriptions on the cell.
 
         Lexicographically ordered by :attr:`gid`, then :attr:`index`.
 
diff --git a/doc/python/decor.rst b/doc/python/decor.rst
index 4cebdb7c..8c0104a3 100644
--- a/doc/python/decor.rst
+++ b/doc/python/decor.rst
@@ -125,60 +125,61 @@ Cable cell decoration
         :param str region: description of the region.
         :param str mechanism: the name of the mechanism.
 
-    .. method:: place(locations, const arb::mechanism_desc& d)
+    .. method:: place(locations, mech_name, label)
 
-        Place one instance of synapse described by ``mechanism`` to each location in ``locations``.
-        Returns a unique identifier that can be used to query the local indexes (see :gen:`index`) assigned to the
-        placed items on the cable cell. For instance: the ``index`` returned when a synapse mechanism is placed,
-        can be used when creating a :py:class:`arbor.connection`
+        Place one instance of the synapse named ``mech_name`` to each location in ``locations`` and label the
+        group of synapses with ``label``. The label can be used to form connections to one of the synapses
+        in the :py:class:`arbor.recipe` by creating a :py:class:`arbor.connection`.
 
         :param str locations: description of the locset.
         :param str mechanism: the name of the mechanism.
-        :rtype: int
+        :param str label: the label of the group of synapses on the locset.
 
-    .. method:: place(locations, mechanism)
+    .. method:: place(locations, mechanism, label)
         :noindex:
 
-        Place one instance of synapse described by ``mechanism`` to each location in ``locations``.
-        Returns a unique identifier that can be used to query the local indexes (see :gen:`index`) assigned to the placed items on the cable cell.
+        Place one instance of the synapse described by ``mechanism`` to each location in ``locations`` and label the
+        group of synapses with ``label``. The label can be used to form connections to one of the synapses
+        in the :py:class:`arbor.recipe` by creating a :py:class:`arbor.connection`.
 
         :param str locations: description of the locset.
         :param mechanism: the mechanism.
         :type mechanism: :py:class:`mechanism`
-        :rtype: int
+        :param str label: the label of the group of synapses on the locset.
 
-    .. method:: place(locations, site)
+    .. method:: place(locations, site, label)
         :noindex:
 
-        Place one gap junction site at each location in ``locations``.
-        Returns a unique identifier that can be used to query the local indexes (see :gen:`index`) assigned to the placed items on the cable cell.
+        Place one gap junction site at each location in ``locations`` and label the group of gap junction sites with
+        ``label``. The label can be used to form connections to/from one of the gap junction sites in the
+        :py:class:`arbor.recipe` by creating a :py:class:`arbor.gap_junction_connection`.
 
         :param str locations: description of the locset.
         :param site: indicates a gap junction site..
         :type site: :py:class:`gap_junction_site`
-        :rtype: int
+        :param str label: the label of the group of gap junction sites on the locset.
 
-    .. method:: place(locations, stim)
+    .. method:: place(locations, stim, label)
         :noindex:
 
-        Add a current stimulus at each location in ``locations``.
-        Returns a unique identifier that can be used to query the local indexes (see :gen:`index`) assigned to the placed items on the cable cell.
+        Add a current stimulus at each location in ``locations`` and label the group of stimuli with ``label``.
 
         :param str locations: description of the locset.
         :param stim: the current stim.
         :type stim: :py:class:`iclamp`
-        :rtype: int
+        :param str label: the label of the group of stimuli on the locset.
 
-    .. method:: place(locations, d)
+    .. method:: place(locations, d, label)
         :noindex:
 
-        Add a voltage spike detector at each location in ``locations``.
-        Returns a unique identifier that can be used to query the local indexes (see :gen:`index`) assigned to the placed items on the cable cell.
+        Add a voltage spike detector at each location in ``locations`` and label the group of detectors with ``label``.
+        The label can be used to form connections from one of the detectors in the :py:class:`arbor.recipe` by creating
+        a :py:class:`arbor.connection`.
 
         :param str locations: description of the locset.
         :param d: description of the detector.
         :type d: :py:class:`threshold_detector`
-        :rtype: int
+        :param str label: the label of the group of detectors on the locset.
 
     .. method:: discretization(policy)
 
diff --git a/doc/python/interconnectivity.rst b/doc/python/interconnectivity.rst
index 944825a2..df1445d3 100644
--- a/doc/python/interconnectivity.rst
+++ b/doc/python/interconnectivity.rst
@@ -7,11 +7,11 @@ Interconnectivity
 
 .. class:: connection
 
-    Describes a connection between two cells, defined by source and destination end points (that is pre-synaptic and post-synaptic respectively),
-    a connection weight and a delay time.
+    Describes a connection between two cells, defined by source and destination end points (that is pre-synaptic and
+    post-synaptic respectively), a connection weight and a delay time.
 
-    The :attr:`dest` does not include the gid of a cell, this is because a :class:`arbor.connection` is bound to the destination cell which means that the gid
-    is implicitly known.
+    The :attr:`dest` does not include the gid of a cell, this is because a :class:`arbor.connection` is bound to the
+    destination cell which means that the gid is implicitly known.
 
     .. function:: connection(source, destination, weight, delay)
 
@@ -19,12 +19,16 @@ Interconnectivity
 
     .. attribute:: source
 
-        The source end point of the connection (type: :class:`arbor.cell_member`, which can be initialized with a (gid, index) tuple).
+        The source end point of the connection (type: :class:`arbor.cell_global_label`, which can be initialized with a
+        (gid, label) or a (gid, (label, policy)) tuple. If the policy is not indicated, the default
+        :attr:`arbor.selection_policy.univalent` is used).
 
     .. attribute:: dest
 
-        The destination end point of the connection (type: :class:`arbor.cell_member.index` representing the index of the destination on the cell).
-        The gid of the cell is implicitly known.
+        The destination end point of the connection (type: :class:`arbor.cell_local_label` representing the label of the
+        destination on the cell, which can be initialized with just a label, in which case the default
+        :attr:`arbor.selection_policy.univalent` is used, or a (label, policy) tuple). The gid of the cell is
+        implicitly known.
 
     .. attribute:: weight
 
@@ -45,20 +49,20 @@ Interconnectivity
             import arbor
 
             def connections_on(gid):
-               # construct a connection from the 0th source index of cell 2 (2,0)
-               # to the 1st target index of cell gid (gid,1) with weight 0.01 and delay of 10 ms.
-               src  = arbor.cell_member(2,0)
-               dest = 1 # gid of the destination is is determined by the argument to `connections_on`
+               # construct a connection from the "detector" source label on cell 2
+               # to the "syn" target label on cell gid with weight 0.01 and delay of 10 ms.
+               src  = arbor.cell_global_label(2, "detector")
+               dest = arbor.cell_local_label("syn") # gid of the destination is is determined by the argument to `connections_on`
                w    = 0.01
                d    = 10
                return [arbor.connection(src, dest, w, d)]
 
 .. class:: gap_junction_connection
 
-    Describes a gap junction between two gap junction sites. Gap junction sites are identified by :class:`arbor.cell_member`.
+    Describes a gap junction between two gap junction sites.
 
-    The :attr:`local` site does not include the gid of a cell, this is because a :class:`arbor.gap_junction_connection` is bound to
-    the destination cell which means that the gid is implicitly known.
+    The :attr:`local` site does not include the gid of a cell, this is because a :class:`arbor.gap_junction_connection`
+    is bound to the destination cell which means that the gid is implicitly known.
 
     .. note::
 
@@ -74,13 +78,16 @@ Interconnectivity
 
     .. attribute:: peer
 
-        The gap junction site: the remote half of the gap junction connection (type: :class:`arbor.cell_member`,
-        which can be initialized with a (gid, index) tuple).
+        The gap junction site: the remote half of the gap junction connection (type: :class:`arbor.cell_global_label`,
+        which can be initialized with a (gid, label) or a (gid, label, policy) tuple. If the policy is not indicated,
+        the default :attr:`arbor.selection_policy.univalent` is used).
 
     .. attribute:: local
 
-        The gap junction site: the local half of the gap junction connection (type: :class:`arbor.cell_member.index`, representing
-        the index of the local site on the cell). The gid of the cell is implicitly known.
+        The gap junction site: the local half of the gap junction connection (type: :class:`arbor.cell_local_label`
+        representing the label of the destination on the cell, which can be initialized with just a label, in which case
+        the default :attr:`arbor.selection_policy.univalent` is used, or a (label, policy) tuple). The gid of the
+        cell is implicitly known.
 
     .. attribute:: ggap
 
@@ -88,7 +95,8 @@ Interconnectivity
 
 .. class:: spike_detector
 
-    A spike detector, generates a spike when voltage crosses a threshold. Can be used as source endpoint for an :class:`arbor.connection`.
+    A spike detector, generates a spike when voltage crosses a threshold. Can be used as source endpoint for an
+    :class:`arbor.connection`.
 
     .. attribute:: threshold
 
diff --git a/doc/python/lif_cell.rst b/doc/python/lif_cell.rst
index dfd6d469..d8623077 100644
--- a/doc/python/lif_cell.rst
+++ b/doc/python/lif_cell.rst
@@ -10,6 +10,21 @@ LIF cells
     A benchmarking cell (leaky integrate-and-fire), used by Arbor developers to test communication performance,
     with neuronal parameters:
 
+    .. function:: lif_cell(source, target)
+
+        Constructor: assigns the label ``source`` to the single built-in source on the cell; and assigns the
+        label ``target`` to the single built-in target on the cell.
+
+    .. attribute:: source
+
+        The label of the single built-in source on the cell. Used for forming connections from the cell in the
+        :py:class:`arbor.recipe` by creating a :py:class:`arbor.connection`.
+
+    .. attribute:: target
+
+        The label of the single built-in target on the cell. Used for forming connections to the cell in the
+        :py:class:`arbor.recipe` by creating a :py:class:`arbor.connection`.
+
     .. attribute:: tau_m
 
         Membrane potential decaying constant [ms].
diff --git a/doc/python/recipe.rst b/doc/python/recipe.rst
index ecdea890..30a383b4 100644
--- a/doc/python/recipe.rst
+++ b/doc/python/recipe.rst
@@ -20,7 +20,7 @@ Recipe
     All recipes derive from this abstract base class.
 
     Recipes provide a cell-centric interface for describing a model.
-    This means that model properties, such as connections, are queried using the global identifier (:attr:`arbor.cell_member.gid`) of a cell.
+    This means that model properties, such as connections, are queried using the global identifier ``gid`` of a cell.
     In the description below, the term ``gid`` is used as shorthand for the cell with global identifier.
 
     **Required Member Functions**
@@ -49,8 +49,8 @@ Recipe
     .. function:: connections_on(gid)
 
         Returns a list of all the **incoming** connections to ``gid``.
-        Each connection should have a valid synapse id ``connection.dest`` on the post-synaptic target ``gid``,
-        and a valid source id ``connection.source.index`` on the pre-synaptic source ``connection.source.gid``.
+        Each connection should have a valid synapse label ``connection.dest`` on the post-synaptic target ``gid``,
+        and a valid source label ``connection.source.label`` on the pre-synaptic source ``connection.source.gid``.
         See :class:`connection`.
 
         By default returns an empty list.
@@ -58,8 +58,8 @@ Recipe
     .. function:: gap_junctions_on(gid)
 
         Returns a list of all the gap junctions connected to ``gid``.
-        Each gap junction ``gj`` should have a valid gap junction site id ``gj.local`` on ``gid``,
-        and a valid gap junction site id ``gj.peer.index`` on ``gj.peer.gid``.
+        Each gap junction ``gj`` should have a valid gap junction site label ``gj.local`` on ``gid``,
+        and a valid gap junction site label ``gj.peer.label`` on ``gj.peer.gid``.
         See :class:`gap_junction_connection`.
 
         By default returns an empty list.
@@ -70,30 +70,12 @@ Recipe
 
         By default returns an empty list.
 
-    .. function:: num_sources(gid)
-
-        The number of spike sources on ``gid``.
-
-        By default returns 0.
-
-    .. function:: num_targets(gid)
-
-        The number of post-synaptic sites on ``gid``, which corresponds to the number of synapses.
-
-        By default returns 0.
-
-    .. function:: num_gap_junction_sites(gid)
-
-        Returns the number of gap junction sites on ``gid``.
-
-        By default returns 0.
-
     .. function:: probes(gid)
 
         Returns a list specifying the probe addresses describing probes on the cell ``gid``.
         Each address in the list is an opaque object of type :class:`probe` produced by
         cell kind-specific probe address functions. Each probe address in the list
-        has a corresponding probe id of type :class:`cell_member_type`: an id ``(gid, i)``
+        has a corresponding probe id of type :class:`cell_member`: an id ``(gid, i)``
         refers to the probes described by the ith entry in the list returned by ``get_probes(gid)``.
 
         By default returns an empty list.
@@ -126,11 +108,13 @@ Event generator and schedules
 
     .. function:: event_generator(target, weight, schedule)
 
-        Construct an event generator for a :attr:`target` synapse with :attr:`weight` of the events to deliver based on a schedule (i.e., :class:`arbor.regular_schedule`, :class:`arbor.explicit_schedule`, :class:`arbor.poisson_schedule`).
+        Construct an event generator for a :attr:`target` synapse with :attr:`weight` of the events to
+        deliver based on a schedule (i.e., :class:`arbor.regular_schedule`, :class:`arbor.explicit_schedule`,
+        :class:`arbor.poisson_schedule`).
 
     .. attribute:: target
 
-        The target synapse of type :class:`arbor.cell_member.index`.
+        The target synapse of type :class:`arbor.cell_local_label`.
 
     .. attribute:: weight
 
@@ -221,7 +205,7 @@ An example of an event generator reads as follows:
         # define a Poisson schedule with start time 1 ms, expected frequency of 5 Hz,
         # and the target cell's gid as seed
         def event_generators(gid):
-            target = 0   # index of the synapse on target cell gid
+            target = arbor.cell_local_label("syn", arbor.selection_policy.round_robin) # label of the synapse on target cell gid
             seed   = gid
             tstart = 1
             freq   = 0.005
@@ -261,14 +245,10 @@ helpers in cell_parameters and make_cable_cell for building cells are used.
 
             # The cell_description method returns a cell.
             def cell_description(self, gid):
+                # Cell should have a synapse labeled "syn"
+                # and a detector labeled "detector"
                 return make_cable_cell(gid, self.params)
 
-            def num_targets(self, gid):
-                return 1
-
-            def num_sources(self, gid):
-                return 1
-
             # The kind method returns the type of cell with gid.
             # Note: this must agree with the type returned by cell_description.
             def cell_kind(self, gid):
@@ -279,13 +259,13 @@ helpers in cell_parameters and make_cable_cell for building cells are used.
                 src = (gid-1)%self.ncells
                 w = 0.01
                 d = 10
-                return [arbor.connection(arbor.cell_member(src,0), 0, w, d)]
+                return [arbor.connection((src,"detector"), "syn", w, d)]
 
             # Attach a generator to the first cell in the ring.
             def event_generators(self, gid):
                 if gid==0:
                     sched = arbor.explicit_schedule([1])
-                    return [arbor.event_generator(0, 0.1, sched)]
+                    return [arbor.event_generator("syn", 0.1, sched)]
                 return []
 
             def get_probes(self, id):
diff --git a/doc/python/spike_source_cell.rst b/doc/python/spike_source_cell.rst
index 339f5ec4..a146e6a9 100644
--- a/doc/python/spike_source_cell.rst
+++ b/doc/python/spike_source_cell.rst
@@ -10,12 +10,16 @@ Spike source cells
     A spike source cell, that generates a user-defined sequence of spikes
     that act as inputs for other cells in the network.
 
-    .. function:: spike_source_cell(schedule)
+    .. function:: spike_source_cell(source, schedule)
+
+        Construct a spike source cell that generates spikes and give it the label ``source``. The label
+        can be used for forming connections from the cell in the :py:class:`arbor.recipe` by creating a
+        :py:class:`arbor.connection`
 
-        Construct a spike source cell that generates spikes
 
         - at regular intervals (using an :class:`arbor.regular_schedule`)
         - at a sequence of user-defined times (using an :class:`arbor.explicit_schedule`)
         - at times defined by a Poisson sequence (using an :class:`arbor.poisson_schedule`)
 
+        :param source: label of the source on the cell.
         :param schedule: User-defined sequence of time points (choose from :class:`arbor.regular_schedule`, :class:`arbor.explicit_schedule`, or :class:`arbor.poisson_schedule`).
diff --git a/doc/tutorial/network_ring.rst b/doc/tutorial/network_ring.rst
index 8401e839..f395d695 100644
--- a/doc/tutorial/network_ring.rst
+++ b/doc/tutorial/network_ring.rst
@@ -62,12 +62,19 @@ These locations will form the endpoints of the connections between the cells.
    # Mark the root of the tree.
    labels['root'] = '(root)'
 
-After we've created a basic :py:class:`arbor.decor`, step **(3)** places a synapse with an exponential decay (``'expsyn'``) is on the ``'synapse_site'``.
+After we've created a basic :py:class:`arbor.decor`, step **(3)** places a synapse with an exponential decay (``'expsyn'``) on the ``'synapse_site'``.
+The synapse is given the label ``'syn'``, which is later used to form :py:class:`arbor.connection` objects terminating *at* the cell.
 Note that mechanisms can be initialized with their name; ``'expsyn'`` is short for ``arbor.mechanism('expsyn')``.
 
-Step **(4)** places a spike detector at the ``'root'``. :py:class:`spike_detectors <arbor.spike_detector>` will send spikes into an
-:class:`arbor.connection`, whereas the :ref:`expsyn mechanism <mechanisms_builtins>` can receive spikes from an
-:class:`arbor.connection`.
+Step **(4)** places a spike detector at the ``'root'``. The detector is given the label ``'detector'``, which is later used to form
+:py:class:`arbor.connection` objects originating *from* the cell.
+
+.. Note::
+
+   The number of synapses placed on the cell in this case is 1, because the ``'synapse_sites'`` locset is an explicit location.
+   Had the chosen locset contained multiple locations, an equal number of synapses would have been placed, all given the same label ``'syn'``.
+
+   The same explanation applies to the number of detectors on this cell.
 
 .. code-block:: python
 
@@ -77,11 +84,11 @@ Step **(4)** places a spike detector at the ``'root'``. :py:class:`spike_detecto
    decor.paint('"soma"', 'hh')
    decor.paint('"dend"', 'pas')
 
-   # (3) Attach a single synapse.
-   decor.place('"synapse_site"', 'expsyn')
+   # (3) Attach a single synapse, label it 'syn'
+   decor.place('"synapse_site"', 'expsyn', 'syn')
 
    # (4) Attach a spike detector with threshold of -10 mV.
-   decor.place('"root"', arbor.spike_detector(-10))
+   decor.place('"root"', arbor.spike_detector(-10), 'detector')
 
    cell = arbor.cable_cell(tree, labels, decor)
 
@@ -97,20 +104,24 @@ are connecting the cells **(8)**, returning a configurable number of cells **(6)
 (``make_cable_cell()`` returns the cell above).
 
 Step **(8)** creates an :py:class:`arbor.connection` between consecutive cells. If a cell has gid ``gid``, the
-previous cell has a gid ``(gid-1)%self.ncells``. The connection has a weight of 0.1 μS and a delay of 5 ms. The first two arguments
-to :py:class:`arbor.connection` are the **source** and **target** of the connection, and these are defined by the
-cell index ``gid`` and the source or target index. (:term:`Remember <connection>` that sources and targets are
-separately indexed.)
-
-The source endpoint has type :class:`arbor.cell_member`, and can be initialized with a ``(gid,index)`` tuple.
-The gid of the target end-point is implicitly known from the argument of :py:func:`arbor.recipe.connections_on`,
-Therefore, we only need to identify the index of the target on the cell using the :class:`arbor.cell_member.index` type.
-The cells have one synapse (step **3**), so the target endpoint has the 0th index. The cell has one
-spike generator (step **4**), so its source index is also 0.
-
-Lastly, we must inform the recipe how many sources and targets we have on each cell (``gid``).
-:func:`arbor.cable_cell.num_targets` and :func:`arbor.cable_cell.num_sources` must be set to 1: each cell has one
-source and one target endpoint.
+previous cell has a gid ``(gid-1)%self.ncells``. The connection has a weight of 0.1 μS and a delay of 5 ms.
+The first two arguments to :py:class:`arbor.connection` are the **source** and **target** of the connection.
+
+The **source** is a :py:class:`arbor.cell_global_label` object containing a cell index ``gid``, the source label
+corresponding to a valid detector label on the cell and an optional selection policy (for choosing a single detector
+out of potentially many detectors grouped under the same label - remember, in this case the number of detectors labeled
+'detector' is 1).
+The :py:class:`arbor.cell_global_label` can be initialized with a ``(gid, label)`` tuple, in which case the selection
+policy is the default :py:attr:`arbor.selection_policy.univalent`; or a ``(gid, (label, policy))`` tuple.
+
+The **target** is a :py:class:`arbor.cell_local_label` object containing a cell index ``gid``, the target label
+corresponding to a valid synapse label on the cell and an optional selection policy (for choosing a single synapse
+out of potentially many synapses grouped under the same label - remember, in this case the number of synapses labeled
+'syn' is 1).
+The :py:class:`arbor.cell_local_label` can be initialized with a ``label`` string, in which case the selection
+policy is the default :py:attr:`arbor.selection_policy.univalent`; or a ``(label, policy)`` tuple. The ``gid``
+of the target cell doesn't need to be explicitly added to the connection, it is the argument to the
+:py:func:`arbor.recipe.connections_on` method.
 
 Step **(9)** attaches an :py:class:`arbor.event_generator` on the 0th target (synapse) on the 0th cell; this means it
 is connected to the ``"synapse_site"`` on cell 0. This initiates the signal cascade through the network. The
@@ -153,19 +164,13 @@ Step **(11)** instantiates the recipe with 4 cells.
          src = (gid-1)%self.ncells
          w = 0.01
          d = 5
-         return [arbor.connection((src,0), (gid,0), w, d)]
-
-      def num_targets(self, gid):
-         return 1
-
-      def num_sources(self, gid):
-         return 1
+         return [arbor.connection((src,'detector'), 'syn', w, d)]
 
       # (9) Attach a generator to the first cell in the ring.
       def event_generators(self, gid):
          if gid==0:
                sched = arbor.explicit_schedule([1])
-               return [arbor.event_generator((0,0), 0.1, sched)]
+               return [arbor.event_generator('syn', 0.1, sched)]
          return []
 
       # (10) Place a probe at the root of each cell.
diff --git a/doc/tutorial/single_cell_detailed.rst b/doc/tutorial/single_cell_detailed.rst
index ba0b0f8c..52207ef0 100644
--- a/doc/tutorial/single_cell_detailed.rst
+++ b/doc/tutorial/single_cell_detailed.rst
@@ -323,16 +323,25 @@ constructed in order to change the default values of its 'gbar' parameter.
 
 The decor object is also used to *place* stimuli and spike detectors on the cell using :meth:`arbor.decor.place`.
 We place 3 current clamps of 2 nA on the "root" locset defined earlier, starting at time = 10, 30, 50 ms and
-lasting 1ms each. As well as spike detectors on the "axon_terminal" locset for voltages above -10 mV:
+lasting 1ms each. As well as spike detectors on the "axon_terminal" locset for voltages above -10 mV.
+Every placement gets a label. The labels of detectors and synapses are used to form connection from and to them
+in the recipe.
 
 .. code-block:: python
 
    # Place stimuli and spike detectors on certain locsets
 
-   decor.place('"root"', arbor.iclamp(10, 1, current=2))
-   decor.place('"root"', arbor.iclamp(30, 1, current=2))
-   decor.place('"root"', arbor.iclamp(50, 1, current=2))
-   decor.place('"axon_terminal"', arbor.spike_detector(-10))
+   decor.place('"root"', arbor.iclamp(10, 1, current=2), 'iclamp0')
+   decor.place('"root"', arbor.iclamp(30, 1, current=2), 'iclamp1')
+   decor.place('"root"', arbor.iclamp(50, 1, current=2), 'iclamp2')
+   decor.place('"axon_terminal"', arbor.spike_detector(-10), 'detector')
+
+.. Note::
+
+   The number of individual locations in the ``'axon_terminal'`` locset depends on the underlying morphology and the
+   number of axon branches in the morphology. The number of detectors that get added on the cell is equal to the number
+   of locations in the locset, and the label ``'detector'`` refers to all of them. If we want to refer to a single
+   detector from the group (to form a network connection for example), we need a :py:class:`arbor.selection_policy`.
 
 Finally, there's one last property that impacts the behavior of a model: the discretisation.
 Cells in Arbor are simulated as discrete components called control volumes (CV). The size of
diff --git a/doc/tutorial/single_cell_detailed_recipe.rst b/doc/tutorial/single_cell_detailed_recipe.rst
index f12580b0..a05849cf 100644
--- a/doc/tutorial/single_cell_detailed_recipe.rst
+++ b/doc/tutorial/single_cell_detailed_recipe.rst
@@ -93,10 +93,10 @@ We can immediately paste the cell description code from the
 
    # Place stimuli and spike detectors.
 
-   decor.place('"root"', arbor.iclamp(10, 1, current=2))
-   decor.place('"root"', arbor.iclamp(30, 1, current=2))
-   decor.place('"root"', arbor.iclamp(50, 1, current=2))
-   decor.place('"axon_terminal"', arbor.spike_detector(-10))
+   decor.place('"root"', arbor.iclamp(10, 1, current=2), 'iclamp0')
+   decor.place('"root"', arbor.iclamp(30, 1, current=2), 'iclamp1')
+   decor.place('"root"', arbor.iclamp(50, 1, current=2), 'iclamp2')
+   decor.place('"axon_terminal"', arbor.spike_detector(-10), 'detector')
 
    # Set cv_policy
 
@@ -153,39 +153,31 @@ examine the recipe in detail: how to create one, and why it is needed.
        def num_cells(self):
            return 1
 
-       # (4) Override the num_sources method
-       def num_sources(self, gid):
-           return 1
-
-       # (5) Override the num_targets method
-       def num_targets(self, gid):
-           return 0
-
-       # (6) Override the num_targets method
+       # (4) Override the cell_kind method
        def cell_kind(self, gid):
            return arbor.cell_kind.cable
 
-       # (7) Override the cell_description method
+       # (5) Override the cell_description method
        def cell_description(self, gid):
            return self.the_cell
 
-       # (8) Override the probes method
+       # (6) Override the probes method
        def probes(self, gid):
            return self.the_probes
 
-       # (9) Override the connections_on method
+       # (7) Override the connections_on method
        def connections_on(self, gid):
            return []
 
-       # (10) Override the gap_junction_on method
+       # (8) Override the gap_junction_on method
        def gap_junction_on(self, gid):
            return []
 
-       # (11) Override the event_generators method
+       # (9) Override the event_generators method
        def event_generators(self, gid):
            return []
 
-       # (12) Overrode the global_properties method
+       # (10) Overrode the global_properties method
        def global_properties(self, gid):
           return self.the_props
 
@@ -221,57 +213,38 @@ what we did in the :ref:`previous example <tutorialsinglecellswc-gprop>`. One la
 Step **(3)** overrides the :meth:`arbor.recipe.num_cells` method. It takes no arguments. We simply return 1,
 as we are only simulating one cell in this example.
 
-Step **(4)** overrides the :meth:`arbor.recipe.num_sources` method. It takes one argument: ``gid``.
-Given this global ID of a cell, the method will return the number of spike *sources* on the cell. We have defined
-our cell with one spike detector, on one location on the morphology, so we return 1.
-
-Step **(5)** overrides the :meth:`arbor.recipe.num_targets` method. It takes one argument: ``gid``.
-Given the gid, this method returns the number of *targets* on the cell. These are typically synapses on the cell
-that are capable of receiving events from other cells. We have defined our cell with 0 synapses, so we return 0.
-
-Step **(6)** overrides the :meth:`arbor.recipe.cell_kind` method. It takes one argument: ``gid``.
+Step **(4)** overrides the :meth:`arbor.recipe.cell_kind` method. It takes one argument: ``gid``.
 Given the gid, this method returns the kind of the cell. Our defined cell is a
 :class:`arbor.cell_kind.cable`, so we simply return that.
 
-Step **(7)** overrides the :meth:`arbor.recipe.cell_description` method. It takes one argument: ``gid``.
+Step **(5)** overrides the :meth:`arbor.recipe.cell_description` method. It takes one argument: ``gid``.
 Given the gid, this method returns the cell description which is the cell object passed to the constructor
 of the recipe. We return ``self.the_cell``.
 
-Step **(8)** overrides the :meth:`arbor.recipe.get_probes` method. It takes one argument: ``gid``.
+Step **(6)** overrides the :meth:`arbor.recipe.get_probes` method. It takes one argument: ``gid``.
 Given the gid, this method returns all the probes on the cell. The probes can be of many different kinds
 measuring different quantities on different locations of the cell. We pass these probes explicitly to the recipe
 and they are stored in ``self.the_probes``, so we return that variable.
 
-Step **(9)** overrides the :meth:`arbor.recipe.connections_on` method. It takes one argument: ``gid``.
+Step **(7)** overrides the :meth:`arbor.recipe.connections_on` method. It takes one argument: ``gid``.
 Given the gid, this method returns all the connections ending on that cell. These are typically synapse
 connections from other cell *sources* to specific *targets* on the cell with id ``gid``. Since we are
 simulating a single cell, and self-connections are not possible, we return an empty list.
 
-Step **(10)** overrides the :meth:`arbor.recipe.gap_junctions_on` method. It takes one argument: ``gid``.
+Step **(8)** overrides the :meth:`arbor.recipe.gap_junctions_on` method. It takes one argument: ``gid``.
 Given the gid, this method returns all the gap junctions on that cell. Gap junctions require 2 separate cells.
 Since we are simulating a single cell, we return an empty list.
 
-Step **(11)** overrides the :meth:`arbor.recipe.event_generators` method. It takes one argument: ``gid``.
+Step **(9)** overrides the :meth:`arbor.recipe.event_generators` method. It takes one argument: ``gid``.
 Given the gid, this method returns *event generators* on that cell. These generators trigger events (or
 spikes) on specific *targets* on the cell. They can be used to simulate spikes from other cells, to kick-start
 a simulation for example. Our cell uses a current clamp as a stimulus, and has no targets, so we return
 an empty list.
 
-Step **(12)** overrides the :meth:`arbor.recipe.global_properties` method. It takes one argument: ``kind``.
+Step **(10)** overrides the :meth:`arbor.recipe.global_properties` method. It takes one argument: ``kind``.
 This method returns the default global properties of the model which apply to all cells in the network of
 that kind. We return ``self.the_props`` which we defined in step **(1)**.
 
-.. Note::
-
-   You may wonder why the methods:  :meth:`arbor.recipe.num_sources`, :meth:`arbor.recipe.num_targets`,
-   and :meth:`arbor.recipe.cell_kind` are required, since they can be inferred by examining the cell description.
-   The recipe was designed to allow building simulations efficiently in a distributed system with minimum
-   communication. Some parts of the model initialization require only the cell kind, or the number of
-   sources and targets, not the full cell description which can be quite expensive to build. Providing these
-   descriptions separately saves time and resources for the user.
-
-   More information on the recipe can be found :ref:`here <modelrecipe>`.
-
 Now we can instantiate a ``single_recipe`` object using the ``cell`` and ``probe`` we created in the
 previous section:
 
diff --git a/doc/tutorial/single_cell_model.rst b/doc/tutorial/single_cell_model.rst
index a5ce9dd5..d659d0dc 100644
--- a/doc/tutorial/single_cell_model.rst
+++ b/doc/tutorial/single_cell_model.rst
@@ -52,8 +52,8 @@ Our *single-segment HH cell* has a simple morphology and dynamics, constructed a
     decor = arbor.decor()
     decor.set_property(Vm=-40)
     decor.paint('"soma"', 'hh')
-    decor.place('"midpoint"', arbor.iclamp( 10, 2, 0.8))
-    decor.place('"midpoint"', arbor.spike_detector(-10))
+    decor.place('"midpoint"', arbor.iclamp( 10, 2, 0.8), 'iclamp')
+    decor.place('"midpoint"', arbor.spike_detector(-10), 'detector')
 
     # (4) Create cell
     cell = arbor.cable_cell(tree, labels, decor)
diff --git a/doc/tutorial/single_cell_recipe.rst b/doc/tutorial/single_cell_recipe.rst
index 41963f59..0012e2f0 100644
--- a/doc/tutorial/single_cell_recipe.rst
+++ b/doc/tutorial/single_cell_recipe.rst
@@ -37,8 +37,8 @@ We can immediately paste the cell description code from the
     decor = arbor.decor()
     decor.set_property(Vm=-40)
     decor.paint('"soma"', 'hh')
-    decor.place('"midpoint"', arbor.iclamp( 10, 2, 0.8))
-    decor.place('"midpoint"', arbor.spike_detector(-10))
+    decor.place('"midpoint"', arbor.iclamp( 10, 2, 0.8), 'iclamp')
+    decor.place('"midpoint"', arbor.spike_detector(-10), 'detector')
     cell = arbor.cable_cell(tree, labels, decor)
 
 The recipe
@@ -73,24 +73,20 @@ It returns `0` by default and models without cells are quite boring!
             # (4.2) Override the num_cells method
             return 1
 
-        def num_sources(self, gid):
-            # (4.3) Override the num_sources method
-            return 1
-
         def cell_kind(self, gid):
-            # (4.4) Override the cell_kind method
+            # (4.3) Override the cell_kind method
             return arbor.cell_kind.cable
 
         def cell_description(self, gid):
-            # (4.5) Override the cell_description method
+            # (4.4) Override the cell_description method
             return self.the_cell
 
         def probes(self, gid):
-            # (4.6) Override the probes method
+            # (4.5) Override the probes method
             return self.the_probes
 
         def global_properties(self, kind):
-            # (4.7) Override the global_properties method
+            # (4.6) Override the global_properties method
             return self.the_props
 
     # (5) Instantiate recipe with a voltage probe located on "midpoint".
@@ -109,21 +105,19 @@ extend with Arbor's own :meth:`arbor.default_catalogue`.
 
 Step **(4.2)** defines that this model has one cell.
 
-Step **(4.3)** defines that this model has one source.
-
-Step **(4.4)** returns :class:`arbor.cell_kind.cable`, the :class:`arbor.cell_kind`
+Step **(4.3)** returns :class:`arbor.cell_kind.cable`, the :class:`arbor.cell_kind`
 associated with the cable cell defined above. If you mix multiple cell kinds and
 descriptions in one recipe, make sure a particular ``gid`` returns matching cell kinds
 and descriptions.
 
-Step **(4.5)** returns the cell description passed in on class initialisation. If we
+Step **(4.4)** returns the cell description passed in on class initialisation. If we
 were modelling multiple cells of different kinds, we would need to make sure that the
 cell returned by :meth:`arbor.recipe.cell_description` has the same cell kind as
 returned by :meth:`arbor.recipe.cell_kind` for every :gen:`gid`.
 
-Step **(4.6)** returns the probes passed in at class initialisation.
+Step **(4.5)** returns the probes passed in at class initialisation.
 
-Step **(4.7)** returns the properties that will be applied to all cells of that kind in the model.
+Step **(4.6)** returns the properties that will be applied to all cells of that kind in the model.
 
 More methods can be overridden if your model requires that, see :class:`arbor.recipe` for options.
 
diff --git a/example/bench/bench.cpp b/example/bench/bench.cpp
index 43df04e0..9006af75 100644
--- a/example/bench/bench.cpp
+++ b/example/bench/bench.cpp
@@ -87,33 +87,21 @@ public:
 
     arb::util::unique_any get_cell_description(arb::cell_gid_type gid) const override {
         std::mt19937_64 rng(gid);
-        arb::benchmark_cell cell;
-        cell.realtime_ratio = params_.cell.realtime_ratio;
 
         // The time_sequence of the cell produces the series of time points at
         // which it will spike. We use a poisson_schedule with a random sequence
         // seeded with the gid. In this way, a cell's random stream depends only
         // on its gid, and will hence give reproducable results when run with
         // different MPI ranks and threads.
-        cell.time_sequence = arb::poisson_schedule(1e-3*params_.cell.spike_freq_hz, rng);
-        return std::move(cell);
+        auto sched = arb::poisson_schedule(1e-3*params_.cell.spike_freq_hz, rng);
+
+        return arb::benchmark_cell("src", "tgt", sched, params_.cell.realtime_ratio);
     }
 
     arb::cell_kind get_cell_kind(arb::cell_gid_type gid) const override {
         return arb::cell_kind::benchmark;
     }
 
-    arb::cell_size_type num_targets(arb::cell_gid_type gid) const override {
-        // Only one target, to which all incoming connections connect.
-        // This could be parameterized, in which case the connections
-        // generated in connections_on should end on random cell-local targets.
-        return 1;
-    }
-
-    arb::cell_size_type num_sources(arb::cell_gid_type gid) const override {
-        return 1;
-    }
-
     std::vector<arb::cell_connection> connections_on(arb::cell_gid_type gid) const override {
         const auto n = params_.network.fan_in;
         std::vector<arb::cell_connection> cons;
@@ -133,8 +121,7 @@ public:
             // Draw random source and adjust to avoid self-connections if neccesary.
             arb::cell_gid_type src = dist(rng);
             if (src>=gid) ++src;
-            // Note: target is {gid, 0}, i.e. the first (and only) target on the cell.
-            arb::cell_connection con({src, 0}, 0, 1.f, params_.network.min_delay);
+            arb::cell_connection con({src, "src"}, {"tgt"}, 1.f, params_.network.min_delay);
             cons.push_back(con);
         }
 
@@ -244,7 +231,7 @@ bench_params read_options(int argc, char** argv) {
         return params;
     }
     if (argc>2) {
-        throw std::runtime_error("More than command line one option not permitted.");
+        throw std::runtime_error("More than one command line option is not permitted.");
     }
 
     std::string fname = argv[1];
diff --git a/example/brunel/brunel.cpp b/example/brunel/brunel.cpp
index 354db0b2..40029aa0 100644
--- a/example/brunel/brunel.cpp
+++ b/example/brunel/brunel.cpp
@@ -119,18 +119,18 @@ public:
         std::vector<cell_connection> connections;
         // Add incoming excitatory connections.
         for (auto i: sample_subset(gid, 0, ncells_exc_, in_degree_exc_)) {
-            connections.push_back({{cell_gid_type(i), 0}, 0, weight_exc_, delay_});
+            connections.push_back({{cell_gid_type(i), "src"}, {"tgt"}, weight_exc_, delay_});
         }
 
         // Add incoming inhibitory connections.
         for (auto i: sample_subset(gid, ncells_exc_, ncells_exc_ + ncells_inh_, in_degree_inh_)) {
-            connections.push_back({{cell_gid_type(i), 0}, 0, weight_inh_, delay_});
+            connections.push_back({{cell_gid_type(i), "src"}, {"tgt"}, weight_inh_, delay_});
         }
         return connections;
     }
 
     util::unique_any get_cell_description(cell_gid_type gid) const override {
-        auto cell = lif_cell();
+        auto cell = lif_cell("src", "tgt");
         cell.tau_m = 10;
         cell.V_th = 10;
         cell.C_m = 20;
@@ -145,15 +145,7 @@ public:
         std::mt19937_64 G;
         G.seed(gid + seed_);
         time_type t0 = 0;
-        return {poisson_generator(0, weight_ext_, t0, lambda_, G)};
-    }
-
-    cell_size_type num_sources(cell_gid_type) const override {
-         return 1;
-    }
-
-    cell_size_type num_targets(cell_gid_type) const override {
-        return 1;
+        return {poisson_generator({"tgt"}, weight_ext_, t0, lambda_, G)};
     }
 
 private:
diff --git a/example/dryrun/branch_cell.hpp b/example/dryrun/branch_cell.hpp
index 15344fc8..77f71c14 100644
--- a/example/dryrun/branch_cell.hpp
+++ b/example/dryrun/branch_cell.hpp
@@ -117,14 +117,14 @@ arb::cable_cell branch_cell(arb::cell_gid_type gid, const cell_parameters& param
     decor.set_default(arb::axial_resistivity{100}); // [Ω·cm]
 
     // Add spike threshold detector at the soma.
-    decor.place(arb::mlocation{0,0}, arb::threshold_detector{10});
+    decor.place(arb::mlocation{0,0}, arb::threshold_detector{10}, "detector");
 
     // Add a synapse to the mid point of the first dendrite.
-    decor.place(arb::mlocation{0, 0.5}, "expsyn");
+    decor.place(arb::mlocation{0, 0.5}, "expsyn", "synapse");
 
     // Add additional synapses that will not be connected to anything.
     for (unsigned i=1u; i<params.synapses; ++i) {
-        decor.place(arb::mlocation{1, 0.5}, "expsyn");
+        decor.place(arb::mlocation{1, 0.5}, "expsyn", "dummy_synapses");
     }
 
     // Make a CV between every sample in the sample tree.
diff --git a/example/dryrun/dryrun.cpp b/example/dryrun/dryrun.cpp
index a743ac75..bdfa32e7 100644
--- a/example/dryrun/dryrun.cpp
+++ b/example/dryrun/dryrun.cpp
@@ -89,16 +89,6 @@ public:
         return gprop;
     }
 
-    // Each cell has one spike detector (at the soma).
-    cell_size_type num_sources(cell_gid_type gid) const override {
-        return 1;
-    }
-
-    // The cell has one target synapse
-    cell_size_type num_targets(cell_gid_type gid) const override {
-        return 1;
-    }
-
     // Each cell has one incoming connection, from any cell in the network spanning all ranks:
     // src gid in {0, ..., num_cells_*num_tiles_ - 1}.
     std::vector<arb::cell_connection> connections_on(cell_gid_type gid) const override {
@@ -108,7 +98,7 @@ public:
         auto src = source_distribution(src_gen);
         if (src>=gid) ++src;
 
-        return {arb::cell_connection({src, 0}, 0, event_weight_, min_delay_)};
+        return {arb::cell_connection({src, "detector"}, {"synapse"}, event_weight_, min_delay_)};
     }
 
     // Return an event generator on every 20th gid. This function needs to generate events
@@ -117,7 +107,7 @@ public:
     std::vector<arb::event_generator> event_generators(cell_gid_type gid) const override {
         std::vector<arb::event_generator> gens;
         if (gid%20 == 0) {
-            gens.push_back(arb::explicit_generator(arb::pse_vector{{0, 1.0, event_weight_}}));
+            gens.push_back(arb::explicit_generator({{{"synapse"}, 1.0, event_weight_}}));
         }
         return gens;
     }
@@ -280,7 +270,7 @@ run_params read_options(int argc, char** argv) {
         return params;
     }
     if (argc>2) {
-        throw std::runtime_error("More than command line one option not permitted.");
+        throw std::runtime_error("More than one command line option is not permitted.");
     }
 
     std::string fname = argv[1];
diff --git a/example/gap_junctions/gap_junctions.cpp b/example/gap_junctions/gap_junctions.cpp
index 58f37a4d..97206b03 100644
--- a/example/gap_junctions/gap_junctions.cpp
+++ b/example/gap_junctions/gap_junctions.cpp
@@ -77,21 +77,11 @@ public:
         return cell_kind::cable;
     }
 
-    // Each cell has one spike detector (at the soma).
-    cell_size_type num_sources(cell_gid_type gid) const override {
-        return 1;
-    }
-
-    // The cell has one target synapse, which will be connected to a cell in another cable.
-    cell_size_type num_targets(cell_gid_type gid) const override {
-        return 1;
-    }
-
     std::vector<arb::cell_connection> connections_on(cell_gid_type gid) const override {
         if(gid % params_.n_cells_per_cable || (int)gid - 1 < 0) {
             return{};
         }
-        return {arb::cell_connection({gid - 1, 0}, 0, params_.event_weight, params_.event_min_delay)};
+        return {arb::cell_connection({gid - 1, "detector"}, {"syn"}, params_.event_weight, params_.event_min_delay)};
     }
 
     std::vector<arb::probe_info> get_probes(cell_gid_type gid) const override {
@@ -108,6 +98,7 @@ public:
     }
 
     std::vector<arb::gap_junction_connection> gap_junctions_on(cell_gid_type gid) const override{
+        using policy = arb::lid_selection_policy;
         std::vector<arb::gap_junction_connection> conns;
 
         int cable_begin = (gid/params_.n_cells_per_cable) * params_.n_cells_per_cable;
@@ -121,10 +112,12 @@ public:
         // Gap junction conductance in μS
 
         if (next_cell < cable_end) {
-            conns.push_back(arb::gap_junction_connection({(cell_gid_type)next_cell, 0}, 1, 0.015));
+            conns.push_back(arb::gap_junction_connection({(cell_gid_type)next_cell, "local_0", policy::assert_univalent},
+                                                         {"local_1", policy::assert_univalent}, 0.015));
         }
         if (prev_cell >= cable_begin) {
-            conns.push_back(arb::gap_junction_connection({(cell_gid_type)prev_cell, 1}, 0, 0.015));
+            conns.push_back(arb::gap_junction_connection({(cell_gid_type)prev_cell, "local_1", policy::assert_univalent},
+                                                         {"local_0", policy::assert_univalent}, 0.015));
         }
 
         return conns;
@@ -312,20 +305,20 @@ arb::cable_cell gj_cell(cell_gid_type gid, unsigned ncell, double stim_duration)
     decor.paint("(all)", pas);
 
     // Add a spike detector to the soma.
-    decor.place(arb::mlocation{0,0}, arb::threshold_detector{10});
+    decor.place(arb::mlocation{0,0}, arb::threshold_detector{10}, "detector");
 
     // Add two gap junction sites.
-    decor.place(arb::mlocation{0, 1}, arb::gap_junction_site{});
-    decor.place(arb::mlocation{0, 1}, arb::gap_junction_site{});
+    decor.place(arb::mlocation{0, 1}, arb::gap_junction_site{}, "local_0");
+    decor.place(arb::mlocation{0, 1}, arb::gap_junction_site{}, "local_1");
 
     // Attach a stimulus to the second cell.
     if (!gid) {
         auto stim = arb::i_clamp::box(0, stim_duration, 0.4);
-        decor.place(arb::mlocation{0, 0.5}, stim);
+        decor.place(arb::mlocation{0, 0.5}, stim, "stim");
     }
 
     // Add a synapse to the mid point of the first dendrite.
-    decor.place(arb::mlocation{0, 0.5}, "expsyn");
+    decor.place(arb::mlocation{0, 0.5}, "expsyn", "syn");
 
     // Create the cell and set its electrical properties.
     return arb::cable_cell(tree, {}, decor);
@@ -340,7 +333,7 @@ gap_params read_options(int argc, char** argv) {
         return params;
     }
     if (argc>2) {
-        throw std::runtime_error("More than command line one option not permitted.");
+        throw std::runtime_error("More than one command line option is not permitted.");
     }
 
     std::string fname = argv[1];
diff --git a/example/generators/generators.cpp b/example/generators/generators.cpp
index a2747ea3..96db4f04 100644
--- a/example/generators/generators.cpp
+++ b/example/generators/generators.cpp
@@ -61,7 +61,7 @@ public:
         // Add one synapse at the soma.
         // This synapse will be the target for all events, from both
         // event_generators.
-        decor.place(arb::mlocation{0, 0.5}, "expsyn");
+        decor.place(arb::mlocation{0, 0.5}, "expsyn", "syn");
 
         return arb::cable_cell(tree, labels, decor);
     }
@@ -77,12 +77,6 @@ public:
         return gprop;
     }
 
-    // The cell has one target synapse, which receives both inhibitory and exchitatory inputs.
-    cell_size_type num_targets(cell_gid_type gid) const override {
-        assert(gid==0); // There is only one cell in the model
-        return 1;
-    }
-
     // Return two generators attached to the one cell.
     std::vector<arb::event_generator> event_generators(cell_gid_type gid) const override {
         assert(gid==0); // There is only one cell in the model
@@ -103,7 +97,7 @@ public:
 
         // Add excitatory generator
         gens.push_back(
-            arb::poisson_generator(0,              // Target synapse index on cell `gid`
+            arb::poisson_generator({"syn"},               // Target synapse index on cell `gid`
                                    w_e,                   // Weight of events to deliver
                                    t0,                    // Events start being delivered from this time
                                    lambda_e,              // Expected frequency (kHz)
@@ -111,7 +105,7 @@ public:
 
         // Add inhibitory generator
         gens.emplace_back(
-            arb::poisson_generator(0, w_i, t0, lambda_i,  RNG(86543891)));
+            arb::poisson_generator({"syn"}, w_i, t0, lambda_i,  RNG(86543891)));
 
         return gens;
     }
diff --git a/example/generators/readme.md b/example/generators/readme.md
index fd32e77d..843d5664 100644
--- a/example/generators/readme.md
+++ b/example/generators/readme.md
@@ -36,10 +36,6 @@ Every user-defined recipe must provide implementations for the following three m
 * `recipe::get_cell_description(gid)`: return a description of the cell with `gid`.
 * `recipe::get_cell_kind(gid)`:  return an `arb::cell_kind` enum value for the cell type.
 
-In addition to these three, we also need to implement
-`recipe::num_targets(gid)` to return 1, to indicate that there is one target
-(i.e. synapse) on the cell.
-
 This single cell model has no connections for spike communication, so the
 default `recipe::connections_on(gid)` method can use the default implementation,
 which returns an empty list of connections for a cell.
@@ -52,28 +48,26 @@ compartment cell with one synapse, and a `arb::cell_kind::cable` respectively:
     // Return an arb::cell that describes a single compartment cell with
     // passive dynamics, and a single expsyn synapse.
     arb::util::unique_any get_cell_description(cell_gid_type gid) const override {
-        arb::cell c;
+        arb::segment_tree tree;
+        double r = 18.8/2.0; // convert 18.8 μm diameter to radius
+        tree.append(arb::mnpos, {0,0,-r,r}, {0,0,r,r}, 1);
+
+        arb::label_dict labels;
+        labels.set("soma", arb::reg::tagged(1));
 
-        c.add_soma(18.8/2.0);
-        c.soma()->add_mechanism("pas");
+        arb::decor decor;
+        decor.paint("\"soma\"", "pas");
 
-        // Add one synapse at the soma.
+        // Add one synapse labeled "syn" at the soma.
         // This synapse will be the target for all events, from both event_generators.
-        auto syn_spec = arb::mechanism_spec("expsyn");
-        c.add_synapse({0, 0.5}, syn_spec);
+        decor.place(arb::mlocation{0, 0.5}, "expsyn", "syn");
 
-        return std::move(c); // move into unique_any wrapper
+        return arb::cable_cell(tree, labels, decor);
     }
 
     cell_kind get_cell_kind(cell_gid_type gid) const override {
         return cell_kind::cable;
     }
-
-    // There is one synapse, i.e. target, on the cell.
-    cell_size_type num_targets(cell_gid_type gid) const override {
-        EXPECTS(gid==0); // There is only one cell in the model
-        return 1;
-    }
 ```
 
 ### Event Generators
@@ -105,18 +99,16 @@ The implementation of this with hard-coded frequencies and weights is:
         std::vector<arb::event_generator> gens;
 
         // Add excitatory generator
-        gens.emplace_back(
-	    arb::poisson_generator(
-                 cell_member_type{0,0}, // Target synapse (gid, local_id).
-                 w_e,                   // Weight of events to deliver
-                 t0,                    // Events start being delivered from this time
-                 lambda_e,              // Expected frequency (events per ms)
-                 RNG(29562872)));       // Random number generator to use
-
-        // Add inhibitory generator
-        gens.emplace_back(
-            arb::poisson_generator(cell_member_type{0,0}, w_i, t0, lambda_i, RNG(86543891)));
-
+         gens.push_back(
+            arb::poisson_generator(
+                {"syn"},               // Target synapse index on cell `gid`
+                w_e,                   // Weight of events to deliver
+                t0,                    // Events start being delivered from this time
+                lambda_e,              // Expected frequency (kHz)
+                RNG(29562872)));       // Random number generator to use
+
+                // Add inhibitory generator
+        gens.emplace_back(arb::poisson_generator({"syn"}, w_i, t0, lambda_i,  RNG(86543891)));
         return gens;
     }
 ```
diff --git a/example/lfp/lfp.cpp b/example/lfp/lfp.cpp
index 10c463ad..61ddb27b 100644
--- a/example/lfp/lfp.cpp
+++ b/example/lfp/lfp.cpp
@@ -33,7 +33,6 @@ struct lfp_demo_recipe: public arb::recipe {
     }
 
     arb::cell_size_type num_cells() const override { return 1; }
-    arb::cell_size_type num_targets(cell_gid_type) const override { return 1; }
 
     std::vector<arb::probe_info> get_probes(cell_gid_type) const override {
         // Four probes:
@@ -95,7 +94,7 @@ private:
 
         // Add exponential synapse at centre of soma.
         synapse_location_ = ls::on_components(0.5, reg::tagged(1));
-        dec.place(synapse_location_, mechanism_desc("expsyn").set("e", 0).set("tau", 2));
+        dec.place(synapse_location_, mechanism_desc("expsyn").set("e", 0).set("tau", 2), "syn");
 
         cell_ = cable_cell(tree, {}, dec);
     }
@@ -187,7 +186,7 @@ int main(int argc, char** argv) {
     auto context = arb::make_context();
 
     // Weight 0.005 μS, onset at t = 0 ms, mean frequency 0.1 kHz.
-    auto events = arb::poisson_generator(0, .005, 0., 0.1, std::minstd_rand{});
+    auto events = arb::poisson_generator({"syn"}, .005, 0., 0.1, std::minstd_rand{});
     lfp_demo_recipe R(events);
 
     const double t_stop = 100;    // [ms]
diff --git a/example/probe-demo/probe-demo.cpp b/example/probe-demo/probe-demo.cpp
index d0ced901..6f67587a 100644
--- a/example/probe-demo/probe-demo.cpp
+++ b/example/probe-demo/probe-demo.cpp
@@ -98,7 +98,6 @@ struct cable_recipe: public arb::recipe {
     }
 
     arb::cell_size_type num_cells() const override { return 1; }
-    arb::cell_size_type num_targets(arb::cell_gid_type) const override { return 0; }
 
     std::vector<arb::probe_info> get_probes(arb::cell_gid_type) const override {
         return {probe_addr}; // (use default tag value 0)
@@ -121,7 +120,7 @@ struct cable_recipe: public arb::recipe {
 
         arb::decor decor;
         decor.paint(arb::reg::all(), "hh"); // HH mechanism over whole cell.
-        decor.place(arb::mlocation{0, 0.}, arb::i_clamp{1.}); // Inject a 1 nA current indefinitely.
+        decor.place(arb::mlocation{0, 0.}, arb::i_clamp{1.}, "iclamp"); // Inject a 1 nA current indefinitely.
 
         return arb::cable_cell(tree, {}, decor);
     }
diff --git a/example/ring/branch_cell.hpp b/example/ring/branch_cell.hpp
index 15344fc8..1a243a4d 100644
--- a/example/ring/branch_cell.hpp
+++ b/example/ring/branch_cell.hpp
@@ -117,14 +117,15 @@ arb::cable_cell branch_cell(arb::cell_gid_type gid, const cell_parameters& param
     decor.set_default(arb::axial_resistivity{100}); // [Ω·cm]
 
     // Add spike threshold detector at the soma.
-    decor.place(arb::mlocation{0,0}, arb::threshold_detector{10});
+    decor.place(arb::mlocation{0,0}, arb::threshold_detector{10}, "detector");
 
     // Add a synapse to the mid point of the first dendrite.
-    decor.place(arb::mlocation{0, 0.5}, "expsyn");
+    decor.place(arb::mlocation{0, 0.5}, "expsyn", "primary_syn");
 
     // Add additional synapses that will not be connected to anything.
-    for (unsigned i=1u; i<params.synapses; ++i) {
-        decor.place(arb::mlocation{1, 0.5}, "expsyn");
+
+    if (params.synapses > 1) {
+        decor.place(arb::ls::uniform("dend"_lab, 0, params.synapses - 2, gid), "expsyn", "extra_syns");
     }
 
     // Make a CV between every sample in the sample tree.
diff --git a/example/ring/ring.cpp b/example/ring/ring.cpp
index c3091d5a..3e34d80b 100644
--- a/example/ring/ring.cpp
+++ b/example/ring/ring.cpp
@@ -84,20 +84,12 @@ public:
         return cell_kind::cable;
     }
 
-    // Each cell has one spike detector (at the soma).
-    cell_size_type num_sources(cell_gid_type gid) const override {
-        return 1;
-    }
-
-    // The cell has one target synapse, which will be connected to cell gid-1.
-    cell_size_type num_targets(cell_gid_type gid) const override {
-        return 1;
-    }
-
     // Each cell has one incoming connection, from cell with gid-1.
     std::vector<arb::cell_connection> connections_on(cell_gid_type gid) const override {
+        std::vector<arb::cell_connection> cons;
         cell_gid_type src = gid? gid-1: num_cells_-1;
-        return {arb::cell_connection({src, 0}, 0, event_weight_, min_delay_)};
+        cons.push_back(arb::cell_connection({src, "detector"}, {"primary_syn"}, event_weight_, min_delay_));
+        return cons;
     }
 
     // Return one event generator on gid 0. This generates a single event that will
@@ -105,7 +97,7 @@ public:
     std::vector<arb::event_generator> event_generators(cell_gid_type gid) const override {
         std::vector<arb::event_generator> gens;
         if (!gid) {
-            gens.push_back(arb::explicit_generator(arb::pse_vector{{0, 1.0, event_weight_}}));
+            gens.push_back(arb::explicit_generator({{{"primary_syn"}, 1.0, event_weight_}}));
         }
         return gens;
     }
@@ -274,7 +266,7 @@ ring_params read_options(int argc, char** argv) {
         return params;
     }
     if (argc>2) {
-        throw std::runtime_error("More than command line one option not permitted.");
+        throw std::runtime_error("More than one command line option is not permitted.");
     }
 
     std::string fname = argv[1];
diff --git a/example/single/single.cpp b/example/single/single.cpp
index db95d817..ea6cf74b 100644
--- a/example/single/single.cpp
+++ b/example/single/single.cpp
@@ -36,7 +36,6 @@ struct single_recipe: public arb::recipe {
     }
 
     arb::cell_size_type num_cells() const override { return 1; }
-    arb::cell_size_type num_targets(arb::cell_gid_type) const override { return 1; }
 
     std::vector<arb::probe_info> get_probes(arb::cell_gid_type) const override {
         arb::mlocation mid_soma = {0, 0.5};
@@ -72,7 +71,7 @@ struct single_recipe: public arb::recipe {
 
         arb::cell_lid_type last_branch = morpho.num_branches()-1;
         arb::mlocation end_last_branch = { last_branch, 1. };
-        decor.place(end_last_branch, "exp2syn");
+        decor.place(end_last_branch, "exp2syn", "synapse");
 
         return arb::cable_cell(morpho, dict, decor);
     }
diff --git a/python/cells.cpp b/python/cells.cpp
index 447a725a..7105d48c 100644
--- a/python/cells.cpp
+++ b/python/cells.cpp
@@ -138,17 +138,23 @@ void register_cells(pybind11::module& m) {
 
     spike_source_cell
         .def(pybind11::init<>(
-            [](const regular_schedule_shim& sched){
-                return arb::spike_source_cell{sched.schedule()};}),
-            "schedule"_a, "Construct a spike source cell that generates spikes at regular intervals.")
+            [](arb::cell_tag_type source_label, const regular_schedule_shim& sched){
+                return arb::spike_source_cell{std::move(source_label), sched.schedule()};}),
+            "source_label"_a, "schedule"_a,
+            "Construct a spike source cell with a single source labeled 'source_label'.\n"
+            "The cell generates spikes on 'source_label' at regular intervals.")
         .def(pybind11::init<>(
-            [](const explicit_schedule_shim& sched){
-                return arb::spike_source_cell{sched.schedule()};}),
-            "schedule"_a, "Construct a spike source cell that generates spikes at a sequence of user-defined times.")
+            [](arb::cell_tag_type source_label, const explicit_schedule_shim& sched){
+                return arb::spike_source_cell{std::move(source_label), sched.schedule()};}),
+            "source_label"_a, "schedule"_a,
+            "Construct a spike source cell with a single source labeled 'source_label'.\n"
+            "The cell generates spikes on 'source_label' at a sequence of user-defined times.")
         .def(pybind11::init<>(
-            [](const poisson_schedule_shim& sched){
-                return arb::spike_source_cell{sched.schedule()};}),
-            "schedule"_a, "Construct a spike source cell that generates spikes at times defined by a Poisson sequence.")
+            [](arb::cell_tag_type source_label, const poisson_schedule_shim& sched){
+                return arb::spike_source_cell{std::move(source_label), sched.schedule()};}),
+            "source_label"_a, "schedule"_a,
+            "Construct a spike source cell with a single source labeled 'source_label'.\n"
+            "The cell generates spikes on 'source_label' at times defined by a Poisson sequence.")
         .def("__repr__", [](const arb::spike_source_cell&){return "<arbor.spike_source_cell>";})
         .def("__str__",  [](const arb::spike_source_cell&){return "<arbor.spike_source_cell>";});
 
@@ -163,20 +169,23 @@ void register_cells(pybind11::module& m) {
 
     benchmark_cell
         .def(pybind11::init<>(
-            [](const regular_schedule_shim& sched, double ratio){
-                return arb::benchmark_cell{sched.schedule(), ratio};}),
-            "schedule"_a, "realtime_ratio"_a=1.0,
-            "Construct a benchmark cell that generates spikes at regular intervals.")
+            [](arb::cell_tag_type source_label, arb::cell_tag_type target_label, const regular_schedule_shim& sched, double ratio){
+                return arb::benchmark_cell{std::move(source_label), std::move(target_label), sched.schedule(), ratio};}),
+            "source_label"_a, "target_label"_a,"schedule"_a, "realtime_ratio"_a=1.0,
+            "Construct a benchmark cell that generates spikes on 'source_label' at regular intervals.\n"
+            "The cell has one source labeled 'source_label', and one target labeled 'target_label'.")
         .def(pybind11::init<>(
-            [](const explicit_schedule_shim& sched, double ratio){
-                return arb::benchmark_cell{sched.schedule(), ratio};}),
-            "schedule"_a, "realtime_ratio"_a=1.0,
-            "Construct a benchmark cell that generates spikes at a sequence of user-defined times.")
+            [](arb::cell_tag_type source_label, arb::cell_tag_type target_label, const explicit_schedule_shim& sched, double ratio){
+                return arb::benchmark_cell{std::move(source_label), std::move(target_label),sched.schedule(), ratio};}),
+            "source_label"_a, "target_label"_a, "schedule"_a, "realtime_ratio"_a=1.0,
+            "Construct a benchmark cell that generates spikes on 'source_label' at a sequence of user-defined times.\n"
+            "The cell has one source labeled 'source_label', and one target labeled 'target_label'.")
         .def(pybind11::init<>(
-            [](const poisson_schedule_shim& sched, double ratio){
-                return arb::benchmark_cell{sched.schedule(), ratio};}),
-            "schedule"_a, "realtime_ratio"_a=1.0,
-            "Construct a benchmark cell that generates spikes at times defined by a Poisson sequence.")
+            [](arb::cell_tag_type source_label, arb::cell_tag_type target_label, const poisson_schedule_shim& sched, double ratio){
+                return arb::benchmark_cell{std::move(source_label), std::move(target_label), sched.schedule(), ratio};}),
+            "source_label"_a, "target_label"_a, "schedule"_a, "realtime_ratio"_a=1.0,
+            "Construct a benchmark cell that generates spikeson 'source_label' at times defined by a Poisson sequence.\n"
+            "The cell has one source labeled 'source_label', and one target labeled 'target_label'.")
         .def("__repr__", [](const arb::benchmark_cell&){return "<arbor.benchmark_cell>";})
         .def("__str__",  [](const arb::benchmark_cell&){return "<arbor.benchmark_cell>";});
 
@@ -186,7 +195,11 @@ void register_cells(pybind11::module& m) {
         "A leaky integrate-and-fire cell.");
 
     lif_cell
-        .def(pybind11::init<>())
+        .def(pybind11::init<>(
+            [](arb::cell_tag_type source_label, arb::cell_tag_type target_label){
+                return arb::lif_cell(std::move(source_label), std::move(target_label));}),
+            "source_label"_a, "target_label"_a,
+            "Construct a lif cell with one source labeled 'source_label', and one target labeled 'target_label'.")
         .def_readwrite("tau_m", &arb::lif_cell::tau_m,
             "Membrane potential decaying constant [ms].")
         .def_readwrite("V_th", &arb::lif_cell::V_th,
@@ -201,6 +214,10 @@ void register_cells(pybind11::module& m) {
             "Refractory period [ms].")
         .def_readwrite("V_reset", &arb::lif_cell::V_reset,
             "Reset potential [mV].")
+        .def_readwrite("source", &arb::lif_cell::source,
+            "Label of the single build-in source on the cell.")
+        .def_readwrite("target", &arb::lif_cell::target,
+            "Label of the single build-in target on the cell.")
         .def("__repr__", &lif_str)
         .def("__str__",  &lif_str);
 
@@ -514,37 +531,42 @@ void register_cells(pybind11::module& m) {
             "Set ion species properties conditions on a region.")
         // Place synapses
         .def("place",
-            [](arb::decor& dec, const char* locset, const arb::mechanism_desc& d) -> int {
-                return dec.place(locset, d); },
-            "locations"_a, "mechanism"_a,
-            "Place one instance of synapse described by 'mechanism' to each location in 'locations'.")
+            [](arb::decor& dec, const char* locset, const arb::mechanism_desc& d, const char* label_name) {
+                return dec.place(locset, d, label_name); },
+            "locations"_a, "mechanism"_a, "label"_a,
+            "Place one instance of the synapse described by 'mechanism' on each location in 'locations'. "
+            "The group of synapses has the label 'label', used for forming connections between cells.")
         .def("place",
-            [](arb::decor& dec, const char* locset, const char* mech_name) -> int {
-                return dec.place(locset, mech_name);
+            [](arb::decor& dec, const char* locset, const char* mech_name, const char* label_name) {
+                return dec.place(locset, mech_name, label_name);
             },
-            "locations"_a, "mechanism"_a,
-            "Place one instance of synapse described by 'mechanism' to each location in 'locations'.")
+            "locations"_a, "mechanism"_a, "label"_a,
+            "Place one instance of the synapse described by 'mechanism' on each location in 'locations'."
+            "The group of synapses has the label 'label', used for forming connections between cells.")
         // Place gap junctions.
         .def("place",
-            [](arb::decor& dec, const char* locset, const arb::gap_junction_site& site) -> int {
-                return dec.place(locset, site);
+            [](arb::decor& dec, const char* locset, const arb::gap_junction_site& site, const char* label_name) {
+                return dec.place(locset, site, label_name);
             },
-            "locations"_a, "gapjunction"_a,
-            "Place one gap junction site at each location in 'locations'.")
+            "locations"_a, "gapjunction"_a, "label"_a,
+            "Place one gap junction site labeled 'label' on each location in 'locations'."
+            "The group of gap junctions has the label 'label', used for forming connections between cells.")
         // Place current clamp stimulus.
         .def("place",
-            [](arb::decor& dec, const char* locset, const arb::i_clamp& stim) -> int {
-                return dec.place(locset, stim);
+            [](arb::decor& dec, const char* locset, const arb::i_clamp& stim, const char* label_name) {
+                return dec.place(locset, stim, label_name);
             },
-            "locations"_a, "iclamp"_a,
-            "Add a current stimulus at each location in locations.")
+            "locations"_a, "iclamp"_a, "label"_a,
+            "Add a current stimulus at each location in locations."
+            "The group of current stimuli has the label 'label'.")
         // Place spike detector.
         .def("place",
-            [](arb::decor& dec, const char* locset, const arb::threshold_detector& d) -> int {
-                return dec.place(locset, d);
+            [](arb::decor& dec, const char* locset, const arb::threshold_detector& d, const char* label_name) {
+                return dec.place(locset, d, label_name);
             },
-            "locations"_a, "detector"_a,
-            "Add a voltage spike detector at each location in locations.")
+            "locations"_a, "detector"_a, "label"_a,
+            "Add a voltage spike detector at each location in locations."
+            "The group of spike detectors has the label 'label', used for forming connections between cells.")
         .def("discretization",
             [](arb::decor& dec, const arb::cv_policy& p) { dec.set_default(p); },
             pybind11::arg_v("policy", "A cv_policy used to discretise the cell into compartments for simulation"));
@@ -577,14 +599,6 @@ void register_cells(pybind11::module& m) {
         .def("cables",
             [](arb::cable_cell& c, const char* label) {return c.concrete_region(label).cables();},
             "label"_a, "The cable segments of the cell morphology for a region label.")
-        // Get lid range associated with a placement.
-        .def("placed_lid_range",
-            [](arb::cable_cell& c, int idx) -> pybind11::tuple {
-                auto range = c.placed_lid_range(idx);
-                return pybind11::make_tuple(range.begin, range.end);
-            },
-            "index"_a,
-            "The range of lids assigned to the items from a placement, for the lids assigned to synapses.")
         // Stringification
         .def("__repr__", [](const arb::cable_cell&){return "<arbor.cable_cell>";})
         .def("__str__",  [](const arb::cable_cell&){return "<arbor.cable_cell>";});
diff --git a/python/event_generator.cpp b/python/event_generator.cpp
index ad7d0bbd..c9608b49 100644
--- a/python/event_generator.cpp
+++ b/python/event_generator.cpp
@@ -17,11 +17,11 @@ void register_event_generators(pybind11::module& m) {
 
     event_generator
         .def(pybind11::init<>(
-            [](arb::cell_lid_type target, double weight, const schedule_shim_base& sched) {
-                return event_generator_shim(target, weight, sched.schedule()); }),
+            [](arb::cell_local_label_type target, double weight, const schedule_shim_base& sched) {
+                return event_generator_shim(std::move(target), weight, sched.schedule()); }),
             "target"_a, "weight"_a, "sched"_a,
             "Construct an event generator with arguments:\n"
-            "  target: The target synapse (gid, local_id).\n"
+            "  target: The target synapse label and selection policy.\n"
             "  weight: The weight of events to deliver.\n"
             "  sched:  A schedule of the events.")
         .def_readwrite("target", &event_generator_shim::target,
diff --git a/python/event_generator.hpp b/python/event_generator.hpp
index 1d0d61c3..4bca919a 100644
--- a/python/event_generator.hpp
+++ b/python/event_generator.hpp
@@ -6,12 +6,12 @@
 namespace pyarb {
 
 struct event_generator_shim {
-    arb::cell_lid_type target;
+    arb::cell_local_label_type target;
     double weight;
     arb::schedule time_sched;
 
-    event_generator_shim(arb::cell_lid_type event_target, double event_weight, arb::schedule sched):
-        target(event_target),
+    event_generator_shim(arb::cell_local_label_type event_target, double event_weight, arb::schedule sched):
+        target(std::move(event_target)),
         weight(event_weight),
         time_sched(std::move(sched))
     {}
diff --git a/python/example/brunel.py b/python/example/brunel.py
index 3bd0af9e..30dc11e2 100755
--- a/python/example/brunel.py
+++ b/python/example/brunel.py
@@ -64,14 +64,14 @@ class brunel_recipe (arbor.recipe):
         connections=[]
         # Add incoming excitatory connections.
         for i in sample_subset(gid, 0, self.ncells_exc_, self.in_degree_exc_):
-            connections.append(arbor.connection((i,0), 0, self.weight_exc_, self.delay_))
+            connections.append(arbor.connection((i,"src"), "tgt", self.weight_exc_, self.delay_))
         # Add incoming inhibitory connections.
         for i in sample_subset(gid, self.ncells_exc_, self.ncells_exc_ + self.ncells_inh_, self.in_degree_inh_):
-            connections.append(arbor.connection((i,0), 0, self.weight_inh_, self.delay_))
+            connections.append(arbor.connection((i,"src"), "tgt", self.weight_inh_, self.delay_))
         return connections
 
     def cell_description(self, gid):
-        cell = arbor.lif_cell()
+        cell = arbor.lif_cell("src", "tgt")
         cell.tau_m = 10
         cell.V_th = 10
         cell.C_m = 20
@@ -83,15 +83,8 @@ class brunel_recipe (arbor.recipe):
 
     def event_generators(self, gid):
         t0 = 0
-        idx = 0
         sched = arbor.poisson_schedule(t0, self.lambda_, gid + self.seed_)
-        return [arbor.event_generator(idx, self.weight_ext_, sched)]
-
-    def num_targets(self, gid):
-        return 1
-
-    def num_sources(self, gid):
-        return 1
+        return [arbor.event_generator("tgt", self.weight_ext_, sched)]
 
 if __name__ == "__main__":
 
diff --git a/python/example/dynamic-catalogue.py b/python/example/dynamic-catalogue.py
index d8da7192..48248e72 100644
--- a/python/example/dynamic-catalogue.py
+++ b/python/example/dynamic-catalogue.py
@@ -25,12 +25,6 @@ class recipe(arb.recipe):
     def num_cells(self):
         return 1
 
-    def num_targets(self, gid):
-        return 0
-
-    def num_sources(self, gid):
-        return 0
-
     def cell_kind(self, gid):
         return arb.cell_kind.cable
 
diff --git a/python/example/mpi.py b/python/example/mpi.py
index 14e7e409..a7096ede 100644
--- a/python/example/mpi.py
+++ b/python/example/mpi.py
@@ -92,12 +92,6 @@ class ring_recipe (arbor.recipe):
         d = 5
         return [arbor.connection((src,0), (gid,0), w, d)]
 
-    def num_targets(self, gid):
-        return 1
-
-    def num_sources(self, gid):
-        return 1
-
     # (9) Attach a generator to the first cell in the ring.
     def event_generators(self, gid):
         if gid==0:
diff --git a/python/example/network_ring.py b/python/example/network_ring.py
index 23657065..1754ab38 100755
--- a/python/example/network_ring.py
+++ b/python/example/network_ring.py
@@ -48,10 +48,10 @@ def make_cable_cell(gid):
     decor.paint('"dend"', 'pas')
 
     # (4) Attach a single synapse.
-    decor.place('"synapse_site"', 'expsyn')
+    decor.place('"synapse_site"', 'expsyn', "syn")
 
     # Attach a spike detector with threshold of -10 mV.
-    decor.place('"root"', arbor.spike_detector(-10))
+    decor.place('"root"', arbor.spike_detector(-10), "detector")
 
     cell = arbor.cable_cell(tree, labels, decor)
 
@@ -88,19 +88,13 @@ class ring_recipe (arbor.recipe):
         src = (gid-1)%self.ncells
         w = 0.01
         d = 5
-        return [arbor.connection((src,0), 0, w, d)]
-
-    def num_targets(self, gid):
-        return 1
-
-    def num_sources(self, gid):
-        return 1
+        return [arbor.connection((src,"detector"), "syn", w, d)]
 
     # (9) Attach a generator to the first cell in the ring.
     def event_generators(self, gid):
         if gid==0:
             sched = arbor.explicit_schedule([1])
-            return [arbor.event_generator(0, 0.1, sched)]
+            return [arbor.event_generator("syn", 0.1, sched)]
         return []
 
     # (10) Place a probe at the root of each cell.
diff --git a/python/example/single_cell_cable.py b/python/example/single_cell_cable.py
index bf3d0ac5..753c7169 100755
--- a/python/example/single_cell_cable.py
+++ b/python/example/single_cell_cable.py
@@ -86,9 +86,7 @@ class Cable(arbor.recipe):
         decor.paint('"cable"',
                     arbor.mechanism(f'pas/e={self.Vm}', {'g': self.g}))
 
-        decor.place('"start"', arbor.iclamp(args.stimulus_start,
-                                            args.stimulus_duration,
-                                            args.stimulus_amplitude))
+        decor.place('"start"', arbor.iclamp(args.stimulus_start, args.stimulus_duration, args.stimulus_amplitude), "iclamp")
 
         policy = arbor.cv_policy_max_extent(self.cv_policy_max_extent)
         decor.discretization(policy)
diff --git a/python/example/single_cell_detailed.py b/python/example/single_cell_detailed.py
index 71e1346a..fb3cd6fa 100755
--- a/python/example/single_cell_detailed.py
+++ b/python/example/single_cell_detailed.py
@@ -60,10 +60,10 @@ decor.paint('"dend"',  mech('Ih', {'gbar': 0.001}))
 
 # Place stimuli and spike detectors.
 
-decor.place('"root"', arbor.iclamp(10, 1, current=2))
-decor.place('"root"', arbor.iclamp(30, 1, current=2))
-decor.place('"root"', arbor.iclamp(50, 1, current=2))
-decor.place('"axon_terminal"', arbor.spike_detector(-10))
+decor.place('"root"', arbor.iclamp(10, 1, current=2), "iclamp0")
+decor.place('"root"', arbor.iclamp(30, 1, current=2), "iclamp1")
+decor.place('"root"', arbor.iclamp(50, 1, current=2), "iclamp2")
+decor.place('"axon_terminal"', arbor.spike_detector(-10), "detector")
 
 # Set cv_policy
 
diff --git a/python/example/single_cell_detailed_recipe.py b/python/example/single_cell_detailed_recipe.py
index 2e1ecac9..83ff1f05 100644
--- a/python/example/single_cell_detailed_recipe.py
+++ b/python/example/single_cell_detailed_recipe.py
@@ -64,10 +64,10 @@ decor.paint('"dend"',  mech('Ih', {'gbar': 0.001}))
 
 # Place stimuli and spike detectors.
 
-decor.place('"root"', arbor.iclamp(10, 1, current=2))
-decor.place('"root"', arbor.iclamp(30, 1, current=2))
-decor.place('"root"', arbor.iclamp(50, 1, current=2))
-decor.place('"axon_terminal"', arbor.spike_detector(-10))
+decor.place('"root"', arbor.iclamp(10, 1, current=2), "iclamp0")
+decor.place('"root"', arbor.iclamp(30, 1, current=2), "iclamp1")
+decor.place('"root"', arbor.iclamp(50, 1, current=2), "iclamp2")
+decor.place('"axon_terminal"', arbor.spike_detector(-10), "detector")
 
 # Set cv_policy
 
@@ -109,12 +109,6 @@ class single_recipe (arbor.recipe):
     def num_cells(self):
         return 1
 
-    def num_sources(self, gid):
-        return 1
-
-    def num_targets(self, gid):
-        return 0
-
     def cell_kind(self, gid):
         return arbor.cell_kind.cable
 
@@ -177,4 +171,4 @@ for d, m in sim.samples(handle):
 df = pandas.DataFrame()
 for i in range(len(data)):
     df = df.append(pandas.DataFrame({'t/ms': data[i][:, 0], 'U/mV': data[i][:, 1], 'Location': str(meta[i]), 'Variable':'voltage'}))
-seaborn.relplot(data=df, kind="line", x="t/ms", y="U/mV",hue="Location",col="Variable",ci=None).savefig('single_cell_recipe_result.svg')
\ No newline at end of file
+seaborn.relplot(data=df, kind="line", x="t/ms", y="U/mV",hue="Location",col="Variable",ci=None).savefig('single_cell_recipe_result.svg')
diff --git a/python/example/single_cell_model.py b/python/example/single_cell_model.py
index 39248517..d0e83d18 100755
--- a/python/example/single_cell_model.py
+++ b/python/example/single_cell_model.py
@@ -15,8 +15,8 @@ labels = arbor.label_dict({'soma':   '(tag 1)',
 decor = arbor.decor()
 decor.set_property(Vm=-40)
 decor.paint('"soma"', 'hh')
-decor.place('"midpoint"', arbor.iclamp( 10, 2, 0.8))
-decor.place('"midpoint"', arbor.spike_detector(-10))
+decor.place('"midpoint"', arbor.iclamp( 10, 2, 0.8), "iclamp")
+decor.place('"midpoint"', arbor.spike_detector(-10), "detector")
 
 # (4) Create cell and the single cell model based on it
 cell = arbor.cable_cell(tree, labels, decor)
diff --git a/python/example/single_cell_nml.py b/python/example/single_cell_nml.py
index 838cb925..7c5920bc 100755
--- a/python/example/single_cell_nml.py
+++ b/python/example/single_cell_nml.py
@@ -57,12 +57,12 @@ decor.paint('"dend"', 'pas')
 # Increase resistivity on dendrites.
 decor.paint('"dend"', rL=500)
 # Attach stimuli that inject 4 nA current for 1 ms, starting at 3 and 8 ms.
-decor.place('"root"', arbor.iclamp(10, 1, current=5))
-decor.place('"stim_site"', arbor.iclamp(3, 1, current=0.5))
-decor.place('"stim_site"', arbor.iclamp(10, 1, current=0.5))
-decor.place('"stim_site"', arbor.iclamp(8, 1, current=4))
+decor.place('"root"', arbor.iclamp(10, 1, current=5), "iclamp0")
+decor.place('"stim_site"', arbor.iclamp(3, 1, current=0.5), "iclamp1")
+decor.place('"stim_site"', arbor.iclamp(10, 1, current=0.5), "iclamp2")
+decor.place('"stim_site"', arbor.iclamp(8, 1, current=4), "iclamp3")
 # Detect spikes at the soma with a voltage threshold of -10 mV.
-decor.place('"axon_end"', arbor.spike_detector(-10))
+decor.place('"axon_end"', arbor.spike_detector(-10), "detector")
 
 # Create the policy used to discretise the cell into CVs.
 # Use a single CV for the soma, and CVs of maximum length 1 μm elsewhere.
diff --git a/python/example/single_cell_recipe.py b/python/example/single_cell_recipe.py
index 43a28f86..f3d51e1d 100644
--- a/python/example/single_cell_recipe.py
+++ b/python/example/single_cell_recipe.py
@@ -17,8 +17,8 @@ labels = arbor.label_dict({'soma':   '(tag 1)',
 decor = arbor.decor()
 decor.set_property(Vm=-40)
 decor.paint('"soma"', 'hh')
-decor.place('"midpoint"', arbor.iclamp( 10, 2, 0.8))
-decor.place('"midpoint"', arbor.spike_detector(-10))
+decor.place('"midpoint"', arbor.iclamp( 10, 2, 0.8), "iclamp")
+decor.place('"midpoint"', arbor.spike_detector(-10), "detector")
 cell = arbor.cable_cell(tree, labels, decor)
 
 # (4) Define a recipe for a single cell and set of probes upon it.
@@ -39,9 +39,6 @@ class single_recipe (arbor.recipe):
     def num_cells(self):
         return 1
 
-    def num_sources(self, gid):
-        return 1
-
     def cell_kind(self, gid):
         return arbor.cell_kind.cable
 
diff --git a/python/example/single_cell_stdp.py b/python/example/single_cell_stdp.py
index 2c783520..b2db196e 100755
--- a/python/example/single_cell_stdp.py
+++ b/python/example/single_cell_stdp.py
@@ -19,12 +19,6 @@ class single_recipe(arbor.recipe):
     def num_cells(self):
         return 1
 
-    def num_sources(self, gid):
-        return 1
-
-    def num_targets(self, gid):
-        return 2
-
     def cell_kind(self, gid):
         return arbor.cell_kind.cable
 
@@ -40,13 +34,13 @@ class single_recipe(arbor.recipe):
         decor.set_property(Vm=-40)
         decor.paint('(all)', 'hh')
 
-        decor.place('"center"', arbor.spike_detector(-10))
-        decor.place('"center"', 'expsyn')
+        decor.place('"center"', arbor.spike_detector(-10), "detector")
+        decor.place('"center"', 'expsyn', "synapse")
 
         mech_syn = arbor.mechanism('expsyn_stdp')
         mech_syn.set("max_weight", 1.)
 
-        decor.place('"center"', mech_syn)
+        decor.place('"center"', mech_syn, "stpd_synapse")
 
         cell = arbor.cable_cell(tree, labels, decor)
 
@@ -59,10 +53,10 @@ class single_recipe(arbor.recipe):
         stimulus_times = numpy.linspace(50, 500, self.n_pairs)
 
         # strong enough stimulus
-        spike = arbor.event_generator(0, 1., arbor.explicit_schedule(stimulus_times))
+        spike = arbor.event_generator("synapse", 1., arbor.explicit_schedule(stimulus_times))
 
         # zero weight -> just modify synaptic weight via stdp
-        stdp = arbor.event_generator(1, 0., arbor.explicit_schedule(stimulus_times - self.dT))
+        stdp = arbor.event_generator("stpd_synapse", 0., arbor.explicit_schedule(stimulus_times - self.dT))
 
         return [spike, stdp]
 
diff --git a/python/example/single_cell_swc.py b/python/example/single_cell_swc.py
index 7203919e..f229b623 100755
--- a/python/example/single_cell_swc.py
+++ b/python/example/single_cell_swc.py
@@ -49,12 +49,12 @@ decor.paint('"dend"', 'pas')
 # Increase resistivity on dendrites.
 decor.paint('"dend"', rL=500)
 # Attach stimuli that inject 4 nA current for 1 ms, starting at 3 and 8 ms.
-decor.place('"root"', arbor.iclamp(10, 1, current=5))
-decor.place('"stim_site"', arbor.iclamp(3, 1, current=0.5))
-decor.place('"stim_site"', arbor.iclamp(10, 1, current=0.5))
-decor.place('"stim_site"', arbor.iclamp(8, 1, current=4))
+decor.place('"root"', arbor.iclamp(10, 1, current=5), "iclamp0")
+decor.place('"stim_site"', arbor.iclamp(3, 1, current=0.5), "iclamp1")
+decor.place('"stim_site"', arbor.iclamp(10, 1, current=0.5), "iclamp2")
+decor.place('"stim_site"', arbor.iclamp(8, 1, current=4), "iclamp3")
 # Detect spikes at the soma with a voltage threshold of -10 mV.
-decor.place('"axon_end"', arbor.spike_detector(-10))
+decor.place('"axon_end"', arbor.spike_detector(-10), "detector")
 
 # Create the policy used to discretise the cell into CVs.
 # Use a single CV for the soma, and CVs of maximum length 1 μm elsewhere.
diff --git a/python/identifiers.cpp b/python/identifiers.cpp
index c64ed997..71e583a6 100644
--- a/python/identifiers.cpp
+++ b/python/identifiers.cpp
@@ -15,6 +15,90 @@ namespace py = pybind11;
 void register_identifiers(py::module& m) {
     using namespace py::literals;
 
+    py::enum_<arb::lid_selection_policy>(m, "selection_policy",
+        "Enumeration used to identify a selection policy, used by the model for selecting one of possibly multiple locations on the cell associated with a labeled item.")
+        .value("round_robin", arb::lid_selection_policy::round_robin,
+               "iterate round-robin over all possible locations.")
+        .value("univalent", arb::lid_selection_policy::assert_univalent,
+               "Assert that there is only one possible location associated with a labeled item on the cell. The model throws an exception if the assertion fails.");
+
+    py::class_<arb::cell_local_label_type> cell_local_label_type(m, "cell_local_label",
+        "For local identification of an item.\n\n"
+        "cell_local_label identifies:\n"
+        "(1) a labeled group of one or more items on one or more locations on the cell.\n"
+        "(2) a policy for selecting one of the items.\n");
+
+    cell_local_label_type
+        .def(py::init(
+            [](arb::cell_tag_type label) {
+              return arb::cell_local_label_type{std::move(label)};
+            }),
+             "label"_a,
+             "Construct a cell_local_label identifier from a label argument identifying a group of one or more items on a cell.\n"
+             "The default round_robin policy is used for selecting one of possibly multiple items associated with the label.")
+        .def(py::init(
+            [](arb::cell_tag_type label, arb::lid_selection_policy policy) {
+              return arb::cell_local_label_type{std::move(label), policy};
+            }),
+             "label"_a, "policy"_a,
+             "Construct a cell_local_label identifier with arguments:\n"
+             "  label:  The identifier of a group of one or more items on a cell.\n"
+             "  policy: The policy for selecting one of possibly multiple items associated with the label.\n")
+        .def(py::init([](py::tuple t) {
+               if (py::len(t)!=2) throw std::runtime_error("tuple length != 2");
+               return arb::cell_local_label_type{t[0].cast<arb::cell_tag_type>(), t[1].cast<arb::lid_selection_policy>()};
+             }),
+             "Construct a cell_local_label identifier with tuple argument (label, policy):\n"
+             "  label:  The identifier of a group of one or more items on a cell.\n"
+             "  policy: The policy for selecting one of possibly multiple items associated with the label.\n")
+        .def_readwrite("label",  &arb::cell_local_label_type::tag,
+             "The identifier of a a group of one or more items on a cell.")
+        .def_readwrite("policy", &arb::cell_local_label_type::policy,
+            "The policy for selecting one of possibly multiple items associated with the label.")
+        .def("__str__", [](arb::cell_local_label_type m) {return pprintf("<arbor.cell_local_label: label {}, policy {}>", m.tag, m.policy);})
+        .def("__repr__",[](arb::cell_local_label_type m) {return pprintf("<arbor.cell_local_label: label {}, policy {}>", m.tag, m.policy);});
+
+    py::implicitly_convertible<py::tuple, arb::cell_local_label_type>();
+    py::implicitly_convertible<py::str, arb::cell_local_label_type>();
+
+    py::class_<arb::cell_global_label_type> cell_global_label_type(m, "cell_global_label",
+        "For global identification of an item.\n\n"
+        "cell_global_label members:\n"
+        "(1) a unique cell identified by its gid.\n"
+        "(2) a cell_local_label, referring to a labeled group of items on the cell and a policy for selecting a single item out of the group.\n");
+
+    cell_global_label_type
+        .def(py::init(
+            [](arb::cell_gid_type gid, arb::cell_tag_type label) {
+              return arb::cell_global_label_type{gid, std::move(label)};
+            }),
+             "gid"_a, "label"_a,
+             "Construct a cell_global_label identifier from a gid and a label argument identifying an item on the cell.\n"
+             "The default round_robin policy is used for selecting one of possibly multiple items on the cell associated with the label.")
+        .def(py::init(
+            [](arb::cell_gid_type gid, arb::cell_local_label_type label) {
+              return arb::cell_global_label_type{gid, label};
+            }),
+             "gid"_a, "label"_a,
+             "Construct a cell_global_label identifier with arguments:\n"
+             "  gid:   The global identifier of the cell.\n"
+             "  label: The cell_local_label representing the label and selection policy of an item on the cell.\n")
+        .def(py::init([](py::tuple t) {
+               if (py::len(t)!=2) throw std::runtime_error("tuple length != 2");
+               return arb::cell_global_label_type{t[0].cast<arb::cell_gid_type>(), t[1].cast<arb::cell_local_label_type>()};
+             }),
+             "Construct a cell_global_label identifier with tuple argument (gid, label):\n"
+             "  gid:   The global identifier of the cell.\n"
+             "  label: The cell_local_label representing the label and selection policy of an item on the cell.\n")
+        .def_readwrite("gid",  &arb::cell_global_label_type::gid,
+             "The global identifier of the cell.")
+        .def_readwrite("label", &arb::cell_global_label_type::label,
+             "The cell_local_label representing the label and selection policy of an item on the cell.")
+        .def("__str__", [](arb::cell_global_label_type m) {return pprintf("<arbor.cell_global_label: gid {}, label ({}, {})>", m.gid, m.label.tag, m.label.policy);})
+        .def("__repr__",[](arb::cell_global_label_type m) {return pprintf("<arbor.cell_global_label: gid {}, label ({}, {})>", m.gid, m.label.tag, m.label.policy);});
+
+    py::implicitly_convertible<py::tuple, arb::cell_global_label_type>();
+
     py::class_<arb::cell_member_type> cell_member(m, "cell_member",
         "For global identification of a cell-local item.\n\n"
         "Items of cell_member must:\n"
@@ -31,7 +115,7 @@ void register_identifiers(py::module& m) {
             "  gid:     The global identifier of the cell.\n"
             "  index:   The cell-local index of the item.\n")
         .def(py::init([](py::tuple t) {
-                if (py::len(t)!=2) throw std::runtime_error("tuple length != 4");
+                if (py::len(t)!=2) throw std::runtime_error("tuple length != 2");
                 return arb::cell_member_type{t[0].cast<arb::cell_gid_type>(), t[1].cast<arb::cell_lid_type>()};
             }),
             "Construct a cell member identifier with tuple argument (gid, index):\n"
diff --git a/python/recipe.cpp b/python/recipe.cpp
index febd5cdb..b5d23897 100644
--- a/python/recipe.cpp
+++ b/python/recipe.cpp
@@ -118,13 +118,13 @@ std::vector<arb::event_generator> py_recipe_shim::event_generators(arb::cell_gid
 }
 
 std::string con_to_string(const arb::cell_connection& c) {
-    return util::pprintf("<arbor.connection: source ({},{}), destination {}, delay {}, weight {}>",
-         c.source.gid, c.source.index, c.dest, c.delay, c.weight);
+    return util::pprintf("<arbor.connection: source ({}, \"{}\", {}), destination (\"{}\", {}), delay {}, weight {}>",
+         c.source.gid, c.source.label.tag, c.source.label.policy, c.dest.tag, c.dest.policy, c.delay, c.weight);
 }
 
 std::string gj_to_string(const arb::gap_junction_connection& gc) {
-    return util::pprintf("<arbor.gap_junction_connection: peer ({},{}), local {}, ggap {}>",
-         gc.peer.gid, gc.peer.index, gc.local, gc.ggap);
+    return util::pprintf("<arbor.gap_junction_connection: peer ({}, \"{}\", {}), local (\"{}\", {}), ggap {}>",
+         gc.peer.gid, gc.peer.label.tag, gc.peer.label.policy, gc.local.tag, gc.local.policy, gc.ggap);
 }
 
 void register_recipe(pybind11::module& m) {
@@ -135,7 +135,7 @@ void register_recipe(pybind11::module& m) {
         "Describes a connection between two cells:\n"
         "  Defined by source and destination end points (that is pre-synaptic and post-synaptic respectively), a connection weight and a delay time.");
     cell_connection
-        .def(pybind11::init<arb::cell_member_type, arb::cell_lid_type, float, float>(),
+        .def(pybind11::init<arb::cell_global_label_type, arb::cell_local_label_type, float, float>(),
             "source"_a, "dest"_a, "weight"_a, "delay"_a,
             "Construct a connection with arguments:\n"
             "  source:      The source end point of the connection.\n"
@@ -143,9 +143,9 @@ void register_recipe(pybind11::module& m) {
             "  weight:      The weight delivered to the target synapse (unit defined by the type of synapse target).\n"
             "  delay:       The delay of the connection [ms].")
         .def_readwrite("source", &arb::cell_connection::source,
-            "The source of the connection.")
+            "The source gid and label of the connection.")
         .def_readwrite("dest", &arb::cell_connection::dest,
-            "The destination of the connection.")
+            "The destination label of the connection.")
         .def_readwrite("weight", &arb::cell_connection::weight,
             "The weight of the connection.")
         .def_readwrite("delay", &arb::cell_connection::delay,
@@ -157,16 +157,16 @@ void register_recipe(pybind11::module& m) {
     pybind11::class_<arb::gap_junction_connection> gap_junction_connection(m, "gap_junction_connection",
         "Describes a gap junction between two gap junction sites.");
     gap_junction_connection
-        .def(pybind11::init<arb::cell_member_type, arb::cell_lid_type, double>(),
+        .def(pybind11::init<arb::cell_global_label_type, arb::cell_local_label_type, double>(),
             "peer"_a, "local"_a, "ggap"_a,
             "Construct a gap junction connection with arguments:\n"
-            "  peer:  One half of the gap junction connection.\n"
-            "  local: Other half of the gap junction connection.\n"
+            "  peer:  remote half of the gap junction connection.\n"
+            "  local: local half of the gap junction connection.\n"
             "  ggap:  Gap junction conductance [μS].")
         .def_readwrite("peer", &arb::gap_junction_connection::peer,
-            "Peer half of the gap junction connection.")
+            "Remote gid and label of the gap junction connection.")
         .def_readwrite("local", &arb::gap_junction_connection::local,
-            "Local half of the gap junction connection.")
+            "Local label of the gap junction connection.")
         .def_readwrite("ggap", &arb::gap_junction_connection::ggap,
             "Gap junction conductance [μS].")
         .def("__str__",  &gj_to_string)
@@ -187,15 +187,6 @@ void register_recipe(pybind11::module& m) {
         .def("cell_kind", &py_recipe::cell_kind,
             "gid"_a,
             "The kind of cell with global identifier gid.")
-        .def("num_sources", &py_recipe::num_sources,
-            "gid"_a,
-            "The number of spike sources on gid, 0 by default.")
-        .def("num_targets", &py_recipe::num_targets,
-            "gid"_a,
-            "The number of post-synaptic sites on gid, 0 by default.")
-        .def("num_gap_junction_sites", &py_recipe::num_gap_junction_sites,
-            "gid"_a,
-            "The number of gap junction sites on gid, 0 by default.")
         .def("event_generators", &py_recipe::event_generators,
             "gid"_a,
             "A list of all the event generators that are attached to gid, [] by default.")
diff --git a/python/recipe.hpp b/python/recipe.hpp
index b00a608c..9956b724 100644
--- a/python/recipe.hpp
+++ b/python/recipe.hpp
@@ -33,15 +33,6 @@ public:
     virtual pybind11::object cell_description(arb::cell_gid_type gid) const = 0;
     virtual arb::cell_kind cell_kind(arb::cell_gid_type gid) const = 0;
 
-    virtual arb::cell_size_type num_sources(arb::cell_gid_type) const {
-        return 0;
-    }
-    virtual arb::cell_size_type num_targets(arb::cell_gid_type) const {
-        return 0;
-    }
-    virtual arb::cell_size_type num_gap_junction_sites(arb::cell_gid_type gid) const {
-        return gap_junctions_on(gid).size();
-    }
     virtual std::vector<pybind11::object> event_generators(arb::cell_gid_type gid) const {
         return {};
     }
@@ -74,18 +65,6 @@ public:
         PYBIND11_OVERLOAD_PURE(arb::cell_kind, py_recipe, cell_kind, gid);
     }
 
-    arb::cell_size_type num_sources(arb::cell_gid_type gid) const override {
-        PYBIND11_OVERLOAD(arb::cell_size_type, py_recipe, num_sources, gid);
-    }
-
-    arb::cell_size_type num_targets(arb::cell_gid_type gid) const override {
-        PYBIND11_OVERLOAD(arb::cell_size_type, py_recipe, num_targets, gid);
-    }
-
-    arb::cell_size_type num_gap_junction_sites(arb::cell_gid_type gid) const override {
-        PYBIND11_OVERLOAD(arb::cell_size_type, py_recipe, num_gap_junction_sites, gid);
-    }
-
     std::vector<pybind11::object> event_generators(arb::cell_gid_type gid) const override {
         PYBIND11_OVERLOAD(std::vector<pybind11::object>, py_recipe, event_generators, gid);
     }
@@ -136,18 +115,6 @@ public:
         return try_catch_pyexception([&](){ return impl_->cell_kind(gid); }, msg);
     }
 
-    arb::cell_size_type num_sources(arb::cell_gid_type gid) const override {
-        return try_catch_pyexception([&](){ return impl_->num_sources(gid); }, msg);
-    }
-
-    arb::cell_size_type num_targets(arb::cell_gid_type gid) const override {
-        return try_catch_pyexception([&](){ return impl_->num_targets(gid); }, msg);
-    }
-
-    arb::cell_size_type num_gap_junction_sites(arb::cell_gid_type gid) const override {
-        return try_catch_pyexception([&](){ return impl_->num_gap_junction_sites(gid); }, msg);
-    }
-
     std::vector<arb::event_generator> event_generators(arb::cell_gid_type gid) const override;
 
     std::vector<arb::cell_connection> connections_on(arb::cell_gid_type gid) const override {
diff --git a/python/single_cell_model.cpp b/python/single_cell_model.cpp
index 22b17ada..64b41689 100644
--- a/python/single_cell_model.cpp
+++ b/python/single_cell_model.cpp
@@ -92,15 +92,7 @@ struct single_cell_recipe: arb::recipe {
         return arb::cell_kind::cable;
     }
 
-    virtual arb::cell_size_type num_sources(arb::cell_gid_type) const override {
-        return cell_.detectors().size();
-    }
-
-    // synapses, connections and event generators
-
-    virtual arb::cell_size_type num_targets(arb::cell_gid_type) const override {
-        return cell_.synapses().size();
-    }
+    // connections and event generators
 
     virtual std::vector<arb::cell_connection> connections_on(arb::cell_gid_type) const override {
         return {}; // no connections on a single cell model
@@ -123,10 +115,6 @@ struct single_cell_recipe: arb::recipe {
 
     // gap junctions
 
-    virtual arb::cell_size_type num_gap_junction_sites(arb::cell_gid_type gid)  const override {
-        return 0; // No gap junctions on a single cell model.
-    }
-
     virtual std::vector<arb::gap_junction_connection> gap_junctions_on(arb::cell_gid_type) const override {
         return {}; // No gap junctions on a single cell model.
     }
diff --git a/python/test/unit/test_cable_probes.py b/python/test/unit/test_cable_probes.py
index 8a39d3cf..33d2c254 100644
--- a/python/test/unit/test_cable_probes.py
+++ b/python/test/unit/test_cable_probes.py
@@ -27,9 +27,9 @@ class cc_recipe(A.recipe):
 
         dec = A.decor()
 
-        dec.place('(location 0 0.08)', "expsyn")
-        dec.place('(location 0 0.09)', "exp2syn")
-        dec.place('(location 0 0.1)', A.iclamp(20.))
+        dec.place('(location 0 0.08)', "expsyn", "syn0")
+        dec.place('(location 0 0.09)', "exp2syn", "syn1")
+        dec.place('(location 0 0.1)', A.iclamp(20.), "iclamp")
         dec.paint('(all)', "hh")
 
         self.cell = A.cable_cell(st, A.label_dict(), dec)
@@ -41,12 +41,6 @@ class cc_recipe(A.recipe):
     def num_cells(self):
         return 1
 
-    def num_targets(self, gid):
-        return 2
-
-    def num_sources(self, gid):
-        return 0
-
     def cell_kind(self, gid):
         return A.cell_kind.cable
 
diff --git a/python/test/unit/test_catalogues.py b/python/test/unit/test_catalogues.py
index eac665c1..f81f3f41 100644
--- a/python/test/unit/test_catalogues.py
+++ b/python/test/unit/test_catalogues.py
@@ -39,12 +39,6 @@ class recipe(arb.recipe):
     def num_cells(self):
         return 1
 
-    def num_targets(self, gid):
-        return 0
-
-    def num_sources(self, gid):
-        return 0
-
     def cell_kind(self, gid):
         return arb.cell_kind.cable
 
diff --git a/python/test/unit/test_event_generators.py b/python/test/unit/test_event_generators.py
index a8b83738..c45e591f 100644
--- a/python/test/unit/test_event_generators.py
+++ b/python/test/unit/test_event_generators.py
@@ -22,21 +22,26 @@ all tests for event generators (regular, explicit, poisson)
 class EventGenerator(unittest.TestCase):
 
     def test_event_generator_regular_schedule(self):
+        cm = arb.cell_local_label("tgt0")
         rs = arb.regular_schedule(2.0, 1., 100.)
-        rg = arb.event_generator(3, 3.14, rs)
-        self.assertEqual(rg.target, 3)
+        rg = arb.event_generator(cm, 3.14, rs)
+        self.assertEqual(rg.target.label, "tgt0")
+        self.assertEqual(rg.target.policy, arb.selection_policy.univalent)
         self.assertAlmostEqual(rg.weight, 3.14)
 
     def test_event_generator_explicit_schedule(self):
+        cm = arb.cell_local_label("tgt1", arb.selection_policy.round_robin)
         es = arb.explicit_schedule([0,1,2,3,4.4])
-        eg = arb.event_generator(42, -0.01, es)
-        self.assertEqual(eg.target, 42)
+        eg = arb.event_generator(cm, -0.01, es)
+        self.assertEqual(eg.target.label, "tgt1")
+        self.assertEqual(eg.target.policy, arb.selection_policy.round_robin)
         self.assertAlmostEqual(eg.weight, -0.01)
 
     def test_event_generator_poisson_schedule(self):
         ps = arb.poisson_schedule(0., 10., 0)
-        pg = arb.event_generator(2, 42., ps)
-        self.assertEqual(pg.target, 2)
+        pg = arb.event_generator("tgt2", 42., ps)
+        self.assertEqual(pg.target.label, "tgt2")
+        self.assertEqual(pg.target.policy, arb.selection_policy.univalent)
         self.assertEqual(pg.weight, 42.)
 
 def suite():
diff --git a/python/test/unit/test_simulator.py b/python/test/unit/test_simulator.py
new file mode 100644
index 00000000..bee420b3
--- /dev/null
+++ b/python/test/unit/test_simulator.py
@@ -0,0 +1,252 @@
+# -*- coding: utf-8 -*-
+#
+# test_simulator.py
+
+import unittest
+import numpy as np
+import arbor as A
+
+# to be able to run .py file from child directory
+import sys, os
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
+
+try:
+    import options
+except ModuleNotFoundError:
+    from test import options
+
+"""
+all tests for the simulator wrapper
+"""
+
+# Test recipe cc2 comprises two cable cells and some probes.
+
+class cc2_recipe(A.recipe):
+    def __init__(self):
+        A.recipe.__init__(self)
+        st = A.segment_tree()
+        i = st.append(A.mnpos, (0, 0, 0, 10), (1, 0, 0, 10), 1)
+        st.append(i, (1, 3, 0, 5), 1)
+        st.append(i, (1, -4, 0, 3), 1)
+        self.the_morphology = A.morphology(st)
+        self.the_cat = A.default_catalogue()
+        self.the_props = A.neuron_cable_properties()
+        self.the_props.register(self.the_cat)
+
+    def num_cells(self):
+        return 2
+
+    def cell_kind(self, gid):
+        return A.cell_kind.cable
+
+    def connections_on(self, gid):
+        return []
+
+    def event_generators(self, gid):
+        return []
+
+    def global_properties(self, kind):
+        return self.the_props
+
+    def probes(self, gid):
+        # Cell 0 has three voltage probes:
+        #     0, 0: end of branch 1
+        #     0, 1: end of branch 2
+        #     0, 2: all terminal points
+        # Values sampled from (0, 0) and (0, 1) should correspond
+        # to the values sampled from (0, 2).
+
+        # Cell 1 has whole cell probes:
+        #     0, 0: all membrane voltages
+        #     0, 1: all expsyn state variable 'g'
+
+        if gid==0:
+            return [A.cable_probe_membrane_voltage('(location 1 1)'),
+                    A.cable_probe_membrane_voltage('(location 2 1)'),
+                    A.cable_probe_membrane_voltage('(terminal)')]
+        elif gid==1:
+            return [A.cable_probe_membrane_voltage_cell(),
+                    A.cable_probe_point_state_cell('expsyn', 'g')]
+        else:
+            return []
+
+    def cell_description(self, gid):
+        c = A.cable_cell(self.the_morphology, A.label_dict())
+        c.set_properties(Vm=0.0, cm=0.01, rL=30, tempK=300)
+        c.paint('(all)', "pas")
+        c.place('(location 0 0)', A.iclamp(current=10 if gid==0 else 20))
+        c.place('(sum (on-branches 0.3) (location 0 0.6))', "expsyn")
+        return c
+
+# Test recipe lif2 comprises two independent LIF cells driven by a regular, rapid
+# sequence of incoming spikes. The cells have differing refactory periods.
+
+class lif2_recipe(A.recipe):
+    def __init__(self):
+        A.recipe.__init__(self)
+
+    def num_cells(self):
+        return 2
+
+    def cell_kind(self, gid):
+        return A.cell_kind.lif
+
+    def connections_on(self, gid):
+        return []
+
+    def event_generators(self, gid):
+        sched_dt = 0.25
+        weight = 400
+        return [A.event_generator((gid,0), weight, A.regular_schedule(sched_dt)) for gid in range(0, self.num_cells())]
+
+    def probes(self, gid):
+        return []
+
+    def cell_description(self, gid):
+        c = A.lif_cell()
+        if gid==0:
+            c.t_ref = 2
+        if gid==1:
+            c.t_ref = 4
+        return c
+
+class Simulator(unittest.TestCase):
+    def init_sim(self, recipe):
+        context = A.context()
+        dd = A.partition_load_balance(recipe, context)
+        return A.simulation(recipe, dd, context)
+
+    def test_simple_run(self):
+        sim = self.init_sim(cc2_recipe())
+        sim.run(1.0, 0.01)
+
+    def test_probe_meta(self):
+        sim = self.init_sim(cc2_recipe())
+
+        self.assertEqual([A.location(1, 1)], sim.probe_metadata((0, 0)))
+        self.assertEqual([A.location(2, 1)], sim.probe_metadata((0, 1)))
+        self.assertEqual([A.location(1, 1), A.location(2, 1)], sorted(sim.probe_metadata((0, 2)), key=lambda x:(x.branch, x.pos)))
+
+        # Default CV policy is one per branch, which also gives a tivial CV over the branch point.
+        # Expect metadata cables to be one for each full branch, plus three length-zero cables corresponding to the branch point.
+        self.assertEqual([A.cable(0, 0, 1), A.cable(0, 1, 1), A.cable(1, 0, 0), A.cable(1, 0, 1), A.cable(2, 0, 0), A.cable(2, 0, 1)],
+                sorted(sim.probe_metadata((1,0))[0], key=lambda x:(x.branch, x.prox, x.dist)))
+
+        # Four expsyn synapses; the two on branch zero should be coalesced, giving a multiplicity of 2.
+        # Expect entries to be in target index order.
+        m11 = sim.probe_metadata((1,1))[0]
+        self.assertEqual(4, len(m11))
+        self.assertEqual([0, 1, 2, 3], [x.target for x in m11])
+        self.assertEqual([2, 2, 1, 1], [x.multiplicity for x in m11])
+        self.assertEqual([A.location(0, 0.3), A.location(0, 0.6), A.location(1, 0.3), A.location(2, 0.3)], [x.location for x in m11])
+
+    def test_probe_scalar_recorders(self):
+        sim = self.init_sim(cc2_recipe())
+        ts = [0, 0.1, 0.3, 0.7]
+        h = sim.sample((0, 0), A.explicit_schedule(ts))
+        dt = 0.01
+        sim.run(10., dt)
+        s, meta = sim.samples(h)[0]
+        self.assertEqual(A.location(1, 1), meta)
+        for i, t in enumerate(s[:,0]):
+            self.assertLess(abs(t-ts[i]), dt)
+
+        sim.remove_sampler(h)
+        sim.reset()
+        h = sim.sample(A.cell_member(0, 0), A.explicit_schedule(ts), A.sampling_policy.exact)
+        sim.run(10., dt)
+        s, meta = sim.samples(h)[0]
+        for i, t in enumerate(s[:,0]):
+            self.assertEqual(t, ts[i])
+
+
+    def test_probe_multi_scalar_recorders(self):
+        sim = self.init_sim(cc2_recipe())
+        ts = [0, 0.1, 0.3, 0.7]
+        h0 = sim.sample((0, 0), A.explicit_schedule(ts))
+        h1 = sim.sample((0, 1), A.explicit_schedule(ts))
+        h2 = sim.sample((0, 2), A.explicit_schedule(ts))
+
+        dt = 0.01
+        sim.run(10., dt)
+
+        r0 = sim.samples(h0)
+        self.assertEqual(1, len(r0))
+        s0, meta0 = r0[0]
+
+        r1 = sim.samples(h1)
+        self.assertEqual(1, len(r1))
+        s1, meta1 = r1[0]
+
+        r2 = sim.samples(h2)
+        self.assertEqual(2, len(r2))
+        s20, meta20 = r2[0]
+        s21, meta21 = r2[1]
+
+        # Probe id (0, 2) has probes over the two locations that correspond to probes (0, 0) and (0, 1).
+
+        # (order is not guaranteed to line up though)
+        if meta20==meta0:
+            self.assertEqual(meta1, meta21)
+            self.assertTrue((s0[:,1]==s20[:,1]).all())
+            self.assertTrue((s1[:,1]==s21[:,1]).all())
+        else:
+            self.assertEqual(meta1, meta20)
+            self.assertTrue((s1[:,1]==s20[:,1]).all())
+            self.assertEqual(meta0, meta21)
+            self.assertTrue((s0[:,1]==s21[:,1]).all())
+
+    def test_probe_vector_recorders(self):
+        sim = self.init_sim(cc2_recipe())
+        ts = [0, 0.1, 0.3, 0.7]
+        h0 = sim.sample((1, 0), A.explicit_schedule(ts), A.sampling_policy.exact)
+        h1 = sim.sample((1, 1), A.explicit_schedule(ts), A.sampling_policy.exact)
+        sim.run(10., 0.01)
+
+        # probe (1, 0) is the whole cell voltage; expect time + 6 sample values per row in returned data (see test_probe_meta above).
+
+        s0, meta0 = sim.samples(h0)[0]
+        self.assertEqual(6, len(meta0))
+        self.assertEqual((len(ts), 7), s0.shape)
+        for i, t in enumerate(s0[:,0]):
+            self.assertEqual(t, ts[i])
+
+        # probe (1, 1) is the 'g' state for all expsyn synapses.
+        # With the default descretization, expect two synapses with multiplicity 2 and two with multiplicity 1.
+
+        s1, meta1 = sim.samples(h1)[0]
+        self.assertEqual(4, len(meta1))
+        self.assertEqual((len(ts), 5), s1.shape)
+        for i, t in enumerate(s1[:,0]):
+            self.assertEqual(t, ts[i])
+
+        meta1_mult = {(m.location.branch, m.location.pos): m.multiplicity for m in meta1}
+        self.assertEqual(2, meta1_mult[(0, 0.3)])
+        self.assertEqual(2, meta1_mult[(0, 0.6)])
+        self.assertEqual(1, meta1_mult[(1, 0.3)])
+        self.assertEqual(1, meta1_mult[(2, 0.3)])
+
+    def test_spikes(self):
+        sim = self.init_sim(lif2_recipe())
+        sim.record(A.spike_recording.all)
+        sim.run(21, 0.01)
+
+        spikes = sim.spikes().tolist()
+        s0 = sorted([t for s, t in spikes if s==(0, 0)])
+        s1 = sorted([t for s, t in spikes if s==(1, 0)])
+
+        self.assertEqual([0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20], s0)
+        self.assertEqual([0, 4, 8, 12, 16, 20], s1)
+
+def suite():
+    # specify class and test functions in tuple (here: all tests starting with 'test' from class Contexts
+    suite = unittest.makeSuite(Simulator, ('test'))
+    return suite
+
+def run():
+    v = options.parse_arguments().verbosity
+    runner = unittest.TextTestRunner(verbosity = v)
+    runner.run(suite())
+
+if __name__ == "__main__":
+    run()
diff --git a/python/test/unit/test_spikes.py b/python/test/unit/test_spikes.py
index 86e7464e..8211f5de 100644
--- a/python/test/unit/test_spikes.py
+++ b/python/test/unit/test_spikes.py
@@ -32,12 +32,6 @@ class art_spiker_recipe(A.recipe):
     def num_cells(self):
         return 3
 
-    def num_targets(self, gid):
-        return 0
-
-    def num_sources(self, gid):
-        return 1
-
     def cell_kind(self, gid):
         return A.cell_kind.spike_source
 
@@ -54,7 +48,7 @@ class art_spiker_recipe(A.recipe):
         return []
 
     def cell_description(self, gid):
-        return A.spike_source_cell(A.explicit_schedule(self.trains[gid]))
+        return A.spike_source_cell("src", A.explicit_schedule(self.trains[gid]))
 
 
 class Spikes(unittest.TestCase):
diff --git a/python/test/unit_distributed/test_domain_decompositions.py b/python/test/unit_distributed/test_domain_decompositions.py
index 7a52288e..439fd425 100644
--- a/python/test/unit_distributed/test_domain_decompositions.py
+++ b/python/test/unit_distributed/test_domain_decompositions.py
@@ -49,7 +49,11 @@ class hetero_recipe (arb.recipe):
         return self.ncells
 
     def cell_description(self, gid):
-        return []
+        tree = arb.segment_tree()
+        tree.append(arb.mnpos, arb.mpoint(-3, 0, 0, 3), arb.mpoint(3, 0, 0, 3), tag=1)
+        decor = arb.decor()
+        decor.place('(location 0 0.5)', arb.gap_junction_site(), "gj")
+        return arb.cable_cell(tree, arb.label_dict(), decor)
 
     def cell_kind(self, gid):
         if (gid%2):
@@ -57,12 +61,6 @@ class hetero_recipe (arb.recipe):
         else:
             return arb.cell_kind.cable
 
-    def num_sources(self, gid):
-        return 0
-
-    def num_targets(self, gid):
-        return 0
-
     def connections_on(self, gid):
         return []
 
@@ -79,22 +77,22 @@ class gj_switch:
         return getattr(self, 'case_' + str(arg), lambda: default)()
 
     def case_1(self):
-        return [arb.gap_junction_connection(arb.cell_member(7 + self.shift_, 0), 0, 0.1)]
+        return [arb.gap_junction_connection((7 + self.shift_, "gj"), "gj", 0.1)]
 
     def case_2(self):
-        return [arb.gap_junction_connection(arb.cell_member(6 + self.shift_, 0), 0, 0.1),
-                arb.gap_junction_connection(arb.cell_member(9 + self.shift_, 0), 0, 0.1)]
+        return [arb.gap_junction_connection((6 + self.shift_, "gj"), "gj", 0.1),
+                arb.gap_junction_connection((9 + self.shift_, "gj"), "gj", 0.1)]
 
     def case_6(self):
-        return [arb.gap_junction_connection(arb.cell_member(2 + self.shift_, 0), 0, 0.1),
-                arb.gap_junction_connection(arb.cell_member(7 + self.shift_, 0), 0, 0.1)]
+        return [arb.gap_junction_connection((2 + self.shift_, "gj"), "gj", 0.1),
+                arb.gap_junction_connection((7 + self.shift_, "gj"), "gj", 0.1)]
 
     def case_7(self):
-        return [arb.gap_junction_connection(arb.cell_member(6 + self.shift_, 0), 0, 0.1),
-                arb.gap_junction_connection(arb.cell_member(1 + self.shift_, 0), 0, 0.1)]
+        return [arb.gap_junction_connection((6 + self.shift_, "gj"), "gj", 0.1),
+                arb.gap_junction_connection((1 + self.shift_, "gj"), "gj", 0.1)]
 
     def case_9(self):
-        return [arb.gap_junction_connection(arb.cell_member(2 + self.shift_, 0), 0, 0.1)]
+        return [arb.gap_junction_connection((2 + self.shift_, "gj"), "gj", 0.1)]
 
 class gj_symmetric (arb.recipe):
     def __init__(self, num_ranks):
@@ -127,7 +125,11 @@ class gj_non_symmetric (arb.recipe):
         return self.size*self.groups
 
     def cell_description(self, gid):
-        return []
+        tree = arb.segment_tree()
+        tree.append(arb.mnpos, arb.mpoint(-3, 0, 0, 3), arb.mpoint(3, 0, 0, 3), tag=1)
+        decor = arb.decor()
+        decor.place('(location 0 0.5)', arb.gap_junction_site(), "gj")
+        return arb.cable_cell(tree, arb.label_dict(), decor)
 
     def cell_kind(self, gid):
         return arb.cell_kind.cable
@@ -137,9 +139,9 @@ class gj_non_symmetric (arb.recipe):
         id = gid%self.size
 
         if (id == group and group != (self.groups - 1)):
-            return [arb.gap_junction_connection(arb.cell_member(gid + self.size, 0), 0, 0.1)]
+            return [arb.gap_junction_connection((gid + self.size, "gj"), "gj", 0.1)]
         elif (id == group - 1):
-            return [arb.gap_junction_connection(arb.cell_member(gid - self.size, 0), 0, 0.1)]
+            return [arb.gap_junction_connection((gid - self.size, "gj"), "gj", 0.1)]
         else:
             return []
 
diff --git a/python/test/unit_distributed/test_simulator.py b/python/test/unit_distributed/test_simulator.py
index 399ffdc4..0dcf1374 100644
--- a/python/test/unit_distributed/test_simulator.py
+++ b/python/test/unit_distributed/test_simulator.py
@@ -31,12 +31,6 @@ class lifN_recipe(A.recipe):
     def num_cells(self):
         return self.n_cell
 
-    def num_targets(self, gid):
-        return 1
-
-    def num_sources(self, gid):
-        return 1
-
     def cell_kind(self, gid):
         return A.cell_kind.lif
 
@@ -46,7 +40,7 @@ class lifN_recipe(A.recipe):
     def event_generators(self, gid):
         sched_dt = 0.25
         weight = 400
-        return [A.event_generator(0, weight, A.regular_schedule(sched_dt)) for gid in range(0, self.num_cells())]
+        return [A.event_generator("tgt", weight, A.regular_schedule(sched_dt)) for gid in range(0, self.num_cells())]
 
     def probes(self, gid):
         return []
@@ -55,7 +49,7 @@ class lifN_recipe(A.recipe):
         return self.props
 
     def cell_description(self, gid):
-        c = A.lif_cell()
+        c = A.lif_cell("src", "tgt")
         if gid%2==0:
             c.t_ref = 2
         else:
diff --git a/test/common_cells.cpp b/test/common_cells.cpp
index 7a5deb48..94422df0 100644
--- a/test/common_cells.cpp
+++ b/test/common_cells.cpp
@@ -132,7 +132,7 @@ msize_t soma_cell_builder::add_branch(
     return bid;
 }
 
-cable_cell_description  soma_cell_builder::make_cell() const {
+cable_cell_description soma_cell_builder::make_cell() const {
     // Test that a valid tree was generated, that is, every branch has
     // either 0 children, or at least 2 children.
     for (auto i: branch_distal_id) {
@@ -181,7 +181,7 @@ cable_cell_description make_cell_soma_only(bool with_stim) {
     auto c = builder.make_cell();
     c.decorations.paint("soma"_lab, "hh");
     if (with_stim) {
-        c.decorations.place(builder.location({0,0.5}), i_clamp{10., 100., 0.1});
+        c.decorations.place(builder.location({0,0.5}), i_clamp{10., 100., 0.1}, "cc");
     }
 
     return {c.morph, c.labels, c.decorations};
@@ -217,7 +217,7 @@ cable_cell_description make_cell_ball_and_stick(bool with_stim) {
     c.decorations.paint("soma"_lab, "hh");
     c.decorations.paint("dend"_lab, "pas");
     if (with_stim) {
-        c.decorations.place(builder.location({1,1}), i_clamp{5, 80, 0.3});
+        c.decorations.place(builder.location({1,1}), i_clamp{5, 80, 0.3}, "cc");
     }
 
     return {c.morph, c.labels, c.decorations};
@@ -256,8 +256,8 @@ cable_cell_description make_cell_ball_and_3stick(bool with_stim) {
     c.decorations.paint("soma"_lab, "hh");
     c.decorations.paint("dend"_lab, "pas");
     if (with_stim) {
-        c.decorations.place(builder.location({2,1}), i_clamp{5.,  80., 0.45});
-        c.decorations.place(builder.location({3,1}), i_clamp{40., 10.,-0.2});
+        c.decorations.place(builder.location({2,1}), i_clamp{5.,  80., 0.45}, "cc0");
+        c.decorations.place(builder.location({3,1}), i_clamp{40., 10.,-0.2}, "cc1");
     }
 
     return {c.morph, c.labels, c.decorations};
diff --git a/test/simple_recipes.hpp b/test/simple_recipes.hpp
index 5344a136..5ff33ff6 100644
--- a/test/simple_recipes.hpp
+++ b/test/simple_recipes.hpp
@@ -116,14 +116,6 @@ public:
     cell_size_type num_cells() const override { return cells_.size(); }
     cell_kind get_cell_kind(cell_gid_type) const override { return cell_kind::cable; }
 
-    cell_size_type num_sources(cell_gid_type i) const override {
-        return cells_.at(i).detectors().size();
-    }
-
-    cell_size_type num_targets(cell_gid_type i) const override {
-        return util::sum_by(cells_.at(i).synapses(), [](auto& syn) {return syn.second.size();});
-    }
-
     util::unique_any get_cell_description(cell_gid_type i) const override {
         return util::make_unique_any<cable_cell>(cells_[i]);
     }
diff --git a/test/unit-distributed/test_communicator.cpp b/test/unit-distributed/test_communicator.cpp
index 5c1f904a..e7e081d5 100644
--- a/test/unit-distributed/test_communicator.cpp
+++ b/test/unit-distributed/test_communicator.cpp
@@ -1,16 +1,19 @@
 #include "../gtest.h"
 #include "test.hpp"
 
-#include <stdexcept>
+#include <tuple>
 #include <vector>
 
 #include <arbor/domain_decomposition.hpp>
+#include <arbor/lif_cell.hpp>
 #include <arbor/load_balance.hpp>
 #include <arbor/spike_event.hpp>
-#include <threading/threading.hpp>
 
 #include "communication/communicator.hpp"
 #include "execution_context.hpp"
+#include "fvm_lowered_cell.hpp"
+#include "lif_cell_group.hpp"
+#include "mc_cell_group.hpp"
 #include "util/filter.hpp"
 #include "util/rangeutil.hpp"
 #include "util/span.hpp"
@@ -184,41 +187,52 @@ namespace {
     // Even gid are rss, and odd gid are cable cells.
     class ring_recipe: public recipe {
     public:
-        ring_recipe(cell_size_type s):
-            size_(s),
-            ranks_(g_context->distributed->size())
-        {}
+        ring_recipe(cell_size_type s): size_(s) {}
 
         cell_size_type num_cells() const override {
             return size_;
         }
 
-        util::unique_any get_cell_description(cell_gid_type) const override {
-            return {};
+        util::unique_any get_cell_description(cell_gid_type gid) const override {
+            if (gid%2) {
+                arb::segment_tree tree;
+                tree.append(arb::mnpos, {0, 0, 0.0, 1.0}, {0, 0, 200, 1.0}, 1);
+                arb::decor decor;
+                decor.set_default(arb::cv_policy_fixed_per_branch(10));
+                decor.place(arb::mlocation{0, 0.5}, arb::threshold_detector{10}, "src");
+                decor.place(arb::mlocation{0, 0.5}, "expsyn", "tgt");
+                return arb::cable_cell(arb::morphology(tree), {}, decor);
+            }
+            return arb::lif_cell("src", "tgt");
         }
 
         cell_kind get_cell_kind(cell_gid_type gid) const override {
-            return gid%2? cell_kind::cable: cell_kind::spike_source;
+            return gid%2? cell_kind::cable: cell_kind::lif;
         }
 
-        cell_size_type num_sources(cell_gid_type) const override { return 1; }
-        cell_size_type num_targets(cell_gid_type) const override { return 1; }
-
         std::vector<cell_connection> connections_on(cell_gid_type gid) const override {
             // a single connection from the preceding cell, i.e. a ring
             // weight is the destination gid
             // delay is 1
-            cell_member_type src = {gid==0? size_-1: gid-1, 0};
-            cell_lid_type dst = 0;
+            cell_global_label_type src = {gid==0? size_-1: gid-1, "src"};
+            cell_local_label_type dst = {"tgt"};
             return {cell_connection(
                         src, dst,   // end points
                         float(gid), // weight
                         1.0f)};     // delay
         }
 
+        std::any get_global_properties(arb::cell_kind kind) const override {
+            if (kind == arb::cell_kind::cable) {
+                arb::cable_cell_global_properties gprop;
+                gprop.default_parameters = arb::neuron_parameter_defaults;
+                return gprop;
+            }
+            return {};
+        }
+
     private:
         cell_size_type size_;
-        cell_size_type ranks_;
     };
 
     cell_gid_type source_of(cell_gid_type gid, cell_size_type num_cells) {
@@ -247,32 +261,32 @@ namespace {
     // Even gid are rss, and odd gid are cable cells.
     class all2all_recipe: public recipe {
     public:
-        all2all_recipe(cell_size_type s):
-            size_(s),
-            ranks_(g_context->distributed->size())
-        {}
+        all2all_recipe(cell_size_type s): size_(s) {}
 
         cell_size_type num_cells() const override {
             return size_;
         }
 
-        util::unique_any get_cell_description(cell_gid_type) const override {
-            return {};
+        util::unique_any get_cell_description(cell_gid_type gid) const override {
+            arb::segment_tree tree;
+            tree.append(arb::mnpos, {0, 0, 0.0, 1.0}, {0, 0, 200, 1.0}, 1);
+            arb::decor decor;
+            decor.set_default(arb::cv_policy_fixed_per_branch(10));
+            decor.place(arb::mlocation{0, 0.5}, arb::threshold_detector{10}, "src");
+            decor.place(arb::ls::uniform(arb::reg::all(), 0, size_, gid), "expsyn", "tgt");
+            return arb::cable_cell(arb::morphology(tree), {}, decor);
         }
         cell_kind get_cell_kind(cell_gid_type gid) const override {
-            return gid%2? cell_kind::cable: cell_kind::spike_source;
+            return cell_kind::cable;
         }
 
-        cell_size_type num_sources(cell_gid_type) const override { return 1; }
-        cell_size_type num_targets(cell_gid_type) const override { return size_; }
-
         std::vector<cell_connection> connections_on(cell_gid_type gid) const override {
             std::vector<cell_connection> cons;
             cons.reserve(size_);
             for (auto sid: util::make_span(0, size_)) {
                 cell_connection con(
-                        {sid, 0},       // source
-                        sid,     // destination
+                        {sid, {"src", arb::lid_selection_policy::round_robin}}, // source
+                        {"tgt", arb::lid_selection_policy::round_robin},        // destination
                         float(gid+sid), // weight
                         1.0f);          // delay
                 cons.push_back(con);
@@ -280,9 +294,14 @@ namespace {
             return cons;
         }
 
+        std::any get_global_properties(arb::cell_kind) const override {
+            arb::cable_cell_global_properties gprop;
+            gprop.default_parameters = arb::neuron_parameter_defaults;
+            return gprop;
+        }
+
     private:
         cell_size_type size_;
-        cell_size_type ranks_;
     };
 
     spike_event expected_event_all2all(cell_gid_type gid, cell_gid_type sid) {
@@ -312,6 +331,105 @@ namespace {
         }
         return map;
     }
+
+    class mini_recipe: public recipe {
+    public:
+        mini_recipe(cell_size_type s): nranks_(s), ncells_(nranks_*3) {}
+
+        cell_size_type num_cells() const override {
+            return ncells_;
+        }
+
+        util::unique_any get_cell_description(cell_gid_type gid) const override {
+            arb::segment_tree tree;
+            tree.append(arb::mnpos, {0, 0, 0.0, 1.0}, {0, 0, 200, 1.0}, 1);
+            arb::decor decor;
+            if (gid%3 != 1) {
+                decor.place(arb::ls::uniform(arb::reg::all(), 0, 1, gid), "expsyn", "synapses_0");
+                decor.place(arb::ls::uniform(arb::reg::all(), 2, 2, gid), "expsyn", "synapses_1");
+            }
+            else {
+                decor.place(arb::ls::uniform(arb::reg::all(), 0, 2, gid), arb::threshold_detector{10}, "detectors_0");
+                decor.place(arb::ls::uniform(arb::reg::all(), 3, 3, gid), arb::threshold_detector{10}, "detectors_1");
+            }
+            return arb::cable_cell(arb::morphology(tree), {}, decor);
+        }
+
+        cell_kind get_cell_kind(cell_gid_type gid) const override {
+            return gid%3 != 1? cell_kind::cable: cell_kind::lif;
+        }
+
+        std::vector<cell_connection> connections_on(cell_gid_type gid) const override {
+            // Cells with gid%3 == 1 are senders, the others are receivers.
+            // The following connections are formed; used to test out lid resolutions:
+            // 7 from detectors_0 (round-robin) to synapses_0 (round-robin)
+            // 1 from detectors_0 (round-robin) to synapses_1 (univalent)
+            // 2 from detectors_1 (round-robin) to synapses_0 (round-robin)
+            // 1 from detectors_1 (univalent)   to synapses_1 (round-robin)
+            // These Should generate the following {src_gid, src_lid} -> {tgt_gid, tgt_lid} mappings (unsorted; 1 rank with 3 cells total):
+            // cell 1 - > cell 0:
+            //   {1, 0} -> {0, 0}
+            //   {1, 1} -> {0, 1}
+            //   {1, 2} -> {0, 0}
+            //   {1, 0} -> {0, 1}
+            //   {1, 1} -> {0, 0}
+            //   {1, 2} -> {0, 1}
+            //   {1, 0} -> {0, 0}
+            //   {1, 1} -> {0, 2}
+            //   {1, 3} -> {0, 1}
+            //   {1, 3} -> {0, 0}
+            //   {1, 3} -> {0, 2}
+
+            // cell 1 - > cell 2:
+            //   {1, 0} -> {2, 0}
+            //   {1, 1} -> {2, 1}
+            //   {1, 2} -> {2, 0}
+            //   {1, 0} -> {2, 1}
+            //   {1, 1} -> {2, 0}
+            //   {1, 2} -> {2, 1}
+            //   {1, 0} -> {2, 0}
+            //   {1, 1} -> {2, 2}
+            //   {1, 3} -> {2, 1}
+            //   {1, 3} -> {2, 0}
+            //   {1, 3} -> {2, 2}
+            std::vector<cell_connection> cons;
+            using pol = lid_selection_policy;
+            if (gid%3 != 1) {
+                for (auto sid: util::make_span(0, ncells_)) {
+                    if (sid%3 == 1) {
+                        cons.push_back({{sid, "detectors_0", pol::round_robin}, {"synapses_0", pol::round_robin}, 1.0, 1.0});
+                        cons.push_back({{sid, "detectors_0", pol::round_robin}, {"synapses_0", pol::round_robin}, 1.0, 1.0});
+                        cons.push_back({{sid, "detectors_0", pol::round_robin}, {"synapses_0", pol::round_robin}, 1.0, 1.0});
+                        cons.push_back({{sid, "detectors_0", pol::round_robin}, {"synapses_0", pol::round_robin}, 1.0, 1.0});
+                        cons.push_back({{sid, "detectors_0", pol::round_robin}, {"synapses_0", pol::round_robin}, 1.0, 1.0});
+                        cons.push_back({{sid, "detectors_0", pol::round_robin}, {"synapses_0", pol::round_robin}, 1.0, 1.0});
+                        cons.push_back({{sid, "detectors_0", pol::round_robin}, {"synapses_0", pol::round_robin}, 1.0, 1.0});
+
+                        cons.push_back({{sid, "detectors_0", pol::round_robin}, {"synapses_1", pol::assert_univalent}, 1.0, 1.0});
+
+                        cons.push_back({{sid, "detectors_1", pol::round_robin}, {"synapses_0", pol::round_robin}, 1.0, 1.0});
+                        cons.push_back({{sid, "detectors_1", pol::round_robin}, {"synapses_0", pol::round_robin}, 1.0, 1.0});
+
+                        cons.push_back({{sid, "detectors_1", pol::assert_univalent}, {"synapses_1", pol::round_robin}, 1.0, 1.0});
+                    }
+                }
+            }
+            return cons;
+        }
+
+        std::any get_global_properties(arb::cell_kind kind) const override {
+            if (kind == arb::cell_kind::cable) {
+                arb::cable_cell_global_properties gprop;
+                gprop.default_parameters = arb::neuron_parameter_defaults;
+                return gprop;
+            }
+            return {};
+        }
+
+    private:
+        cell_size_type nranks_;
+        cell_size_type ncells_;
+    };
 }
 
 template <typename F>
@@ -389,7 +507,31 @@ TEST(communicator, ring)
     // use a node decomposition that reflects the resources available
     // on the node that the test is running on, including gpus.
     const auto D = partition_load_balance(R, g_context);
-    auto C = communicator(R, D, *g_context);
+
+    // set up source and target label->lid resolvers
+    // from mc_cell_group and lif_cell_group
+    std::vector<cell_gid_type> mc_gids, lif_gids;
+    for (auto g: D.groups) {
+        if (g.kind == cell_kind::cable) {
+            mc_gids.insert(mc_gids.end(), g.gids.begin(), g.gids.end());
+        }
+        else if (g.kind == cell_kind::lif) {
+            lif_gids.insert(lif_gids.end(), g.gids.begin(), g.gids.end());
+        }
+    }
+    cell_label_range mc_srcs, mc_tgts, lif_srcs, lif_tgts;
+    auto mc_group = mc_cell_group(mc_gids, R, mc_srcs, mc_tgts, make_fvm_lowered_cell(backend_kind::multicore, *g_context));
+    auto lif_group = lif_cell_group(lif_gids, R, lif_srcs, lif_tgts);
+
+    auto local_sources = cell_labels_and_gids(mc_srcs, mc_gids);
+    auto local_targets = cell_labels_and_gids(mc_tgts, mc_gids);
+    local_sources.append({lif_srcs, lif_gids});
+    local_targets.append({lif_tgts, lif_gids});
+
+    auto global_sources = g_context->distributed->gather_cell_labels_and_gids(local_sources);
+
+    // construct the communicator
+    auto C = communicator(R, D, label_resolution_map(global_sources), label_resolution_map(local_targets), *g_context);
 
     // every cell fires
     EXPECT_TRUE(test_ring(D, C, [](cell_gid_type g){return true;}));
@@ -484,7 +626,30 @@ TEST(communicator, all2all)
     // use a node decomposition that reflects the resources available
     // on the node that the test is running on, including gpus.
     const auto D = partition_load_balance(R, g_context);
-    auto C = communicator(R, D, *g_context);
+
+    // set up source and target label->lid resolvers
+    // from mc_cell_group
+    std::vector<cell_gid_type> mc_gids;
+    for (auto g: D.groups) {
+        mc_gids.insert(mc_gids.end(), g.gids.begin(), g.gids.end());
+    }
+    cell_label_range local_sources, local_targets;
+    auto mc_group = mc_cell_group(mc_gids, R, local_sources, local_targets, make_fvm_lowered_cell(backend_kind::multicore, *g_context));
+    auto global_sources = g_context->distributed->gather_cell_labels_and_gids({local_sources, mc_gids});
+
+    // construct the communicator
+    auto C = communicator(R, D, label_resolution_map(global_sources), label_resolution_map({local_targets, mc_gids}), *g_context);
+    auto connections = C.connections();
+
+    for (auto i: util::make_span(0, n_global)) {
+        for (unsigned j = 0; j < n_local; ++j) {
+            auto c = connections[i*n_local+j];
+            EXPECT_EQ(i, c.source().gid);
+            EXPECT_EQ(0u, c.source().index);
+            EXPECT_EQ(i, c.destination());
+            EXPECT_LT(c.index_on_domain(), n_local);
+        }
+    }
 
     // every cell fires
     EXPECT_TRUE(test_all2all(D, C, [](cell_gid_type g){return true;}));
@@ -495,3 +660,50 @@ TEST(communicator, all2all)
     // odd-numbered cells fire
     EXPECT_TRUE(test_all2all(D, C, [](cell_gid_type g){return g%2==1;}));
 }
+
+TEST(communicator, mini_network)
+{
+    using util::make_span;
+
+    // construct a homogeneous network of 10*n_domain identical cells in a ring
+    unsigned N = g_context->distributed->size();
+
+    auto R = mini_recipe(N);
+    // use a node decomposition that reflects the resources available
+    // on the node that the test is running on, including gpus.
+    const auto D = partition_load_balance(R, g_context);
+
+    // set up source and target label->lid resolvers
+    // from mc_cell_group
+    std::vector<cell_gid_type> gids;
+    for (auto g: D.groups) {
+        gids.insert(gids.end(), g.gids.begin(), g.gids.end());
+    }
+    cell_label_range local_sources, local_targets;
+    auto mc_group = mc_cell_group(gids, R, local_sources, local_targets, make_fvm_lowered_cell(backend_kind::multicore, *g_context));
+    auto global_sources = g_context->distributed->gather_cell_labels_and_gids({local_sources, gids});
+
+    // construct the communicator
+    auto C = communicator(R, D, label_resolution_map(global_sources), label_resolution_map({local_targets, gids}), *g_context);
+
+    // sort connections by source then target
+    auto connections = C.connections();
+    util::sort(connections, [](const connection& lhs, const connection& rhs) {
+      return std::forward_as_tuple(lhs.source(), lhs.index_on_domain(), lhs.destination()) < std::forward_as_tuple(rhs.source(), rhs.index_on_domain(), rhs.destination());
+    });
+
+    // Expect one set of 22 connections from every rank: these have been sorted.
+    std::vector<cell_lid_type> ex_source_lids =  {0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3};
+    std::vector<std::vector<cell_lid_type>> ex_target_lids = {{0, 0, 1, 0, 0, 1, 0, 1, 2, 0, 1, 2, 0, 1, 0, 1, 0, 1, 2, 0, 1, 2},
+                                                              {0, 1, 1, 0, 1, 1, 0, 1, 2, 0, 1, 2, 0, 1, 0, 1, 0, 1, 2, 0, 1, 2}};
+
+    for (auto i: util::make_span(0, N)) {
+        std::vector<cell_gid_type> ex_source_gids(22u, i*3 + 1);
+        for (unsigned j = 0; j < 22u; ++j) {
+            auto c = connections[i*22 + j];
+            EXPECT_EQ(ex_source_gids[j], c.source().gid);
+            EXPECT_EQ(ex_source_lids[j], c.source().index);
+            EXPECT_EQ(ex_target_lids[i%2][j], c.destination());
+        }
+    }
+}
diff --git a/test/unit-distributed/test_domain_decomposition.cpp b/test/unit-distributed/test_domain_decomposition.cpp
index e905f564..92a7a453 100644
--- a/test/unit-distributed/test_domain_decomposition.cpp
+++ b/test/unit-distributed/test_domain_decomposition.cpp
@@ -51,9 +51,6 @@ namespace {
                 cell_kind::cable;
         }
 
-        cell_size_type num_sources(cell_gid_type) const override { return 0; }
-        cell_size_type num_targets(cell_gid_type) const override { return 0; }
-
         std::vector<cell_connection> connections_on(cell_gid_type) const override {
             return {};
         }
@@ -85,20 +82,20 @@ namespace {
         std::vector<gap_junction_connection> gap_junctions_on(cell_gid_type gid) const override {
             unsigned shift = (gid/size_)*size_;
             switch (gid % size_) {
-                case 1 :  return { gap_junction_connection({7 + shift, 0}, 0, 0.1)};
+                case 1 :  return { gap_junction_connection({7 + shift, "gj"}, {"gj"}, 0.1)};
                 case 2 :  return {
-                    gap_junction_connection({6 + shift, 0}, 0, 0.1),
-                    gap_junction_connection({9 + shift, 0}, 0, 0.1)
+                    gap_junction_connection({6 + shift, "gj"}, {"gj"}, 0.1),
+                    gap_junction_connection({9 + shift, "gj"}, {"gj"}, 0.1)
                 };
                 case 6 :  return {
-                    gap_junction_connection({2 + shift, 0}, 0, 0.1),
-                    gap_junction_connection({7 + shift, 0}, 0, 0.1)
+                    gap_junction_connection({2 + shift, "gj"}, {"gj"}, 0.1),
+                    gap_junction_connection({7 + shift, "gj"}, {"gj"}, 0.1)
                 };
                 case 7 :  return {
-                    gap_junction_connection({6 + shift, 0}, 0, 0.1),
-                    gap_junction_connection({1 + shift, 0}, 0, 0.1)
+                    gap_junction_connection({6 + shift, "gj"}, {"gj"}, 0.1),
+                    gap_junction_connection({1 + shift, "gj"}, {"gj"}, 0.1)
                 };
-                case 9 :  return { gap_junction_connection({2 + shift, 0}, 0, 0.1)};
+                case 9 :  return { gap_junction_connection({2 + shift, "gj"}, {"gj"}, 0.1)};
                 default : return {};
             }
         }
@@ -127,10 +124,10 @@ namespace {
             unsigned group = gid/groups_;
             unsigned id = gid%size_;
             if (id == group && group != (groups_ - 1)) {
-                return {gap_junction_connection({gid + size_, 0}, 0, 0.1)};
+                return {gap_junction_connection({gid + size_, "gj"}, {"gj"}, 0.1)};
             }
             else if (id == group - 1) {
-                return {gap_junction_connection({gid - size_, 0}, 0, 0.1)};
+                return {gap_junction_connection({gid - size_, "gj"}, {"gj"}, 0.1)};
             }
             else {
                 return {};
diff --git a/test/unit-distributed/test_mpi.cpp b/test/unit-distributed/test_mpi.cpp
index 9dfa81d1..95ae3a8a 100644
--- a/test/unit-distributed/test_mpi.cpp
+++ b/test/unit-distributed/test_mpi.cpp
@@ -123,6 +123,40 @@ TEST(mpi, gather_string) {
     }
 }
 
+TEST(mpi, gather_string_vec) {
+    int id = mpi::rank(MPI_COMM_WORLD);
+    int size = mpi::size(MPI_COMM_WORLD);
+
+    // Make a vector of strings of variable length:
+    // rank strings
+    //  0   a
+    //  1   b; bb
+    //  2   c; cc; ccc
+    //  3   d; dd; ddd; dddd
+    //   ...
+    // 25   z; zz; ...; zzzz...zzz   (26 times z)
+    // 26   a; aa; ...; aaaa...aaaa  (27 times a)
+    auto make_string = [](int length, int id) {
+      return std::string(length, 'a'+char(id%26));};
+
+    std::vector<std::string> string_vec(id+1);
+    for (int i = 0; i < id+1; ++i) {
+        string_vec[i] = make_string(i+1, id);
+    }
+
+    auto gathered = mpi::gather_all(string_vec, MPI_COMM_WORLD);
+
+    int expected_size = size*(size+1)/2;
+    ASSERT_TRUE(expected_size==(int)gathered.size());
+
+    int idx = 0;
+    for (int i = 0; i < size; ++i) {
+        for (int j = 0; j < i+1; ++j) {
+            EXPECT_EQ(make_string(j+1, i), gathered[idx++]);
+        }
+    }
+}
+
 TEST(mpi, gather) {
     int id = mpi::rank(MPI_COMM_WORLD);
     int size = mpi::size(MPI_COMM_WORLD);
diff --git a/test/unit/CMakeLists.txt b/test/unit/CMakeLists.txt
index 745e52a2..ff579936 100644
--- a/test/unit/CMakeLists.txt
+++ b/test/unit/CMakeLists.txt
@@ -110,6 +110,7 @@ set(unit_sources
     test_index.cpp
     test_kinetic_linear.cpp
     test_lexcmp.cpp
+    test_label_resolution.cpp
     test_lif_cell_group.cpp
     test_local_context.cpp
     test_maputil.cpp
diff --git a/test/unit/test_cable_cell.cpp b/test/unit/test_cable_cell.cpp
index d57a0caa..882f4082 100644
--- a/test/unit/test_cable_cell.cpp
+++ b/test/unit/test_cable_cell.cpp
@@ -30,27 +30,45 @@ TEST(cable_cell, lid_ranges) {
 
     // Place synapses and threshold detectors in interleaved order.
     // Note: there are 2 terminal points.
-    auto idx1 = decorations.place("term"_lab, "expsyn");
-    auto idx2 = decorations.place("term"_lab, "expsyn");
-    auto idx3 = decorations.place("term"_lab, threshold_detector{-10});
-    auto idx4 = decorations.place(empty_sites, "expsyn");
-    auto idx5 = decorations.place("term"_lab, threshold_detector{-20});
-    auto idx6 = decorations.place(three_sites, "expsyn");
+    decorations.place("term"_lab, "expsyn", "t0");
+    decorations.place("term"_lab, "expsyn", "t1");
+    decorations.place("term"_lab, threshold_detector{-10}, "s0");
+    decorations.place(empty_sites, "expsyn", "t2");
+    decorations.place("term"_lab, threshold_detector{-20}, "s1");
+    decorations.place(three_sites, "expsyn", "t3");
+    decorations.place("term"_lab, "exp2syn", "t3");
 
     cable_cell cell(morph, dict, decorations);
 
     // Get the assigned lid ranges for each placement
-    auto r1 = cell.placed_lid_range(idx1);
-    auto r2 = cell.placed_lid_range(idx2);
-    auto r3 = cell.placed_lid_range(idx3);
-    auto r4 = cell.placed_lid_range(idx4);
-    auto r5 = cell.placed_lid_range(idx5);
-    auto r6 = cell.placed_lid_range(idx6);
-
-    EXPECT_EQ(idx1, 0u); EXPECT_EQ(r1.begin, 0u); EXPECT_EQ(r1.end, 2u);
-    EXPECT_EQ(idx2, 1u); EXPECT_EQ(r2.begin, 2u); EXPECT_EQ(r2.end, 4u);
-    EXPECT_EQ(idx3, 2u); EXPECT_EQ(r3.begin, 0u); EXPECT_EQ(r3.end, 2u);
-    EXPECT_EQ(idx4, 3u); EXPECT_EQ(r4.begin, 4u); EXPECT_EQ(r4.end, 4u);
-    EXPECT_EQ(idx5, 4u); EXPECT_EQ(r5.begin, 2u); EXPECT_EQ(r5.end, 4u);
-    EXPECT_EQ(idx6, 5u); EXPECT_EQ(r6.begin, 4u); EXPECT_EQ(r6.end, 7u);
+    const auto& src_ranges = cell.detector_ranges();
+    const auto& tgt_ranges = cell.synapse_ranges();
+
+    EXPECT_EQ(1u, tgt_ranges.count("t0"));
+    EXPECT_EQ(1u, tgt_ranges.count("t1"));
+    EXPECT_EQ(1u, src_ranges.count("s0"));
+    EXPECT_EQ(1u, tgt_ranges.count("t2"));
+    EXPECT_EQ(1u, src_ranges.count("s1"));
+    EXPECT_EQ(2u, tgt_ranges.count("t3"));
+
+    auto r1 = tgt_ranges.equal_range("t0").first->second;
+    auto r2 = tgt_ranges.equal_range("t1").first->second;
+    auto r3 = src_ranges.equal_range("s0").first->second;
+    auto r4 = tgt_ranges.equal_range("t2").first->second;
+    auto r5 = src_ranges.equal_range("s1").first->second;
+
+    auto r6_range = tgt_ranges.equal_range("t3");
+    auto r6_0 = r6_range.first;
+    auto r6_1 = std::next(r6_range.first);
+    if (r6_0->second.begin != 4u) {
+        std::swap(r6_0, r6_1);
+    }
+
+    EXPECT_EQ(r1.begin, 0u); EXPECT_EQ(r1.end, 2u);
+    EXPECT_EQ(r2.begin, 2u); EXPECT_EQ(r2.end, 4u);
+    EXPECT_EQ(r3.begin, 0u); EXPECT_EQ(r3.end, 2u);
+    EXPECT_EQ(r4.begin, 4u); EXPECT_EQ(r4.end, 4u);
+    EXPECT_EQ(r5.begin, 2u); EXPECT_EQ(r5.end, 4u);
+    EXPECT_EQ(r6_0->second.begin, 4u); EXPECT_EQ(r6_0->second.end, 7u);
+    EXPECT_EQ(r6_1->second.begin, 7u); EXPECT_EQ(r6_1->second.end, 9u);
 }
diff --git a/test/unit/test_domain_decomposition.cpp b/test/unit/test_domain_decomposition.cpp
index 2db33679..223f894f 100644
--- a/test/unit/test_domain_decomposition.cpp
+++ b/test/unit/test_domain_decomposition.cpp
@@ -68,7 +68,7 @@ namespace {
 
         arb::util::unique_any get_cell_description(cell_gid_type) const override {
             auto c = arb::make_cell_soma_only(false);
-            c.decorations.place(mlocation{0,1}, gap_junction_site{});
+            c.decorations.place(mlocation{0,1}, gap_junction_site{}, "gj");
             return {arb::cable_cell(c)};
         }
 
@@ -78,38 +78,38 @@ namespace {
         std::vector<gap_junction_connection> gap_junctions_on(cell_gid_type gid) const override {
             switch (gid) {
                 case 0 :  return {
-                    gap_junction_connection({13, 0}, 0, 0.1)
+                    gap_junction_connection({13, "gj"}, {"gj"}, 0.1)
                 };
                 case 2 :  return {
-                    gap_junction_connection({7, 0}, 0, 0.1),
-                    gap_junction_connection({11, 0}, 0, 0.1)
+                    gap_junction_connection({7,  "gj"}, {"gj"}, 0.1),
+                    gap_junction_connection({11, "gj"}, {"gj"}, 0.1)
                 };
                 case 3 :  return {
-                    gap_junction_connection({4, 0}, 0, 0.1),
-                    gap_junction_connection({8, 0}, 0, 0.1)
+                    gap_junction_connection({4, "gj"}, {"gj"}, 0.1),
+                    gap_junction_connection({8, "gj"}, {"gj"}, 0.1)
                 };
                 case 4 :  return {
-                    gap_junction_connection({3, 0}, 0, 0.1),
-                    gap_junction_connection({8, 0}, 0, 0.1),
-                    gap_junction_connection({9, 0}, 0, 0.1)
+                    gap_junction_connection({3, "gj"}, {"gj"}, 0.1),
+                    gap_junction_connection({8, "gj"}, {"gj"}, 0.1),
+                    gap_junction_connection({9, "gj"}, {"gj"}, 0.1)
                 };
                 case 7 :  return {
-                    gap_junction_connection({2, 0}, 0, 0.1),
-                    gap_junction_connection({11, 0}, 0, 0.1)
+                    gap_junction_connection({2,  "gj"}, {"gj"}, 0.1),
+                    gap_junction_connection({11, "gj"}, {"gj"}, 0.1)
                 };;
                 case 8 :  return {
-                    gap_junction_connection({3, 0}, 0, 0.1),
-                    gap_junction_connection({4, 0}, 0, 0.1)
+                    gap_junction_connection({3, "gj"}, {"gj"}, 0.1),
+                    gap_junction_connection({4, "gj"}, {"gj"}, 0.1)
                 };;
                 case 9 :  return {
-                    gap_junction_connection({4, 0}, 0, 0.1)
+                    gap_junction_connection({4, "gj"}, {"gj"}, 0.1)
                 };
                 case 11 : return {
-                    gap_junction_connection({2, 0}, 0, 0.1),
-                    gap_junction_connection({7, 0}, 0, 0.1)
+                    gap_junction_connection({2, "gj"}, {"gj"}, 0.1),
+                    gap_junction_connection({7, "gj"}, {"gj"}, 0.1)
                 };
                 case 13 : return {
-                    gap_junction_connection({0, 0}, 0, 0.1)
+                    gap_junction_connection({0, "gj"}, {"gj"}, 0.1)
                 };
                 default : return {};
             }
diff --git a/test/unit/test_event_delivery.cpp b/test/unit/test_event_delivery.cpp
index 1d4f9cd9..9ffcb086 100644
--- a/test/unit/test_event_delivery.cpp
+++ b/test/unit/test_event_delivery.cpp
@@ -35,16 +35,13 @@ struct test_recipe: public n_cable_cell_recipe {
         labels.set("soma", arb::reg::tagged(1));
 
         decor decorations;
-        decorations.place(mlocation{0, 0.5}, "expsyn");
-        decorations.place(mlocation{0, 0.5}, threshold_detector{-64});
-        decorations.place(mlocation{0, 0.5}, gap_junction_site{});
+        decorations.place(mlocation{0, 0.5}, "expsyn", "synapse");
+        decorations.place(mlocation{0, 0.5}, threshold_detector{-64}, "detector");
+        decorations.place(mlocation{0, 0.5}, gap_junction_site{}, "gapjunction");
         cable_cell c(st, labels, decorations);
 
         return c;
     }
-
-    cell_size_type num_sources(cell_gid_type) const override { return 1; }
-    cell_size_type num_targets(cell_gid_type) const override { return 1; }
 };
 
 using gid_vector = std::vector<cell_gid_type>;
@@ -107,13 +104,13 @@ struct test_recipe_gj: public test_recipe {
     explicit test_recipe_gj(int n, cell_gj_pairs gj_pairs):
         test_recipe(n), gj_pairs_(std::move(gj_pairs)) {}
 
-    cell_size_type num_gap_junction_sites(cell_gid_type) const override { return 1; }
-
     std::vector<gap_junction_connection> gap_junctions_on(cell_gid_type i) const override {
         std::vector<gap_junction_connection> gjs;
         for (auto p: gj_pairs_) {
-            if (p.first == i) gjs.push_back({{p.second, 0u}, 0u, 0.});
-            if (p.second == i) gjs.push_back({{p.first, 0u}, 0u, 0.});
+            if (p.first == i) gjs.push_back({{p.second, "gapjunction", lid_selection_policy::assert_univalent},
+                                             {"gapjunction", lid_selection_policy::assert_univalent}, 0.});
+            if (p.second == i) gjs.push_back({{p.first, "gapjunction", lid_selection_policy::assert_univalent},
+                                                    {"gapjunction", lid_selection_policy::assert_univalent}, 0.});
         }
         return gjs;
     }
diff --git a/test/unit/test_event_generators.cpp b/test/unit/test_event_generators.cpp
index 0f17feb4..cdb97b82 100644
--- a/test/unit/test_event_generators.cpp
+++ b/test/unit/test_event_generators.cpp
@@ -18,7 +18,8 @@ namespace{
 }
 
 TEST(event_generators, assign_and_copy) {
-    event_generator gen = regular_generator(2, 5., 0.5, 0.75);
+    event_generator gen = regular_generator({"l2"}, 5., 0.5, 0.75);
+    gen.resolve_label([](const cell_local_label_type&) {return 2;});
     spike_event expected{2, 0.75, 5.};
 
     auto first = [](const event_seq& seq) {
@@ -54,16 +55,18 @@ TEST(event_generators, regular) {
     // events regularly spaced 0.5 ms apart.
     time_type t0 = 2.0;
     time_type dt = 0.5;
-    cell_lid_type target = 3;
+    cell_tag_type label = "label";
+    cell_lid_type lid = 3;
     float weight = 3.14;
 
-    event_generator gen = regular_generator(target, weight, t0, dt);
+    event_generator gen = regular_generator(label, weight, t0, dt);
+    gen.resolve_label([lid](const cell_local_label_type&) {return lid;});
 
     // Helper for building a set of expected events.
     auto expected = [&] (std::vector<time_type> times) {
         pse_vector events;
         for (auto t: times) {
-            events.push_back({target, t, weight});
+            events.push_back({lid, t, weight});
         }
         return events;
     };
@@ -80,42 +83,53 @@ TEST(event_generators, regular) {
 }
 
 TEST(event_generators, seq) {
-    pse_vector in = {
-        {0, 0.1, 1.0},
-        {0, 1.0, 2.0},
-        {0, 1.0, 3.0},
-        {0, 1.5, 4.0},
-        {0, 2.3, 5.0},
-        {0, 3.0, 6.0},
-        {0, 3.5, 7.0},
-    };
-
-    auto events = [&in] (int b, int e) {
-        return pse_vector(in.begin()+b, in.begin()+e);
+    explicit_generator::lse_vector in = {
+        {{"l0"}, 0.1, 1.0},
+        {{"l0"}, 1.0, 2.0},
+        {{"l2"}, 1.0, 3.0},
+        {{"l1"}, 1.5, 4.0},
+        {{"l2"}, 2.3, 5.0},
+        {{"l0"}, 3.0, 6.0},
+        {{"l0"}, 3.5, 7.0},
     };
+    std::unordered_map<cell_tag_type, cell_lid_type> lid_map = {{"l0", 0},{"l1", 1}, {"l2", 2}};
+    pse_vector expected;
+    std::transform(in.begin(), in.end(), std::back_inserter(expected),
+        [lid_map](const auto& item) {return spike_event{lid_map.at(item.label.tag), item.time, item.weight};});
 
     event_generator gen = explicit_generator(in);
-    EXPECT_EQ(in, as_vector(gen.events(0, 100.)));
+    gen.resolve_label([lid_map](const cell_local_label_type& item) {return lid_map.at(item.tag);});
+
+    EXPECT_EQ(expected, as_vector(gen.events(0, 100.)));
     gen.reset();
-    EXPECT_EQ(in, as_vector(gen.events(0, 100.)));
+    EXPECT_EQ(expected, as_vector(gen.events(0, 100.)));
     gen.reset();
 
     // Check reported sub-intervals against a smaller set of events.
     in = {
-        {0, 1.5, 4.0},
-        {0, 2.3, 5.0},
-        {0, 3.0, 6.0},
-        {0, 3.5, 7.0},
+        {{"l0"}, 1.5, 4.0},
+        {{"l0"}, 2.3, 5.0},
+        {{"l0"}, 3.0, 6.0},
+        {{"l0"}, 3.5, 7.0},
     };
+    expected.clear();
+    std::transform(in.begin(), in.end(), std::back_inserter(expected),
+        [lid_map](const auto& item) {return spike_event{lid_map.at(item.label.tag), item.time, item.weight};});
+
     gen = explicit_generator(in);
+    gen.resolve_label([lid_map](const cell_local_label_type& item) {return lid_map.at(item.tag);});
 
     auto draw = [](event_generator& gen, time_type t0, time_type t1) {
         gen.reset();
         return as_vector(gen.events(t0, t1));
     };
 
+    auto events = [&expected] (int b, int e) {
+      return pse_vector(expected.begin()+b, expected.begin()+e);
+    };
+
     // a range that includes all the events
-    EXPECT_EQ(in, draw(gen, 0, 4));
+    EXPECT_EQ(expected, draw(gen, 0, 4));
 
     // a strict subset including the first event
     EXPECT_EQ(events(0, 2), draw(gen, 0, 3));
@@ -143,10 +157,12 @@ TEST(event_generators, poisson) {
     time_type t0 = 0;
     time_type t1 = 10;
     time_type lambda = 10; // expect 10 events per ms
-    cell_lid_type target = 2;
+    cell_tag_type label = "label";
+    cell_lid_type lid = 2;
     float weight = 42;
 
-    event_generator gen = poisson_generator(target, weight, t0, lambda, G);
+    event_generator gen = poisson_generator(label, weight, t0, lambda, G);
+    gen.resolve_label([lid](const cell_local_label_type&) {return lid;});
 
     pse_vector int1 = as_vector(gen.events(0, t1));
     // Test that the output is sorted
diff --git a/test/unit/test_fvm_layout.cpp b/test/unit/test_fvm_layout.cpp
index 6132b3b1..ed621761 100644
--- a/test/unit/test_fvm_layout.cpp
+++ b/test/unit/test_fvm_layout.cpp
@@ -56,7 +56,7 @@ namespace {
             auto description = builder.make_cell();
             description.decorations.paint("\"soma\"", "hh");
             description.decorations.paint("\"dend\"", "pas");
-            description.decorations.place(builder.location({1,1}), i_clamp{5, 80, 0.3});
+            description.decorations.place(builder.location({1,1}), i_clamp{5, 80, 0.3}, "clamp");
 
             s.builders.push_back(std::move(builder));
             descriptions.push_back(description);
@@ -108,8 +108,8 @@ namespace {
             desc.decorations.paint(c2, membrane_capacitance{0.013});
             desc.decorations.paint(c3, membrane_capacitance{0.018});
 
-            desc.decorations.place(b.location({2,1}), i_clamp{5.,  80., 0.45});
-            desc.decorations.place(b.location({3,1}), i_clamp{40., 10.,-0.2});
+            desc.decorations.place(b.location({2,1}), i_clamp{5.,  80., 0.45}, "clamo0");
+            desc.decorations.place(b.location({3,1}), i_clamp{40., 10.,-0.2}, "clamp1");
 
             desc.decorations.set_default(axial_resistivity{90});
 
@@ -133,10 +133,10 @@ TEST(fvm_layout, mech_index) {
     auto& builders = system.builders;
 
     // Add four synapses of two varieties across the cells.
-    descriptions[0].decorations.place(builders[0].location({1, 0.4}), "expsyn");
-    descriptions[0].decorations.place(builders[0].location({1, 0.4}), "expsyn");
-    descriptions[1].decorations.place(builders[1].location({2, 0.4}), "exp2syn");
-    descriptions[1].decorations.place(builders[1].location({3, 0.4}), "expsyn");
+    descriptions[0].decorations.place(builders[0].location({1, 0.4}), "expsyn", "syn0");
+    descriptions[0].decorations.place(builders[0].location({1, 0.4}), "expsyn", "syn1");
+    descriptions[1].decorations.place(builders[1].location({2, 0.4}), "exp2syn", "syn3");
+    descriptions[1].decorations.place(builders[1].location({3, 0.4}), "expsyn", "syn4");
 
     cable_cell_global_properties gprop;
     gprop.default_parameters = neuron_parameter_defaults;
@@ -253,10 +253,10 @@ TEST(fvm_layout, coalescing_synapses) {
     {
         auto desc = builder.make_cell();
 
-        desc.decorations.place(builder.location({1, 0.3}), "expsyn");
-        desc.decorations.place(builder.location({1, 0.5}), "expsyn");
-        desc.decorations.place(builder.location({1, 0.7}), "expsyn");
-        desc.decorations.place(builder.location({1, 0.9}), "expsyn");
+        desc.decorations.place(builder.location({1, 0.3}), "expsyn", "syn0");
+        desc.decorations.place(builder.location({1, 0.5}), "expsyn", "syn1");
+        desc.decorations.place(builder.location({1, 0.7}), "expsyn", "syn2");
+        desc.decorations.place(builder.location({1, 0.9}), "expsyn", "syn3");
 
         cable_cell cell(desc);
         fvm_cv_discretization D = fvm_cv_discretize({cell}, neuron_parameter_defaults);
@@ -270,10 +270,10 @@ TEST(fvm_layout, coalescing_synapses) {
         auto desc = builder.make_cell();
 
         // Add synapses of two varieties.
-        desc.decorations.place(builder.location({1, 0.3}), "expsyn");
-        desc.decorations.place(builder.location({1, 0.5}), "exp2syn");
-        desc.decorations.place(builder.location({1, 0.7}), "expsyn");
-        desc.decorations.place(builder.location({1, 0.9}), "exp2syn");
+        desc.decorations.place(builder.location({1, 0.3}), "expsyn", "syn0");
+        desc.decorations.place(builder.location({1, 0.5}), "exp2syn", "syn1");
+        desc.decorations.place(builder.location({1, 0.7}), "expsyn", "syn2");
+        desc.decorations.place(builder.location({1, 0.9}), "exp2syn", "syn3");
 
         cable_cell cell(desc);
         fvm_cv_discretization D = fvm_cv_discretize({cell}, neuron_parameter_defaults);
@@ -290,10 +290,10 @@ TEST(fvm_layout, coalescing_synapses) {
     {
         auto desc = builder.make_cell();
 
-        desc.decorations.place(builder.location({1, 0.3}), "expsyn");
-        desc.decorations.place(builder.location({1, 0.5}), "expsyn");
-        desc.decorations.place(builder.location({1, 0.7}), "expsyn");
-        desc.decorations.place(builder.location({1, 0.9}), "expsyn");
+        desc.decorations.place(builder.location({1, 0.3}), "expsyn", "syn0");
+        desc.decorations.place(builder.location({1, 0.5}), "expsyn", "syn1");
+        desc.decorations.place(builder.location({1, 0.7}), "expsyn", "syn2");
+        desc.decorations.place(builder.location({1, 0.9}), "expsyn", "syn3");
 
         cable_cell cell(desc);
         fvm_cv_discretization D = fvm_cv_discretize({cell}, neuron_parameter_defaults);
@@ -307,10 +307,10 @@ TEST(fvm_layout, coalescing_synapses) {
         auto desc = builder.make_cell();
 
         // Add synapses of two varieties.
-        desc.decorations.place(builder.location({1, 0.3}), "expsyn");
-        desc.decorations.place(builder.location({1, 0.5}), "exp2syn");
-        desc.decorations.place(builder.location({1, 0.7}), "expsyn");
-        desc.decorations.place(builder.location({1, 0.9}), "exp2syn");
+        desc.decorations.place(builder.location({1, 0.3}), "expsyn", "syn0");
+        desc.decorations.place(builder.location({1, 0.5}), "exp2syn", "syn1");
+        desc.decorations.place(builder.location({1, 0.7}), "expsyn", "syn2");
+        desc.decorations.place(builder.location({1, 0.9}), "exp2syn", "syn3");
 
         cable_cell cell(desc);
         fvm_cv_discretization D = fvm_cv_discretize({cell}, neuron_parameter_defaults);
@@ -328,10 +328,10 @@ TEST(fvm_layout, coalescing_synapses) {
         auto desc = builder.make_cell();
 
         // Add synapses of two varieties.
-        desc.decorations.place(builder.location({1, 0.3}), "expsyn");
-        desc.decorations.place(builder.location({1, 0.3}), "expsyn");
-        desc.decorations.place(builder.location({1, 0.7}), "expsyn");
-        desc.decorations.place(builder.location({1, 0.7}), "expsyn");
+        desc.decorations.place(builder.location({1, 0.3}), "expsyn", "syn0");
+        desc.decorations.place(builder.location({1, 0.3}), "expsyn", "syn1");
+        desc.decorations.place(builder.location({1, 0.7}), "expsyn", "syn2");
+        desc.decorations.place(builder.location({1, 0.7}), "expsyn", "syn3");
 
         cable_cell cell(desc);
         fvm_cv_discretization D = fvm_cv_discretize({cell}, neuron_parameter_defaults);
@@ -345,10 +345,10 @@ TEST(fvm_layout, coalescing_synapses) {
         auto desc = builder.make_cell();
 
         // Add synapses of two varieties.
-        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 0, 0.2));
-        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 0, 0.2));
-        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 0.1, 0.2));
-        desc.decorations.place(builder.location({1, 0.7}), syn_desc("expsyn", 0.1, 0.2));
+        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 0, 0.2), "syn0");
+        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 0, 0.2), "syn1");
+        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 0.1, 0.2), "syn2");
+        desc.decorations.place(builder.location({1, 0.7}), syn_desc("expsyn", 0.1, 0.2), "syn3");
 
         cable_cell cell(desc);
         fvm_cv_discretization D = fvm_cv_discretize({cell}, neuron_parameter_defaults);
@@ -368,14 +368,14 @@ TEST(fvm_layout, coalescing_synapses) {
         auto desc = builder.make_cell();
 
         // Add synapses of two varieties.
-        desc.decorations.place(builder.location({1, 0.7}), syn_desc("expsyn", 0, 3));
-        desc.decorations.place(builder.location({1, 0.7}), syn_desc("expsyn", 1, 3));
-        desc.decorations.place(builder.location({1, 0.7}), syn_desc("expsyn", 0, 3));
-        desc.decorations.place(builder.location({1, 0.7}), syn_desc("expsyn", 1, 3));
-        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 0, 2));
-        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 1, 2));
-        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 0, 2));
-        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 1, 2));
+        desc.decorations.place(builder.location({1, 0.7}), syn_desc("expsyn", 0, 3), "syn0");
+        desc.decorations.place(builder.location({1, 0.7}), syn_desc("expsyn", 1, 3), "syn1");
+        desc.decorations.place(builder.location({1, 0.7}), syn_desc("expsyn", 0, 3), "syn2");
+        desc.decorations.place(builder.location({1, 0.7}), syn_desc("expsyn", 1, 3), "syn3");
+        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 0, 2), "syn4");
+        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 1, 2), "syn5");
+        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 0, 2), "syn6");
+        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 1, 2), "syn7");
 
         cable_cell cell(desc);
         fvm_cv_discretization D = fvm_cv_discretize({cell}, neuron_parameter_defaults);
@@ -396,16 +396,16 @@ TEST(fvm_layout, coalescing_synapses) {
         auto desc = builder.make_cell();
 
         // Add synapses of two varieties.
-        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn",  1, 2));
-        desc.decorations.place(builder.location({1, 0.3}), syn_desc_2("exp2syn", 4, 1));
-        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn",  1, 2));
-        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn",  5, 1));
-        desc.decorations.place(builder.location({1, 0.3}), syn_desc_2("exp2syn", 1, 3));
-        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn",  1, 2));
-        desc.decorations.place(builder.location({1, 0.7}), syn_desc_2("exp2syn", 2, 2));
-        desc.decorations.place(builder.location({1, 0.7}), syn_desc_2("exp2syn", 2, 1));
-        desc.decorations.place(builder.location({1, 0.7}), syn_desc_2("exp2syn", 2, 1));
-        desc.decorations.place(builder.location({1, 0.7}), syn_desc_2("exp2syn", 2, 2));
+        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn",  1, 2), "syn0");
+        desc.decorations.place(builder.location({1, 0.3}), syn_desc_2("exp2syn", 4, 1), "syn1");
+        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn",  1, 2), "syn2");
+        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn",  5, 1), "syn3");
+        desc.decorations.place(builder.location({1, 0.3}), syn_desc_2("exp2syn", 1, 3), "syn4");
+        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn",  1, 2), "syn5");
+        desc.decorations.place(builder.location({1, 0.7}), syn_desc_2("exp2syn", 2, 2), "syn6");
+        desc.decorations.place(builder.location({1, 0.7}), syn_desc_2("exp2syn", 2, 1), "syn7");
+        desc.decorations.place(builder.location({1, 0.7}), syn_desc_2("exp2syn", 2, 1), "syn8");
+        desc.decorations.place(builder.location({1, 0.7}), syn_desc_2("exp2syn", 2, 2), "syn9");
 
         cable_cell cell(desc);
         fvm_cv_discretization D = fvm_cv_discretize({cell}, neuron_parameter_defaults);
@@ -445,14 +445,14 @@ TEST(fvm_layout, synapse_targets) {
         return mechanism_desc(name).set("e", syn_e.at(idx));
     };
 
-    descriptions[0].decorations.place(builders[0].location({1, 0.9}), syn_desc("expsyn", 0));
-    descriptions[0].decorations.place(builders[0].location({0, 0.5}), syn_desc("expsyn", 1));
-    descriptions[0].decorations.place(builders[0].location({1, 0.4}), syn_desc("expsyn", 2));
+    descriptions[0].decorations.place(builders[0].location({1, 0.9}), syn_desc("expsyn", 0), "syn0");
+    descriptions[0].decorations.place(builders[0].location({0, 0.5}), syn_desc("expsyn", 1), "syn1");
+    descriptions[0].decorations.place(builders[0].location({1, 0.4}), syn_desc("expsyn", 2), "syn2");
 
-    descriptions[1].decorations.place(builders[1].location({2, 0.4}), syn_desc("exp2syn", 3));
-    descriptions[1].decorations.place(builders[1].location({1, 0.4}), syn_desc("exp2syn", 4));
-    descriptions[1].decorations.place(builders[1].location({3, 0.4}), syn_desc("expsyn", 5));
-    descriptions[1].decorations.place(builders[1].location({3, 0.7}), syn_desc("exp2syn", 6));
+    descriptions[1].decorations.place(builders[1].location({2, 0.4}), syn_desc("exp2syn", 3), "syn3");
+    descriptions[1].decorations.place(builders[1].location({1, 0.4}), syn_desc("exp2syn", 4), "syn4");
+    descriptions[1].decorations.place(builders[1].location({3, 0.4}), syn_desc("expsyn", 5), "syn5");
+    descriptions[1].decorations.place(builders[1].location({3, 0.7}), syn_desc("exp2syn", 6), "syn6");
 
     cable_cell_global_properties gprop;
     gprop.default_parameters = neuron_parameter_defaults;
diff --git a/test/unit/test_fvm_lowered.cpp b/test/unit/test_fvm_lowered.cpp
index 0634c25f..d8f04096 100644
--- a/test/unit/test_fvm_lowered.cpp
+++ b/test/unit/test_fvm_lowered.cpp
@@ -1,4 +1,5 @@
 #include <cmath>
+#include <numeric>
 #include <string>
 #include <vector>
 
@@ -97,7 +98,7 @@ public:
 
     arb::util::unique_any get_cell_description(cell_gid_type gid) const override {
         auto c = soma_cell_builder(20).make_cell();
-        c.decorations.place(mlocation{0, 1}, gap_junction_site{});
+        c.decorations.place(mlocation{0, 1}, gap_junction_site{}, "gj");
         return {cable_cell{c}};
     }
 
@@ -107,21 +108,21 @@ public:
     std::vector<gap_junction_connection> gap_junctions_on(cell_gid_type gid) const override {
         switch (gid) {
             case 0 :
-                return {gap_junction_connection({5, 0}, 0, 0.1)};
+                return {gap_junction_connection({5, "gj"}, {"gj"}, 0.1)};
             case 2 :
                 return {
-                        gap_junction_connection({3, 0}, 0, 0.1),
+                        gap_junction_connection({3, "gj"}, {"gj"}, 0.1),
                 };
             case 3 :
                 return {
-                        gap_junction_connection({7, 0}, 0, 0.1),
-                        gap_junction_connection({2, 0}, 0, 0.1)
+                        gap_junction_connection({7, "gj"}, {"gj"}, 0.1),
+                        gap_junction_connection({2, "gj"}, {"gj"}, 0.1)
                 };
             case 5 :
-                return {gap_junction_connection({0, 0}, 0, 0.1)};
+                return {gap_junction_connection({0, "gj"}, {"gj"}, 0.1)};
             case 7 :
                 return {
-                        gap_junction_connection({3, 0}, 0, 0.1),
+                        gap_junction_connection({3, "gj"}, {"gj"}, 0.1),
                 };
             default :
                 return {};
@@ -162,7 +163,7 @@ public:
 
     arb::util::unique_any get_cell_description(cell_gid_type) const override {
         auto c = soma_cell_builder(20).make_cell();
-        c.decorations.place(mlocation{0,1}, gap_junction_site{});
+        c.decorations.place(mlocation{0,1}, gap_junction_site{}, "gj");
         return {cable_cell{c}};
     }
 
@@ -173,27 +174,27 @@ public:
         switch (gid) {
             case 0 :
                 return {
-                        gap_junction_connection({2, 0}, 0, 0.1),
-                        gap_junction_connection({3, 0}, 0, 0.1),
-                        gap_junction_connection({5, 0}, 0, 0.1)
+                        gap_junction_connection({2, "gj"}, {"gj"}, 0.1),
+                        gap_junction_connection({3, "gj"}, {"gj"}, 0.1),
+                        gap_junction_connection({5, "gj"}, {"gj"}, 0.1)
                 };
             case 2 :
                 return {
-                        gap_junction_connection({0, 0}, 0, 0.1),
-                        gap_junction_connection({3, 0}, 0, 0.1),
-                        gap_junction_connection({5, 0}, 0, 0.1)
+                        gap_junction_connection({0, "gj"}, {"gj"}, 0.1),
+                        gap_junction_connection({3, "gj"}, {"gj"}, 0.1),
+                        gap_junction_connection({5, "gj"}, {"gj"}, 0.1)
                 };
             case 3 :
                 return {
-                        gap_junction_connection({0, 0}, 0, 0.1),
-                        gap_junction_connection({2, 0}, 0, 0.1),
-                        gap_junction_connection({5, 0}, 0, 0.1)
+                        gap_junction_connection({0, "gj"}, {"gj"}, 0.1),
+                        gap_junction_connection({2, "gj"}, {"gj"}, 0.1),
+                        gap_junction_connection({5, "gj"}, {"gj"}, 0.1)
                 };
             case 5 :
                 return {
-                        gap_junction_connection({2, 0}, 0, 0.1),
-                        gap_junction_connection({3, 0}, 0, 0.1),
-                        gap_junction_connection({0, 0}, 0, 0.1)
+                        gap_junction_connection({2, "gj"}, {"gj"}, 0.1),
+                        gap_junction_connection({3, "gj"}, {"gj"}, 0.1),
+                        gap_junction_connection({0, "gj"}, {"gj"}, 0.1)
                 };
             default :
                 return {};
@@ -224,12 +225,8 @@ TEST(fvm_lowered, matrix_init)
     builder.add_branch(0, 200, 1.0/2, 1.0/2, 10, "dend"); // 10 compartments
     cable_cell cell = builder.make_cell();
 
-    std::vector<target_handle> targets;
-    std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map probe_map;
-
     fvm_cell fvcell(context);
-    fvcell.initialize({0}, cable1d_recipe(cell), cell_to_intdom, targets, probe_map);
+    fvcell.initialize({0}, cable1d_recipe(cell));
 
     auto& J = fvcell.*private_matrix_ptr;
     auto& S = fvcell.*private_state_ptr;
@@ -268,23 +265,19 @@ TEST(fvm_lowered, target_handles) {
     };
 
     // (in increasing target order)
-    descriptions[0].decorations.place(mlocation{0, 0.7}, "expsyn");
-    descriptions[0].decorations.place(mlocation{0, 0.3}, "expsyn");
-    descriptions[1].decorations.place(mlocation{2, 0.2}, "exp2syn");
-    descriptions[1].decorations.place(mlocation{2, 0.8}, "expsyn");
+    descriptions[0].decorations.place(mlocation{0, 0.7}, "expsyn", "syn0");
+    descriptions[0].decorations.place(mlocation{0, 0.3}, "expsyn", "syn1");
+    descriptions[1].decorations.place(mlocation{2, 0.2}, "exp2syn", "syn2");
+    descriptions[1].decorations.place(mlocation{2, 0.8}, "expsyn", "syn3");
 
-    descriptions[1].decorations.place(mlocation{0, 0}, threshold_detector{3.3});
+    descriptions[1].decorations.place(mlocation{0, 0}, threshold_detector{3.3}, "detector");
 
     cable_cell cells[] = {descriptions[0], descriptions[1]};
 
     EXPECT_EQ(cells[0].morphology().num_branches(), 1u);
     EXPECT_EQ(cells[1].morphology().num_branches(), 3u);
 
-    std::vector<target_handle> targets;
-    std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map probe_map;
-
-    auto test_target_handles = [&](fvm_cell& cell) {
+    auto test_target_handles = [&](fvm_cell& cell, const std::vector<target_handle>& targets) {
         mechanism* expsyn = find_mechanism(cell, "expsyn");
         ASSERT_TRUE(expsyn);
         mechanism* exp2syn = find_mechanism(cell, "exp2syn");
@@ -313,12 +306,12 @@ TEST(fvm_lowered, target_handles) {
     };
 
     fvm_cell fvcell0(context);
-    fvcell0.initialize({0, 1}, cable1d_recipe(cells, true), cell_to_intdom, targets, probe_map);
-    test_target_handles(fvcell0);
+    auto fvm_info0 = fvcell0.initialize({0, 1}, cable1d_recipe(cells, true));
+    test_target_handles(fvcell0, fvm_info0.target_handles);
 
     fvm_cell fvcell1(context);
-    fvcell1.initialize({0, 1}, cable1d_recipe(cells, false), cell_to_intdom, targets, probe_map);
-    test_target_handles(fvcell1);
+    auto fvm_info1 = fvcell1.initialize({0, 1}, cable1d_recipe(cells, false));
+    test_target_handles(fvcell1, fvm_info1.target_handles);
 
 }
 
@@ -345,9 +338,9 @@ TEST(fvm_lowered, stimulus) {
     auto desc = make_cell_ball_and_stick(false);
 
     // At end of stick
-    desc.decorations.place(mlocation{0,1},   i_clamp::box(5., 80., 0.3));
+    desc.decorations.place(mlocation{0,1},   i_clamp::box(5., 80., 0.3), "clamp0");
     // On the soma CV, which is over the approximate interval: (cable 0 0 0.1)
-    desc.decorations.place(mlocation{0,0.05}, i_clamp::box(1., 2.,  0.1));
+    desc.decorations.place(mlocation{0,0.05}, i_clamp::box(1., 2.,  0.1), "clamp1");
 
     std::vector<cable_cell> cells{desc};
 
@@ -357,19 +350,14 @@ TEST(fvm_lowered, stimulus) {
     // The implementation of the stimulus is tested by creating a lowered cell, then
     // testing that the correct currents are injected at the correct control volumes
     // as during the stimulus windows.
-    std::vector<fvm_index_type> cell_to_intdom(cells.size(), 0);
-
     cable_cell_global_properties gprop;
     gprop.default_parameters = neuron_parameter_defaults;
 
     fvm_cv_discretization D = fvm_cv_discretize(cells, gprop.default_parameters, context);
     const auto& A = D.cv_area;
 
-    std::vector<target_handle> targets;
-    probe_association_map probe_map;
-
     fvm_cell fvcell(context);
-    fvcell.initialize({0}, cable1d_recipe(cells), cell_to_intdom, targets, probe_map);
+    fvcell.initialize({0}, cable1d_recipe(cells));
 
     auto& state = *(fvcell.*private_state_ptr).get();
     auto& J = state.current_density;
@@ -422,7 +410,7 @@ TEST(fvm_lowered, ac_stimulus) {
     const double max_time = 8; // (ms)
 
     // Envelope is linear ramp from 0 to max_time.
-    dec.place(mlocation{0, 0}, i_clamp({{0, 0}, {max_time, max_amplitude}, {max_time, 0}}, freq, phase));
+    dec.place(mlocation{0, 0}, i_clamp({{0, 0}, {max_time, max_amplitude}, {max_time, 0}}, freq, phase), "clamp");
     std::vector<cable_cell> cells = {cable_cell(tree, {}, dec)};
 
     cable_cell_global_properties gprop;
@@ -431,12 +419,8 @@ TEST(fvm_lowered, ac_stimulus) {
     fvm_cv_discretization D = fvm_cv_discretize(cells, gprop.default_parameters, context);
     const auto& A = D.cv_area;
 
-    std::vector<target_handle> targets;
-    probe_association_map probe_map;
-    std::vector<fvm_index_type> cell_to_intdom(cells.size(), 0);
-
     fvm_cell fvcell(context);
-    fvcell.initialize({0}, cable1d_recipe(cells), cell_to_intdom, targets, probe_map);
+    fvcell.initialize({0}, cable1d_recipe(cells));
 
     auto& state = *(fvcell.*private_state_ptr).get();
     auto& J = state.current_density;
@@ -516,13 +500,9 @@ TEST(fvm_lowered, derived_mechs) {
     {
         // Test initialization and global parameter values.
 
-        std::vector<target_handle> targets;
-        std::vector<fvm_index_type> cell_to_intdom;
-        probe_association_map probe_map;
-
         arb::execution_context context(resources);
         fvm_cell fvcell(context);
-        fvcell.initialize({0, 1, 2}, rec, cell_to_intdom, targets, probe_map);
+        fvcell.initialize({0, 1, 2}, rec);
 
         // Both mechanisms will have the same internal name, "test_kin1".
 
@@ -588,10 +568,6 @@ TEST(fvm_lowered, read_valence) {
         resources.num_threads = arbenv::thread_concurrency();
     }
 
-    std::vector<target_handle> targets;
-    std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map probe_map;
-
     {
         std::vector<cable_cell> cells(1);
 
@@ -603,7 +579,7 @@ TEST(fvm_lowered, read_valence) {
 
         arb::execution_context context(resources);
         fvm_cell fvcell(context);
-        fvcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
+        fvcell.initialize({0}, rec);
 
         // test_ca_read_valence initialization should write ca ion valence
         // to state variable 'record_zca':
@@ -631,7 +607,7 @@ TEST(fvm_lowered, read_valence) {
 
         arb::execution_context context(resources);
         fvm_cell fvcell(context);
-        fvcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
+        fvcell.initialize({0}, rec);
 
         auto cr_mech_ptr = dynamic_cast<multicore::mechanism*>(find_mechanism(fvcell, 0));
         auto cr_opt_record_z_ptr = util::value_by_key((cr_mech_ptr->*private_field_table_ptr)(), "record_z"s);
@@ -744,12 +720,8 @@ TEST(fvm_lowered, ionic_currents) {
     cable1d_recipe rec({cable_cell{c}});
     rec.catalogue() = make_unit_test_catalogue();
 
-    std::vector<target_handle> targets;
-    std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map probe_map;
-
     fvm_cell fvcell(context);
-    fvcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
+    fvcell.initialize({0}, rec);
 
     auto& state = *(fvcell.*private_state_ptr).get();
     auto& ion = state.ion_data.at("ca"s);
@@ -784,17 +756,13 @@ TEST(fvm_lowered, point_ionic_current) {
     double soma_area_m2 = 4*math::pi<double>*r*r*1e-12; // [m²]
 
     // Event weight is translated by point_ica_current into a current contribution in nA.
-    c.decorations.place(mlocation{0u, 0.5}, "point_ica_current");
+    c.decorations.place(mlocation{0u, 0.5}, "point_ica_current", "syn");
 
     cable1d_recipe rec({cable_cell{c}});
     rec.catalogue() = make_unit_test_catalogue();
 
-    std::vector<target_handle> targets;
-    std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map probe_map;
-
     fvm_cell fvcell(context);
-    fvcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
+    fvcell.initialize({0}, rec);
 
     // Only one target, corresponding to our point process on soma.
     double ica_nA = 12.3;
@@ -870,12 +838,8 @@ TEST(fvm_lowered, weighted_write_ion) {
     rec.catalogue() = make_unit_test_catalogue();
     rec.add_ion("ca", 2, con_int, con_ext, 0.0);
 
-    std::vector<target_handle> targets;
-    std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map probe_map;
-
     fvm_cell fvcell(context);
-    fvcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
+    fvcell.initialize({0}, rec);
 
     auto& state = *(fvcell.*private_state_ptr).get();
     auto& ion = state.ion_data.at("ca"s);
@@ -931,32 +895,37 @@ TEST(fvm_lowered, gj_coords_simple) {
 
     class gap_recipe: public recipe {
     public:
-        gap_recipe() {}
+        gap_recipe(std::vector<cable_cell> cells) : cells_(cells) {
+            gprop_.default_parameters = neuron_parameter_defaults;
+        }
 
         cell_size_type num_cells() const override { return n_; }
         cell_kind get_cell_kind(cell_gid_type) const override { return cell_kind::cable; }
         util::unique_any get_cell_description(cell_gid_type gid) const override {
-            return {};
+            return cells_[gid];
         }
         std::vector<arb::gap_junction_connection> gap_junctions_on(cell_gid_type gid) const override{
             std::vector<gap_junction_connection> conns;
-            conns.push_back(gap_junction_connection({(gid+1)%2, 0}, 0, 0.5));
+            conns.push_back(gap_junction_connection({(gid+1)%2, "gj", lid_selection_policy::assert_univalent}, {"gj", lid_selection_policy::assert_univalent}, 0.5));
             return conns;
         }
-
+        std::any get_global_properties(cell_kind) const override {
+            return gprop_;
+        }
     protected:
+        arb::cable_cell_global_properties gprop_;
+        std::vector<cable_cell> cells_;
         cell_size_type n_ = 2;
     };
 
     fvm_cell fvcell(context);
 
-    gap_recipe rec;
     std::vector<cable_cell> cells;
     {
         soma_cell_builder b(2.1);
         b.add_branch(0, 10, 0.3, 0.2, 5, "dend");
         auto c = b.make_cell();
-        c.decorations.place(b.location({1, 0.8}), gap_junction_site{});
+        c.decorations.place(b.location({1, 0.8}), gap_junction_site{}, "gj");
         cells.push_back(c);
     }
 
@@ -964,14 +933,17 @@ TEST(fvm_lowered, gj_coords_simple) {
         soma_cell_builder b(2.4);
         b.add_branch(0, 10, 0.3, 0.2, 2, "dend");
         auto c = b.make_cell();
-        c.decorations.place(b.location({1, 1}), gap_junction_site{});
+        c.decorations.place(b.location({1, 1}), gap_junction_site{}, "gj");
         cells.push_back(c);
     }
+    gap_recipe rec(cells);
 
     fvm_cv_discretization D = fvm_cv_discretize(cells, neuron_parameter_defaults, context);
 
     std::vector<cell_gid_type> gids = {0, 1};
-    auto GJ = fvcell.fvm_gap_junctions(cells, gids, rec, D);
+    auto fvm_info = fvcell.initialize(gids, rec);
+
+    auto GJ = fvcell.fvm_gap_junctions(cells, gids, fvm_info.gap_junction_data, rec, D);
 
     auto weight = [&](fvm_value_type g, fvm_index_type i){
         return g * 1e3 / D.cv_area[i];
@@ -996,41 +968,47 @@ TEST(fvm_lowered, gj_coords_complex) {
 
     class gap_recipe: public recipe {
     public:
-        gap_recipe() {}
+        gap_recipe(std::vector<cable_cell> cells) : cells_(cells) {
+            gprop_.default_parameters = neuron_parameter_defaults;
+        }
 
         cell_size_type num_cells() const override { return n_; }
         cell_kind get_cell_kind(cell_gid_type) const override { return cell_kind::cable; }
         util::unique_any get_cell_description(cell_gid_type gid) const override {
-            return {};
+            return cells_[gid];
         }
         std::vector<arb::gap_junction_connection> gap_junctions_on(cell_gid_type gid) const override{
             std::vector<gap_junction_connection> conns;
             switch (gid) {
             case 0:
                 return {
-                    gap_junction_connection({2, 0}, 1, 0.01),
-                    gap_junction_connection({1, 0}, 0, 0.03),
-                    gap_junction_connection({1, 1}, 0, 0.04)
+                    gap_junction_connection({2, "gj0", lid_selection_policy::assert_univalent}, {"gj1", lid_selection_policy::assert_univalent}, 0.01),
+                    gap_junction_connection({1, "gj0", lid_selection_policy::assert_univalent}, {"gj0", lid_selection_policy::assert_univalent}, 0.03),
+                    gap_junction_connection({1, "gj1", lid_selection_policy::assert_univalent}, {"gj0", lid_selection_policy::assert_univalent}, 0.04)
                 };
             case 1:
                 return {
-                    gap_junction_connection({0, 0}, 0, 0.03),
-                    gap_junction_connection({0, 0}, 1, 0.04),
-                    gap_junction_connection({2, 1}, 2, 0.02),
-                    gap_junction_connection({2, 2}, 3, 0.01)
+                    gap_junction_connection({0, "gj0", lid_selection_policy::assert_univalent}, {"gj0", lid_selection_policy::assert_univalent}, 0.03),
+                    gap_junction_connection({0, "gj0", lid_selection_policy::assert_univalent}, {"gj1", lid_selection_policy::assert_univalent}, 0.04),
+                    gap_junction_connection({2, "gj1", lid_selection_policy::assert_univalent}, {"gj2", lid_selection_policy::assert_univalent}, 0.02),
+                    gap_junction_connection({2, "gj2", lid_selection_policy::assert_univalent}, {"gj3", lid_selection_policy::assert_univalent}, 0.01)
                 };
             case 2:
                 return {
-                    gap_junction_connection({0, 1}, 0, 0.01),
-                    gap_junction_connection({1, 2}, 1, 0.02),
-                    gap_junction_connection({1, 3}, 2, 0.01)
+                    gap_junction_connection({0, "gj1", lid_selection_policy::assert_univalent}, {"gj0", lid_selection_policy::assert_univalent}, 0.01),
+                    gap_junction_connection({1, "gj2", lid_selection_policy::assert_univalent}, {"gj1", lid_selection_policy::assert_univalent}, 0.02),
+                    gap_junction_connection({1, "gj3", lid_selection_policy::assert_univalent}, {"gj2", lid_selection_policy::assert_univalent}, 0.01)
                 };
             default : return {};
             }
             return conns;
         }
-
+        std::any get_global_properties(cell_kind) const override {
+            return gprop_;
+        }
     protected:
+        arb::cable_cell_global_properties gprop_;
+        std::vector<cable_cell> cells_;
         cell_size_type n_ = 3;
     };
 
@@ -1041,8 +1019,8 @@ TEST(fvm_lowered, gj_coords_complex) {
     auto c0 = b0.make_cell();
     mlocation c0_gj[2] = {b0.location({1, 1}), b0.location({1, 0.5})};
 
-    c0.decorations.place(c0_gj[0], gap_junction_site{});
-    c0.decorations.place(c0_gj[1], gap_junction_site{});
+    c0.decorations.place(c0_gj[0], gap_junction_site{}, "gj0");
+    c0.decorations.place(c0_gj[1], gap_junction_site{}, "gj1");
 
     soma_cell_builder b1(1.4);
     b1.add_branch(0, 12, 0.3, 0.5, 6, "dend");
@@ -1052,10 +1030,10 @@ TEST(fvm_lowered, gj_coords_complex) {
     auto c1 = b1.make_cell();
     mlocation c1_gj[4] = {b1.location({2, 1}), b1.location({1, 1}), b1.location({1, 0.45}), b1.location({1, 0.1})};
 
-    c1.decorations.place(c1_gj[0], gap_junction_site{});
-    c1.decorations.place(c1_gj[1], gap_junction_site{});
-    c1.decorations.place(c1_gj[2], gap_junction_site{});
-    c1.decorations.place(c1_gj[3], gap_junction_site{});
+    c1.decorations.place(c1_gj[0], gap_junction_site{}, "gj0");
+    c1.decorations.place(c1_gj[1], gap_junction_site{}, "gj1");
+    c1.decorations.place(c1_gj[2], gap_junction_site{}, "gj2");
+    c1.decorations.place(c1_gj[3], gap_junction_site{}, "gj3");
 
 
     soma_cell_builder b2(2.9);
@@ -1068,19 +1046,18 @@ TEST(fvm_lowered, gj_coords_complex) {
     auto c2 = b2.make_cell();
     mlocation c2_gj[3] = {b2.location({1, 0.5}), b2.location({4, 1}), b2.location({2, 1})};
 
-    c2.decorations.place(c2_gj[0], gap_junction_site{});
-    c2.decorations.place(c2_gj[1], gap_junction_site{});
-    c2.decorations.place(c2_gj[2], gap_junction_site{});
+    c2.decorations.place(c2_gj[0], gap_junction_site{}, "gj0");
+    c2.decorations.place(c2_gj[1], gap_junction_site{}, "gj1");
+    c2.decorations.place(c2_gj[2], gap_junction_site{}, "gj2");
 
     std::vector<cable_cell> cells{c0, c1, c2};
-
-    std::vector<fvm_index_type> cell_to_intdom;
-
     std::vector<cell_gid_type> gids = {0, 1, 2};
 
-    gap_recipe rec;
+    gap_recipe rec(cells);
     fvm_cell fvcell(context);
-    fvcell.fvm_intdom(rec, gids, cell_to_intdom);
+
+    auto fvm_info = fvcell.initialize(gids, rec);
+    fvcell.fvm_intdom(rec, gids, fvm_info.cell_to_intdom);
     fvm_cv_discretization D = fvm_cv_discretize(cells, neuron_parameter_defaults, context);
 
     using namespace cv_prefer;
@@ -1093,7 +1070,7 @@ TEST(fvm_lowered, gj_coords_complex) {
     int c2_gj_cv[3];
     for (int i = 0; i<3; ++i) c2_gj_cv[i] = D.geometry.location_cv(2, c2_gj[i], cv_nonempty);
 
-    std::vector<fvm_gap_junction> GJ = fvcell.fvm_gap_junctions(cells, gids, rec, D);
+    std::vector<fvm_gap_junction> GJ = fvcell.fvm_gap_junctions(cells, gids, fvm_info.gap_junction_data, rec, D);
     EXPECT_EQ(10u, GJ.size());
 
     auto weight = [&](fvm_value_type g, fvm_index_type i){
@@ -1140,12 +1117,16 @@ TEST(fvm_lowered, cell_group_gj) {
 
     class gap_recipe: public recipe {
     public:
-        gap_recipe() {}
+        gap_recipe(const std::vector<cable_cell>& cg0, const std::vector<cable_cell>& cg1) {
+            cells_ = cg0;
+            cells_.insert(cells_.end(), cg1.begin(), cg1.end());
+            gprop_.default_parameters = neuron_parameter_defaults;
+        }
 
         cell_size_type num_cells() const override { return n_; }
         cell_kind get_cell_kind(cell_gid_type) const override { return cell_kind::cable; }
         util::unique_any get_cell_description(cell_gid_type gid) const override {
-            return {};
+            return cells_[gid];
         }
         std::vector<arb::gap_junction_connection> gap_junctions_on(cell_gid_type gid) const override{
             std::vector<gap_junction_connection> conns;
@@ -1153,17 +1134,23 @@ TEST(fvm_lowered, cell_group_gj) {
                 // connect 5 of the first 10 cells in a ring; connect 5 of the second 10 cells in a ring
                 auto next_cell = gid == 8 ? 0 : (gid == 18 ? 10 : gid + 2);
                 auto prev_cell = gid == 0 ? 8 : (gid == 10 ? 18 : gid - 2);
-                conns.push_back(gap_junction_connection({next_cell, 0}, 0, 0.03));
-                conns.push_back(gap_junction_connection({prev_cell, 0}, 0, 0.03));
+                conns.push_back(gap_junction_connection({next_cell, "gj", lid_selection_policy::assert_univalent},
+                                                             {"gj", lid_selection_policy::assert_univalent}, 0.03));
+                conns.push_back(gap_junction_connection({prev_cell, "gj", lid_selection_policy::assert_univalent},
+                                                             {"gj", lid_selection_policy::assert_univalent}, 0.03));
             }
             return conns;
         }
+        std::any get_global_properties(cell_kind) const override {
+            return gprop_;
+        }
 
     protected:
+        arb::cable_cell_global_properties gprop_;
+        std::vector<cable_cell> cells_;
         cell_size_type n_ = 20;
     };
 
-    gap_recipe rec;
     std::vector<cable_cell> cell_group0;
     std::vector<cable_cell> cell_group1;
 
@@ -1171,7 +1158,7 @@ TEST(fvm_lowered, cell_group_gj) {
     for (unsigned i = 0; i < 20; i++) {
         cable_cell_description c = soma_cell_builder(2.1).make_cell();
         if (i % 2 == 0) {
-            c.decorations.place(mlocation{0, 1}, gap_junction_site{});
+            c.decorations.place(mlocation{0, 1}, gap_junction_site{}, "gj");
         }
         if (i < 10) {
             cell_group0.push_back(c);
@@ -1184,18 +1171,22 @@ TEST(fvm_lowered, cell_group_gj) {
     std::vector<cell_gid_type> gids_cg0 = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
     std::vector<cell_gid_type> gids_cg1 = {10, 11, 12, 13, 14, 15, 16, 17, 18, 19};
 
-    std::vector<fvm_index_type> cell_to_intdom0, cell_to_intdom1;
+    gap_recipe rec(cell_group0, cell_group1);
 
-    fvm_cell fvcell(context);
+    fvm_cell fvcell0(context);
+    fvm_cell fvcell1(context);
+
+    auto fvm_info_0 = fvcell0.initialize(gids_cg0, rec);
+    auto fvm_info_1 = fvcell1.initialize(gids_cg1, rec);
 
-    auto num_dom0 = fvcell.fvm_intdom(rec, gids_cg0, cell_to_intdom0);
-    auto num_dom1 = fvcell.fvm_intdom(rec, gids_cg1, cell_to_intdom1);
+    auto num_dom0 = fvcell0.fvm_intdom(rec, gids_cg0, fvm_info_0.cell_to_intdom);
+    auto num_dom1 = fvcell1.fvm_intdom(rec, gids_cg1, fvm_info_1.cell_to_intdom);
 
     fvm_cv_discretization D0 = fvm_cv_discretize(cell_group0, neuron_parameter_defaults, context);
     fvm_cv_discretization D1 = fvm_cv_discretize(cell_group1, neuron_parameter_defaults, context);
 
-    auto GJ0 = fvcell.fvm_gap_junctions(cell_group0, gids_cg0, rec, D0);
-    auto GJ1 = fvcell.fvm_gap_junctions(cell_group1, gids_cg1, rec, D1);
+    auto GJ0 = fvcell0.fvm_gap_junctions(cell_group0, gids_cg0, fvm_info_0.gap_junction_data, rec, D0);
+    auto GJ1 = fvcell1.fvm_gap_junctions(cell_group1, gids_cg1, fvm_info_1.gap_junction_data, rec, D1);
 
     EXPECT_EQ(10u, GJ0.size());
     EXPECT_EQ(10u, GJ1.size());
@@ -1211,8 +1202,8 @@ TEST(fvm_lowered, cell_group_gj) {
     EXPECT_EQ(6u, num_dom0);
     EXPECT_EQ(6u, num_dom1);
 
-    EXPECT_EQ(expected_doms, cell_to_intdom0);
-    EXPECT_EQ(expected_doms, cell_to_intdom1);
+    EXPECT_EQ(expected_doms, fvm_info_0.cell_to_intdom);
+    EXPECT_EQ(expected_doms, fvm_info_1.cell_to_intdom);
 
 }
 
@@ -1293,27 +1284,17 @@ TEST(fvm_lowered, post_events_shared_state) {
             auto ndetectors = detectors_per_cell_[gid];
             auto offset = 1.0 / ndetectors;
             for (unsigned i = 0; i < ndetectors; ++i) {
-                decor.place(arb::mlocation{0, offset * i}, arb::threshold_detector{10});
+                decor.place(arb::mlocation{0, offset * i}, arb::threshold_detector{10}, "detector"+std::to_string(i));
             }
-            decor.place(arb::mlocation{0, 0.5}, synapse_);
+            decor.place(arb::mlocation{0, 0.5}, synapse_, "syanpse");
 
-            return arb::cable_cell(arb::morphology(tree), {}, decor);;
+            return arb::cable_cell(arb::morphology(tree), {}, decor);
         }
 
         cell_kind get_cell_kind(cell_gid_type gid) const override {
             return cell_kind::cable;
         }
 
-        // Each cell has one spike detector (at the soma).
-        cell_size_type num_sources(cell_gid_type gid) const override {
-            return detectors_per_cell_[gid];
-        }
-
-        // The cell has one target synapse, which will be connected to cell gid-1.
-        cell_size_type num_targets(cell_gid_type gid) const override {
-            return 1;
-        }
-
         std::any get_global_properties(arb::cell_kind) const override {
             arb::cable_cell_global_properties gprop;
             gprop.default_parameters = arb::neuron_parameter_defaults;
@@ -1329,10 +1310,7 @@ TEST(fvm_lowered, post_events_shared_state) {
         mechanism_catalogue cat_;
     };
 
-    std::vector<target_handle> targets;
-    probe_association_map probe_map;
-
-    std::vector<unsigned> gids                 = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
+    std::vector<unsigned> gids = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
     const unsigned ncell = gids.size();
     const unsigned cv_per_cell = 10;
 
@@ -1344,10 +1322,9 @@ TEST(fvm_lowered, post_events_shared_state) {
 
     for (const auto& detectors_per_cell: detectors_per_cell_vec) {
         detector_recipe rec(cv_per_cell, detectors_per_cell, "post_events_syn");
-        std::vector<fvm_index_type> cell_to_intdom;
 
         fvm_cell fvcell(context);
-        fvcell.initialize(gids, rec, cell_to_intdom, targets, probe_map);
+        fvcell.initialize(gids, rec);
 
         auto& S = fvcell.*private_state_ptr;
 
@@ -1366,10 +1343,9 @@ TEST(fvm_lowered, post_events_shared_state) {
     }
     for (const auto& detectors_per_cell: detectors_per_cell_vec) {
         detector_recipe rec(cv_per_cell, detectors_per_cell, "expsyn");
-        std::vector<fvm_index_type> cell_to_intdom;
 
         fvm_cell fvcell(context);
-        fvcell.initialize(gids, rec, cell_to_intdom, targets, probe_map);
+        fvcell.initialize(gids, rec);
 
         auto& S = fvcell.*private_state_ptr;
 
@@ -1377,5 +1353,194 @@ TEST(fvm_lowered, post_events_shared_state) {
         EXPECT_EQ(0u, S->src_to_spike.size());
         EXPECT_EQ(0u, S->time_since_spike.size());
     }
-
 }
+
+TEST(fvm_lowered, label_data) {
+    arb::proc_allocation resources;
+    if (auto nt = arbenv::get_env_num_threads()) {
+        resources.num_threads = nt;
+    } else {
+        resources.num_threads = arbenv::thread_concurrency();
+    }
+    arb::execution_context context(resources);
+
+    class decorated_recipe: public arb::recipe {
+    public:
+        decorated_recipe(unsigned ncell): ncell_(ncell) {
+            // Cells will have one of 2 decorations:
+            //   (1)  1 synapse label with 4 locations; 1 synapse label with 1 location
+            //        1 detector label with 1 location
+            //        0 gap junctions
+            //        if (duplicate) add a duplicate label, check fvm initialize throws
+            //   (2)  0 synapse labels
+            //        1 detector label with 3 locations; 1 detector label with 2 locations
+            //        1 gap-junction label with 2 locations; 1 gap-junction label with 1 location
+            using arb::reg::all;
+            using arb::ls::uniform;
+            arb::segment_tree tree;
+            tree.append(arb::mnpos, {0, 0, 0.0, 1.0}, {0, 0, 200, 1.0}, 1);
+            {
+                arb::decor decor;
+                decor.set_default(arb::cv_policy_fixed_per_branch(10));
+                decor.place(uniform(all(), 0, 3, 42), "expsyn", "4_synapses");
+                decor.place(uniform(all(), 4, 4, 42), "expsyn", "1_synapse");
+                decor.place(uniform(all(), 5, 5, 42), arb::threshold_detector{10}, "1_detector");
+
+                cells_.push_back(arb::cable_cell(arb::morphology(tree), {}, decor));
+            }
+            {
+                arb::decor decor;
+                decor.set_default(arb::cv_policy_fixed_per_branch(10));
+                decor.place(uniform(all(), 0, 2, 24), arb::threshold_detector{10}, "3_detectors");
+                decor.place(uniform(all(), 3, 4, 24), arb::threshold_detector{10}, "2_detectors");
+                decor.place(uniform(all(), 5, 6, 24), arb::gap_junction_site(), "2_gap_junctions");
+                decor.place(uniform(all(), 7, 7, 24), arb::gap_junction_site(), "1_gap_junction");
+
+                cells_.push_back(arb::cable_cell(arb::morphology(tree), {}, decor));
+            }
+        }
+
+        cell_size_type num_cells() const override {
+            return ncell_;
+        }
+
+        arb::util::unique_any get_cell_description(cell_gid_type gid) const override {
+            if (gid %3 == 0) {
+                return cells_[0];
+            }
+            return cells_[1];
+        }
+
+        cell_kind get_cell_kind(cell_gid_type gid) const override {
+            return cell_kind::cable;
+        }
+
+        std::any get_global_properties(arb::cell_kind) const override {
+            arb::cable_cell_global_properties gprop;
+            gprop.default_parameters = arb::neuron_parameter_defaults;
+            return gprop;
+        }
+
+    private:
+        unsigned ncell_;
+        std::vector<cable_cell> cells_;
+    };
+
+    std::vector<unsigned> gids = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
+    const unsigned ncell = gids.size();
+
+    // Check correct synapse, deterctor and gj data
+    decorated_recipe rec(ncell);
+    std::vector<fvm_index_type> cell_to_intdom;
+    std::vector<target_handle> targets;
+    probe_association_map probe_map;
+
+    fvm_cell fvcell(context);
+    auto fvm_info = fvcell.initialize(gids, rec);
+
+    for (auto gid: gids) {
+        if (gid%3 == 0) {
+            EXPECT_EQ(5u, fvm_info.num_targets[gid]);
+            EXPECT_EQ(1u, fvm_info.num_sources[gid]);
+        }
+        else {
+            EXPECT_EQ(0u, fvm_info.num_targets[gid]);
+            EXPECT_EQ(5u, fvm_info.num_sources[gid]);
+        }
+    }
+
+    // synapses
+    {
+        auto clg = cell_labels_and_gids(fvm_info.target_data, gids);
+        std::vector<cell_size_type> expected_sizes = {2, 0, 0, 2, 0, 0, 2, 0, 0, 2};
+        std::vector<std::pair<cell_tag_type, lid_range>> expected_labeled_ranges, actual_labeled_ranges;
+        expected_labeled_ranges = {
+            {"1_synapse",  {4, 5}}, {"4_synapses", {0, 4}},
+            {"1_synapse",  {4, 5}}, {"4_synapses", {0, 4}},
+            {"1_synapse",  {4, 5}}, {"4_synapses", {0, 4}},
+            {"1_synapse",  {4, 5}}, {"4_synapses", {0, 4}}
+        };
+
+        EXPECT_EQ(clg.gids, gids);
+        EXPECT_EQ(clg.label_range.sizes(), expected_sizes);
+        EXPECT_EQ(clg.label_range.labels().size(), expected_labeled_ranges.size());
+        EXPECT_EQ(clg.label_range.ranges().size(), expected_labeled_ranges.size());
+
+        for (unsigned i = 0; i < expected_labeled_ranges.size(); ++i) {
+            actual_labeled_ranges.push_back({clg.label_range.labels()[i], clg.label_range.ranges()[i]});
+        }
+
+        std::vector<cell_size_type> size_partition;
+        auto part = util::make_partition(size_partition, expected_sizes);
+        for (const auto& r: part) {
+            util::sort(util::subrange_view(actual_labeled_ranges, r));
+        }
+        EXPECT_EQ(actual_labeled_ranges, expected_labeled_ranges);
+    }
+
+    // detectors
+    {
+        auto clg = cell_labels_and_gids(fvm_info.source_data, gids);
+        std::vector<cell_size_type> expected_sizes = {1, 2, 2, 1, 2, 2, 1, 2, 2, 1};
+        std::vector<std::pair<cell_tag_type, lid_range>> expected_labeled_ranges, actual_labeled_ranges;
+        expected_labeled_ranges = {
+            {"1_detector",  {0, 1}},
+            {"2_detectors", {3, 5}}, {"3_detectors", {0, 3}},
+            {"2_detectors", {3, 5}}, {"3_detectors", {0, 3}},
+            {"1_detector",  {0, 1}},
+            {"2_detectors", {3, 5}}, {"3_detectors", {0, 3}},
+            {"2_detectors", {3, 5}}, {"3_detectors", {0, 3}},
+            {"1_detector",  {0, 1}},
+            {"2_detectors", {3, 5}}, {"3_detectors", {0, 3}},
+            {"2_detectors", {3, 5}}, {"3_detectors", {0, 3}},
+            {"1_detector",  {0, 1}}
+        };
+
+        EXPECT_EQ(clg.gids, gids);
+        EXPECT_EQ(clg.label_range.sizes(), expected_sizes);
+        EXPECT_EQ(clg.label_range.labels().size(), expected_labeled_ranges.size());
+        EXPECT_EQ(clg.label_range.ranges().size(), expected_labeled_ranges.size());
+
+        for (unsigned i = 0; i < expected_labeled_ranges.size(); ++i) {
+            actual_labeled_ranges.push_back({clg.label_range.labels()[i], clg.label_range.ranges()[i]});
+        }
+
+        std::vector<cell_size_type> size_partition;
+        auto part = util::make_partition(size_partition, expected_sizes);
+        for (const auto& r: part) {
+            util::sort(util::subrange_view(actual_labeled_ranges, r));
+        }
+        EXPECT_EQ(actual_labeled_ranges, expected_labeled_ranges);
+    }
+
+    // gap_junctions
+    {
+        auto clg = cell_labels_and_gids(fvm_info.gap_junction_data, gids);
+        std::vector<cell_size_type> expected_sizes = {0, 2, 2, 0, 2, 2, 0, 2, 2, 0};
+        std::vector<std::pair<cell_tag_type, lid_range>> expected_labeled_ranges, actual_labeled_ranges;
+        expected_labeled_ranges = {
+            {"1_gap_junction",  {2, 3}}, {"2_gap_junctions", {0, 2}},
+            {"1_gap_junction",  {2, 3}}, {"2_gap_junctions", {0, 2}},
+            {"1_gap_junction",  {2, 3}}, {"2_gap_junctions", {0, 2}},
+            {"1_gap_junction",  {2, 3}}, {"2_gap_junctions", {0, 2}},
+            {"1_gap_junction",  {2, 3}}, {"2_gap_junctions", {0, 2}},
+            {"1_gap_junction",  {2, 3}}, {"2_gap_junctions", {0, 2}},
+        };
+
+        EXPECT_EQ(clg.gids, gids);
+        EXPECT_EQ(clg.label_range.sizes(), expected_sizes);
+        EXPECT_EQ(clg.label_range.labels().size(), expected_labeled_ranges.size());
+        EXPECT_EQ(clg.label_range.ranges().size(), expected_labeled_ranges.size());
+
+        for (unsigned i = 0; i < expected_labeled_ranges.size(); ++i) {
+            actual_labeled_ranges.push_back({clg.label_range.labels()[i], clg.label_range.ranges()[i]});
+        }
+
+        std::vector<cell_size_type> size_partition;
+        auto part = util::make_partition(size_partition, expected_sizes);
+        for (const auto& r: part) {
+            util::sort(util::subrange_view(actual_labeled_ranges, r));
+        }
+        EXPECT_EQ(actual_labeled_ranges, expected_labeled_ranges);
+    }
+}
\ No newline at end of file
diff --git a/test/unit/test_label_resolution.cpp b/test/unit/test_label_resolution.cpp
new file mode 100644
index 00000000..c5ad7582
--- /dev/null
+++ b/test/unit/test_label_resolution.cpp
@@ -0,0 +1,318 @@
+#include "../gtest.h"
+
+#include <vector>
+
+#include <arbor/arbexcept.hpp>
+
+#include "label_resolution.hpp"
+
+using namespace arb;
+
+TEST(test_cell_label_range, build) {
+    using ivec = std::vector<cell_size_type>;
+    using svec = std::vector<cell_tag_type>;
+    using lvec = std::vector<lid_range>;
+
+    // Test add_cell and add_label
+    auto b0 = cell_label_range();
+    EXPECT_THROW(b0.add_label("l0", {0u, 1u}), arb::arbor_internal_error);
+    EXPECT_TRUE(b0.sizes().empty());
+    EXPECT_TRUE(b0.labels().empty());
+    EXPECT_TRUE(b0.ranges().empty());
+    EXPECT_TRUE(b0.check_invariant());
+
+    auto b1 = cell_label_range();
+    b1.add_cell();
+    b1.add_cell();
+    b1.add_cell();
+    EXPECT_EQ((ivec{0u, 0u, 0u}), b1.sizes());
+    EXPECT_TRUE(b1.labels().empty());
+    EXPECT_TRUE(b1.ranges().empty());
+    EXPECT_TRUE(b1.check_invariant());
+
+    auto b2 = cell_label_range();
+    b2.add_cell();
+    b2.add_label("l0", {0u, 1u});
+    b2.add_label("l0", {3u, 13u});
+    b2.add_label("l1", {0u, 5u});
+    b2.add_cell();
+    b2.add_cell();
+    b2.add_label("l2", {6u, 8u});
+    b2.add_label("l3", {1u, 0u});
+    b2.add_label("l4", {7u, 2u});
+    b2.add_label("l4", {7u, 2u});
+    b2.add_label("l2", {7u, 2u});
+    EXPECT_EQ((ivec{3u, 0u, 5u}), b2.sizes());
+    EXPECT_EQ((svec{"l0", "l0", "l1", "l2", "l3", "l4", "l4", "l2"}), b2.labels());
+    EXPECT_EQ((lvec{{0u, 1u}, {3u, 13u}, {0u, 5u}, {6u, 8u}, {1u, 0u}, {7u, 2u}, {7u, 2u}, {7u, 2u}}), b2.ranges());
+    EXPECT_TRUE(b2.check_invariant());
+
+    auto b3 = cell_label_range();
+    b3.add_cell();
+    b3.add_label("r0", {0u, 9u});
+    b3.add_label("r1", {10u, 10u});
+    b3.add_cell();
+    EXPECT_EQ((ivec{2u, 0u}), b3.sizes());
+    EXPECT_EQ((svec{"r0", "r1"}), b3.labels());
+    EXPECT_EQ((lvec{{0u, 9u}, {10u, 10u}}), b3.ranges());
+    EXPECT_TRUE(b3.check_invariant());
+
+    // Test appending
+    b0.append(b1);
+    EXPECT_EQ((ivec{0u, 0u, 0u}), b0.sizes());
+    EXPECT_TRUE(b0.labels().empty());
+    EXPECT_TRUE(b0.ranges().empty());
+    EXPECT_TRUE(b0.check_invariant());
+
+    b0.append(b2);
+    EXPECT_EQ((ivec{0u, 0u, 0u, 3u, 0u, 5u}), b0.sizes());
+    EXPECT_EQ((svec{"l0", "l0", "l1", "l2", "l3", "l4", "l4", "l2"}), b0.labels());
+    EXPECT_EQ((lvec{{0u, 1u}, {3u, 13u}, {0u, 5u}, {6u, 8u}, {1u, 0u}, {7u, 2u}, {7u, 2u}, {7u, 2u}}), b0.ranges());
+    EXPECT_TRUE(b0.check_invariant());
+
+    b0.append(b3);
+    EXPECT_EQ((ivec{0u, 0u, 0u, 3u, 0u, 5u, 2u, 0u}), b0.sizes());
+    EXPECT_EQ((svec{"l0", "l0", "l1", "l2", "l3", "l4", "l4", "l2", "r0", "r1"}), b0.labels());
+    EXPECT_EQ((lvec{{0u, 1u}, {3u, 13u}, {0u, 5u}, {6u, 8u}, {1u, 0u}, {7u, 2u}, {7u, 2u}, {7u, 2u}, {0u, 9u}, {10u, 10u}}), b0.ranges());
+    EXPECT_TRUE(b0.check_invariant());
+}
+
+TEST(test_cell_labels_and_gids, build) {
+    // Test add_cell and add_label
+    auto b0 = cell_label_range();
+    EXPECT_THROW(cell_labels_and_gids(b0, {1u}), arb::arbor_internal_error);
+    auto c0 = cell_labels_and_gids(b0, {});
+
+    auto b1 = cell_label_range();
+    b1.add_cell();
+    auto c1 = cell_labels_and_gids(b1, {0});
+    c0.append(c1);
+    EXPECT_TRUE(c0.check_invariant());
+
+    auto b2 = cell_label_range();
+    b2.add_cell();
+    b2.add_cell();
+    b2.add_cell();
+    auto c2 = cell_labels_and_gids(b2, {4, 6, 0});
+    c0.append(c2);
+    EXPECT_TRUE(c0.check_invariant());
+
+    auto b3 = cell_label_range();
+    b3.add_cell();
+    b3.add_cell();
+    auto c3 = cell_labels_and_gids(b3, {1, 1});
+    c0.append(c3);
+    EXPECT_TRUE(c0.check_invariant());
+
+    c0.gids = {};
+    EXPECT_FALSE(c0.check_invariant());
+
+    c0.gids = {0, 4, 6, 0, 1, 1};
+    EXPECT_TRUE(c0.check_invariant());
+
+    c0.label_range = {};
+    EXPECT_FALSE(c0.check_invariant());
+}
+
+TEST(test_label_resolution, policies) {
+    using vec = std::vector<cell_lid_type>;
+    {
+        std::vector<cell_gid_type> gids = {0, 1, 2, 3, 4};
+        std::vector<cell_size_type> sizes = {1, 0, 1, 2, 3};
+        std::vector<cell_tag_type> labels = {"l0_0", "l2_0", "l3_0", "l3_1", "l4_0", "l4_1", "l4_1"};
+        std::vector<lid_range> ranges = {{0, 1}, {0, 3}, {1, 2}, {4, 10}, {5, 6}, {8, 11}, {12, 14}};
+
+        auto res_map = label_resolution_map(cell_labels_and_gids({sizes, labels, ranges}, gids));
+
+        // Check resolver map correctness
+        // gid 0
+        EXPECT_EQ(1u, res_map.count(0, "l0_0"));
+        auto rset = res_map.at(0, "l0_0");
+        EXPECT_EQ(1u, rset.ranges.size());
+        EXPECT_EQ(lid_range(0, 1), rset.ranges.front());
+        EXPECT_EQ((vec{0u, 1u}), rset.ranges_partition);
+
+        // gid 1
+        EXPECT_EQ(0u, res_map.count(1, "l0_0"));
+
+        // gid 2
+        EXPECT_EQ(1u, res_map.count(2, "l2_0"));
+        rset = res_map.at(2, "l2_0");
+        EXPECT_EQ(1u, rset.ranges.size());
+        EXPECT_EQ(lid_range(0, 3), rset.ranges.front());
+        EXPECT_EQ((vec{0u, 3u}), rset.ranges_partition);
+
+        // gid 3
+        EXPECT_EQ(1u, res_map.count(3, "l3_0"));
+        rset = res_map.at(3, "l3_0");
+        EXPECT_EQ(1u, rset.ranges.size());
+        EXPECT_EQ(lid_range(1, 2), rset.ranges.front());
+        EXPECT_EQ((vec{0u, 1u}), rset.ranges_partition);
+
+        EXPECT_EQ(1u, res_map.count(3, "l3_1"));
+        rset = res_map.at(3, "l3_1");
+        EXPECT_EQ(1u, rset.ranges.size());
+        EXPECT_EQ(lid_range(4, 10), rset.ranges.front());
+        EXPECT_EQ((vec{0u, 6u}), rset.ranges_partition);
+
+        // gid 4
+        EXPECT_EQ(1u, res_map.count(4, "l4_0"));
+        rset = res_map.at(4, "l4_0");
+        EXPECT_EQ(1u, rset.ranges.size());
+        EXPECT_EQ(lid_range(5, 6), rset.ranges.front());
+        EXPECT_EQ((vec{0u, 1u}), rset.ranges_partition);
+
+        EXPECT_EQ(1u, res_map.count(4, "l4_1"));
+        rset = res_map.at(4, "l4_1");
+        EXPECT_EQ(2u, rset.ranges.size());
+        EXPECT_EQ(lid_range(8, 11), rset.ranges.at(0));
+        EXPECT_EQ(lid_range(12, 14), rset.ranges.at(1));
+        EXPECT_EQ((vec{0u, 3u, 5u}), rset.ranges_partition);
+
+        // Check lid resolution
+        auto lid_resolver = arb::resolver(&res_map);
+        // Non-existent gid or label
+        EXPECT_THROW(lid_resolver.resolve({9, "l9_0"}), arb::bad_connection_label);
+        EXPECT_THROW(lid_resolver.resolve({0, "l1_0"}), arb::bad_connection_label);
+        EXPECT_THROW(lid_resolver.resolve({1, "l1_0"}), arb::bad_connection_label);
+
+        // Univalent
+        EXPECT_EQ(0u, lid_resolver.resolve({0, "l0_0", lid_selection_policy::assert_univalent}));
+        EXPECT_EQ(5u, lid_resolver.resolve({4, "l4_0", lid_selection_policy::assert_univalent}));
+        EXPECT_EQ(5u, lid_resolver.resolve({4, "l4_0", lid_selection_policy::assert_univalent})); // Repeated request
+
+        EXPECT_THROW(lid_resolver.resolve({2, "l2_0", lid_selection_policy::assert_univalent}), arb::bad_connection_label);
+        EXPECT_THROW(lid_resolver.resolve({3, "l3_1", lid_selection_policy::assert_univalent}), arb::bad_connection_label);
+        EXPECT_THROW(lid_resolver.resolve({4, "l4_1", lid_selection_policy::assert_univalent}), arb::bad_connection_label);
+
+        // Round robin
+        EXPECT_EQ(0u, lid_resolver.resolve({0, "l0_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(0u, lid_resolver.resolve({0, "l0_0", lid_selection_policy::round_robin}));
+
+        EXPECT_EQ(0u, lid_resolver.resolve({2, "l2_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(1u, lid_resolver.resolve({2, "l2_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(2u, lid_resolver.resolve({2, "l2_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(0u, lid_resolver.resolve({2, "l2_0", lid_selection_policy::round_robin}));
+
+        EXPECT_EQ(5u, lid_resolver.resolve({4, "l4_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(5u, lid_resolver.resolve({4, "l4_0", lid_selection_policy::round_robin}));
+
+        EXPECT_EQ(8u,  lid_resolver.resolve({4, "l4_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(9u,  lid_resolver.resolve({4, "l4_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(10u, lid_resolver.resolve({4, "l4_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(12u, lid_resolver.resolve({4, "l4_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(13u, lid_resolver.resolve({4, "l4_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(8u,  lid_resolver.resolve({4, "l4_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(9u,  lid_resolver.resolve({4, "l4_1", lid_selection_policy::round_robin}));
+
+        EXPECT_EQ(4u, lid_resolver.resolve({3, "l3_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(5u, lid_resolver.resolve({3, "l3_1", lid_selection_policy::round_robin}));
+
+        // Reset
+        lid_resolver = arb::resolver(&res_map);
+        EXPECT_EQ(4u, lid_resolver.resolve({3, "l3_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(5u, lid_resolver.resolve({3, "l3_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(6u, lid_resolver.resolve({3, "l3_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(7u, lid_resolver.resolve({3, "l3_1", lid_selection_policy::round_robin}));
+
+        // Test exception
+        gids.push_back(5);
+        sizes.push_back(1);
+        labels.emplace_back("l5_0");
+        ranges.emplace_back(0, 0);
+        res_map = label_resolution_map(cell_labels_and_gids({sizes, labels, ranges}, gids));
+
+        lid_resolver = arb::resolver(&res_map);
+        EXPECT_THROW(lid_resolver.resolve({5, "l5_0"}), arb::bad_connection_label);
+
+        ranges.back() = {4, 2};
+        EXPECT_THROW(label_resolution_map(cell_labels_and_gids({sizes, labels, ranges}, gids)), arb::arbor_internal_error);
+
+    }
+    // multivalent labels
+    {
+        std::vector<cell_gid_type> gids = {0, 1, 2};
+        std::vector<cell_size_type> sizes = {3, 0, 6};
+        std::vector<cell_tag_type> labels = {"l0_0", "l0_1", "l0_0", "l2_0", "l2_0", "l2_2", "l2_1", "l2_1", "l2_2"};
+        std::vector<lid_range> ranges = {{0, 1}, {0, 3}, {1, 3}, {4, 6}, {9, 12}, {5, 5}, {1, 2}, {0, 1}, {22, 23}};
+
+        auto res_map = label_resolution_map(cell_labels_and_gids({sizes, labels, ranges}, gids));
+
+        // Check resolver map correctness
+        // gid 0
+        EXPECT_EQ(1u, res_map.count(0, "l0_1"));
+        auto rset = res_map.at(0, "l0_1");
+        EXPECT_EQ(1u, rset.ranges.size());
+        EXPECT_EQ(lid_range(0, 3), rset.ranges.front());
+        EXPECT_EQ((vec{0u, 3u}), rset.ranges_partition);
+
+        EXPECT_EQ(1u, res_map.count(0, "l0_0"));
+        rset = res_map.at(0, "l0_0");
+        EXPECT_EQ(2u, rset.ranges.size());
+        EXPECT_EQ(lid_range(0, 1), rset.ranges.at(0));
+        EXPECT_EQ(lid_range(1, 3), rset.ranges.at(1));
+        EXPECT_EQ((vec{0u, 1u, 3u}), rset.ranges_partition);
+
+        // gid 1
+        EXPECT_EQ(0u, res_map.count(1, "l0_1"));
+
+        // gid 2
+        EXPECT_EQ(1u, res_map.count(2, "l2_0"));
+        rset = res_map.at(2, "l2_0");
+        EXPECT_EQ(2u, rset.ranges.size());
+        EXPECT_EQ(lid_range(4, 6), rset.ranges.at(0));
+        EXPECT_EQ(lid_range(9, 12), rset.ranges.at(1));
+        EXPECT_EQ((vec{0u, 2u, 5u}), rset.ranges_partition);
+
+        EXPECT_EQ(1u, res_map.count(2, "l2_1"));
+        rset = res_map.at(2, "l2_1");
+        EXPECT_EQ(2u, rset.ranges.size());
+        EXPECT_EQ(lid_range(1, 2), rset.ranges.at(0));
+        EXPECT_EQ(lid_range(0, 1), rset.ranges.at(1));
+        EXPECT_EQ((vec{0u, 1u, 2u}), rset.ranges_partition);
+
+        EXPECT_EQ(1u, res_map.count(2, "l2_2"));
+        rset = res_map.at(2, "l2_2");
+        EXPECT_EQ(2u, rset.ranges.size());
+        EXPECT_EQ(lid_range(5, 5), rset.ranges.at(0));
+        EXPECT_EQ(lid_range(22, 23), rset.ranges.at(1));
+        EXPECT_EQ((vec{0u, 0u, 1u}), rset.ranges_partition);
+
+        // Check lid resolution
+        auto lid_resolver = arb::resolver(&res_map);
+        // gid 0
+        EXPECT_EQ(0u, lid_resolver.resolve({0, "l0_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(1u, lid_resolver.resolve({0, "l0_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(2u, lid_resolver.resolve({0, "l0_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(0u, lid_resolver.resolve({0, "l0_1", lid_selection_policy::round_robin}));
+
+        EXPECT_THROW(lid_resolver.resolve({0, "l0_0", lid_selection_policy::assert_univalent}), arb::bad_connection_label);
+        EXPECT_EQ(0u, lid_resolver.resolve({0, "l0_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(1u, lid_resolver.resolve({0, "l0_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(2u, lid_resolver.resolve({0, "l0_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(0u, lid_resolver.resolve({0, "l0_0", lid_selection_policy::round_robin}));
+
+        // gid 2
+        EXPECT_THROW(lid_resolver.resolve({2, "l2_0", lid_selection_policy::assert_univalent}), arb::bad_connection_label);
+        EXPECT_EQ(4u,  lid_resolver.resolve({2, "l2_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(5u,  lid_resolver.resolve({2, "l2_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(9u,  lid_resolver.resolve({2, "l2_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(10u, lid_resolver.resolve({2, "l2_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(11u, lid_resolver.resolve({2, "l2_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(4u,  lid_resolver.resolve({2, "l2_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(5u,  lid_resolver.resolve({2, "l2_0", lid_selection_policy::round_robin}));
+
+        EXPECT_THROW(lid_resolver.resolve({2, "l2_1", lid_selection_policy::assert_univalent}), arb::bad_connection_label);
+        EXPECT_EQ(1u, lid_resolver.resolve({2, "l2_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(0u, lid_resolver.resolve({2, "l2_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(1u, lid_resolver.resolve({2, "l2_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(0u, lid_resolver.resolve({2, "l2_1", lid_selection_policy::round_robin}));
+
+        EXPECT_EQ(22u, lid_resolver.resolve({2, "l2_2", lid_selection_policy::assert_univalent}));
+        EXPECT_EQ(22u, lid_resolver.resolve({2, "l2_2", lid_selection_policy::round_robin}));
+        EXPECT_EQ(22u, lid_resolver.resolve({2, "l2_2", lid_selection_policy::round_robin}));
+        EXPECT_EQ(22u, lid_resolver.resolve({2, "l2_2", lid_selection_policy::assert_univalent}));
+
+    }
+}
+
diff --git a/test/unit/test_lif_cell_group.cpp b/test/unit/test_lif_cell_group.cpp
index 272245a1..9ca765c4 100644
--- a/test/unit/test_lif_cell_group.cpp
+++ b/test/unit/test_lif_cell_group.cpp
@@ -41,17 +41,15 @@ public:
         // In a ring, each cell has just one incoming connection.
         std::vector<cell_connection> connections;
         // gid-1 >= 0 since gid != 0
-        cell_member_type source{(gid - 1) % n_lif_cells_, 0};
-        cell_lid_type target{0};
-        cell_connection conn(source, target, weight_, delay_);
+        auto src_gid = (gid - 1) % n_lif_cells_;
+        cell_connection conn({src_gid, "src"}, {"tgt"}, weight_, delay_);
         connections.push_back(conn);
 
         // If first LIF cell, then add
         // the connection from the last LIF cell as well
         if (gid == 1) {
-            cell_member_type source{n_lif_cells_, 0};
-            cell_lid_type target{0};
-            cell_connection conn(source, target, weight_, delay_);
+            auto src_gid = n_lif_cells_;
+            cell_connection conn({src_gid, "src"}, {"tgt"}, weight_, delay_);
             connections.push_back(conn);
         }
 
@@ -62,17 +60,11 @@ public:
         // regularly spiking cell.
         if (gid == 0) {
             // Produces just a single spike at time 0ms.
-            return spike_source_cell{explicit_schedule({0.f})};
+            return spike_source_cell("src", explicit_schedule({0.f}));
         }
         // LIF cell.
-        return lif_cell();
-    }
-
-    cell_size_type num_sources(cell_gid_type) const override {
-        return 1;
-    }
-    cell_size_type num_targets(cell_gid_type) const override {
-        return 1;
+        auto cell = lif_cell("src", "tgt");
+        return cell;
     }
 
 private:
@@ -100,23 +92,15 @@ public:
             return {};
         }
         std::vector<cell_connection> connections;
-        cell_member_type source{gid - 1, 0};
-        cell_lid_type target{0};
-        cell_connection conn(source, target, weight_, delay_);
+        cell_connection conn({gid-1, "src"}, {"tgt"}, weight_, delay_);
         connections.push_back(conn);
 
         return connections;
     }
 
     util::unique_any get_cell_description(cell_gid_type gid) const override {
-        return lif_cell();
-    }
-
-    cell_size_type num_sources(cell_gid_type) const override {
-        return 1;
-    }
-    cell_size_type num_targets(cell_gid_type) const override {
-        return 1;
+        auto cell = lif_cell("src", "tgt");
+        return cell;
     }
 
 private:
@@ -139,13 +123,7 @@ public:
         return {};
     }
     util::unique_any get_cell_description(cell_gid_type gid) const override {
-        return lif_cell();
-    }
-    cell_size_type num_sources(cell_gid_type) const override {
-        return 1;
-    }
-    cell_size_type num_targets(cell_gid_type) const override {
-        return 1;
+        return lif_cell("src", "tgt");
     }
     std::vector<probe_info> get_probes(cell_gid_type gid) const override{
         return {arb::cable_probe_membrane_voltage{mlocation{0, 0}}};
diff --git a/test/unit/test_mc_cell_group.cpp b/test/unit/test_mc_cell_group.cpp
index 564b2179..e72a3a53 100644
--- a/test/unit/test_mc_cell_group.cpp
+++ b/test/unit/test_mc_cell_group.cpp
@@ -28,8 +28,8 @@ namespace {
         auto d = builder.make_cell();
         d.decorations.paint("soma"_lab, "hh");
         d.decorations.paint("dend"_lab, "pas");
-        d.decorations.place(builder.location({1,1}), i_clamp::box(5, 80, 0.3));
-        d.decorations.place(builder.location({0, 0}), threshold_detector{0});
+        d.decorations.place(builder.location({1,1}), i_clamp::box(5, 80, 0.3), "clamp0");
+        d.decorations.place(builder.location({0, 0}), threshold_detector{0}, "detector0");
         return d;
     }
 }
@@ -41,7 +41,8 @@ ACCESS_BIND(
 
 TEST(mc_cell_group, get_kind) {
     cable_cell cell = make_cell();
-    mc_cell_group group{{0}, cable1d_recipe({cell}), lowered_cell()};
+    cell_label_range srcs, tgts;
+    mc_cell_group group{{0}, cable1d_recipe({cell}), srcs, tgts, lowered_cell()};
 
     EXPECT_EQ(cell_kind::cable, group.get_cell_kind());
 }
@@ -53,7 +54,8 @@ TEST(mc_cell_group, test) {
     rec.nernst_ion("ca");
     rec.nernst_ion("k");
 
-    mc_cell_group group{{0}, rec, lowered_cell()};
+    cell_label_range srcs, tgts;
+    mc_cell_group group{{0}, rec, srcs, tgts, lowered_cell()};
     group.advance(epoch(0, 0., 50.), 0.01, {});
 
     // Model is expected to generate 4 spikes as a result of the
@@ -69,7 +71,7 @@ TEST(mc_cell_group, sources) {
     for (int i=0; i<20; ++i) {
         auto desc = make_cell();
         if (i==0 || i==3 || i==17) {
-            desc.decorations.place(mlocation{0, 0.3}, threshold_detector{2.3});
+            desc.decorations.place(mlocation{0, 0.3}, threshold_detector{2.3}, "detector1");
         }
         cells.emplace_back(desc);
 
@@ -82,7 +84,8 @@ TEST(mc_cell_group, sources) {
     rec.nernst_ion("ca");
     rec.nernst_ion("k");
 
-    mc_cell_group group{gids, rec, lowered_cell()};
+    cell_label_range srcs, tgts;
+    mc_cell_group group{gids, rec, srcs, tgts, lowered_cell()};
 
     // Expect group sources to be lexicographically sorted by source id
     // with gids in cell group's range and indices starting from zero.
diff --git a/test/unit/test_mc_cell_group_gpu.cpp b/test/unit/test_mc_cell_group_gpu.cpp
index f7c8fbb7..4640b02f 100644
--- a/test/unit/test_mc_cell_group_gpu.cpp
+++ b/test/unit/test_mc_cell_group_gpu.cpp
@@ -29,8 +29,8 @@ namespace {
         auto d = builder.make_cell();
         d.decorations.paint("soma"_lab, "hh");
         d.decorations.paint("dend"_lab, "pas");
-        d.decorations.place(builder.location({1,1}), i_clamp::box(5, 80, 0.3));
-        d.decorations.place(builder.location({0, 0}), threshold_detector{0});
+        d.decorations.place(builder.location({1,1}), i_clamp::box(5, 80, 0.3), "clamp0");
+        d.decorations.place(builder.location({0, 0}), threshold_detector{0}, "detector0");
         return d;
     }
 }
@@ -43,7 +43,8 @@ TEST(mc_cell_group, gpu_test)
     rec.nernst_ion("ca");
     rec.nernst_ion("k");
 
-    mc_cell_group group{{0}, rec, lowered_cell()};
+    cell_label_range srcs, tgts;
+    mc_cell_group group{{0}, rec, srcs, tgts, lowered_cell()};
     group.advance(epoch(0, 0., 50.), 0.01, {});
 
     // The model is expected to generate 4 spikes as a result of the
diff --git a/test/unit/test_merge_events.cpp b/test/unit/test_merge_events.cpp
index d61363f4..f09cffc0 100644
--- a/test/unit/test_merge_events.cpp
+++ b/test/unit/test_merge_events.cpp
@@ -156,9 +156,9 @@ TEST(merge_events, X)
         {0, 26, 4},
     };
 
-    std::vector<event_generator> generators = {
-        regular_generator(2, 42.f, t0, 5)
-    };
+    auto gen = regular_generator({"l0"}, 42.f, t0, 5);
+    gen.resolve_label([](const cell_local_label_type&) {return 2;});
+    std::vector<event_generator> generators = {gen};
 
     merge_events(t0, t1, lc, events, generators, lf);
 
@@ -182,24 +182,33 @@ TEST(merge_events, X)
 // Test the tournament tree for merging two small sequences 
 TEST(merge_events, tourney_seq)
 {
-    pse_vector evs1 = {
-        {0, 1, 1},
-        {0, 2, 2},
-        {0, 3, 3},
-        {0, 4, 4},
-        {0, 5, 5},
+    explicit_generator::lse_vector evs1 = {
+        {{"l0"}, 1, 1},
+        {{"l0"}, 2, 2},
+        {{"l0"}, 3, 3},
+        {{"l0"}, 4, 4},
+        {{"l0"}, 5, 5},
     };
 
-    pse_vector evs2 = {
-        {0, 1.5, 1},
-        {0, 2.5, 2},
-        {0, 3.5, 3},
-        {0, 4.5, 4},
-        {0, 5.5, 5},
+    explicit_generator::lse_vector evs2 = {
+        {{"l0"}, 1.5, 1},
+        {{"l0"}, 2.5, 2},
+        {{"l0"}, 3.5, 3},
+        {{"l0"}, 4.5, 4},
+        {{"l0"}, 5.5, 5},
     };
 
+    pse_vector expected;
+
+    auto gen_pse = [](const auto& item) {return spike_event{0, item.time, item.weight};};
+    std::transform(evs1.begin(), evs1.end(), std::back_inserter(expected), gen_pse);
+    std::transform(evs2.begin(), evs2.end(), std::back_inserter(expected), gen_pse);
+    util::sort(expected);
+
     event_generator g1 = explicit_generator(evs1);
     event_generator g2 = explicit_generator(evs2);
+    g1.resolve_label([](const cell_local_label_type&) {return 0;});
+    g2.resolve_label([](const cell_local_label_type&) {return 0;});
 
     std::vector<event_span> spans;
     spans.emplace_back(g1.events(0, terminal_time));
@@ -213,10 +222,6 @@ TEST(merge_events, tourney_seq)
     }
 
     EXPECT_TRUE(std::is_sorted(lf.begin(), lf.end()));
-    auto expected = evs1;
-    util::append(expected, evs2);
-    util::sort(expected);
-
     EXPECT_EQ(expected, lf);
 }
 
@@ -234,13 +239,15 @@ TEST(merge_events, tourney_poisson)
 
     std::vector<event_generator> generators;
     for (auto i=0u; i<ngen; ++i) {
-        cell_lid_type tgt = i;
+        cell_lid_type lid = i;
+        cell_tag_type label = "tgt"+std::to_string(i);
         float weight = i;
         // the first and last generators have the same seed to test that sorting
         // of events with the same time but different weights works properly.
         rndgen G(i%(ngen-1));
-        generators.emplace_back(
-                poisson_generator(tgt, weight, t0, lambda, G));
+        auto gen = poisson_generator(label, weight, t0, lambda, G);
+        gen.resolve_label([lid](const cell_local_label_type&) {return lid;});
+        generators.push_back(std::move(gen));
     }
 
     // manually generate the expected output
diff --git a/test/unit/test_probe.cpp b/test/unit/test_probe.cpp
index 1ac01253..8c465da2 100644
--- a/test/unit/test_probe.cpp
+++ b/test/unit/test_probe.cpp
@@ -111,7 +111,7 @@ void run_v_i_probe_test(const context& ctx) {
     bs.decorations.set_default(cv_policy_fixed_per_branch(1));
 
     auto stim = i_clamp::box(0, 100, 0.3);
-    bs.decorations.place(mlocation{1, 1}, stim);
+    bs.decorations.place(mlocation{1, 1}, stim, "clamp");
 
     cable1d_recipe rec((cable_cell(bs)));
 
@@ -123,13 +123,10 @@ void run_v_i_probe_test(const context& ctx) {
     rec.add_probe(0, 20, cable_probe_membrane_voltage{loc1});
     rec.add_probe(0, 30, cable_probe_total_ion_current_density{loc2});
 
-    std::vector<target_handle> targets;
-    std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map probe_map;
-
     fvm_cell lcell(*ctx);
-    lcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
+    auto fvm_info = lcell.initialize({0}, rec);
 
+    const auto& probe_map = fvm_info.probe_map;
     EXPECT_EQ(3u, rec.get_probes(0).size());
     EXPECT_EQ(3u, probe_map.size());
 
@@ -226,16 +223,12 @@ void run_v_cell_probe_test(const context& ctx) {
         cable1d_recipe rec(cell, false);
         rec.add_probe(0, 0, cable_probe_membrane_voltage_cell{});
 
-        std::vector<target_handle> targets;
-        std::vector<fvm_index_type> cell_to_intdom;
-        probe_association_map probe_map;
-
         fvm_cell lcell(*ctx);
-        lcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
+        auto fvm_info = lcell.initialize({0}, rec);
 
-        ASSERT_EQ(1u, probe_map.size());
+        ASSERT_EQ(1u, fvm_info.probe_map.size());
 
-        const fvm_probe_multi* h_ptr = std::get_if<fvm_probe_multi>(&probe_map.data_on({0, 0}).front().info);
+        const fvm_probe_multi* h_ptr = std::get_if<fvm_probe_multi>(&fvm_info.probe_map.data_on({0, 0}).front().info);
         ASSERT_TRUE(h_ptr);
         auto& h = *h_ptr;
 
@@ -280,8 +273,8 @@ void run_expsyn_g_probe_test(const context& ctx) {
     builder.add_branch(0, 200, 1.0/2, 1.0/2, 1, "dend");
     builder.add_branch(0, 200, 1.0/2, 1.0/2, 1, "dend");
     auto bs = builder.make_cell();
-    bs.decorations.place(loc0, "expsyn");
-    bs.decorations.place(loc1, "expsyn");
+    bs.decorations.place(loc0, "expsyn", "syn0");
+    bs.decorations.place(loc1, "expsyn", "syn1");
     bs.decorations.set_default(cv_policy_fixed_per_branch(2));
 
     auto run_test = [&](bool coalesce_synapses) {
@@ -289,12 +282,10 @@ void run_expsyn_g_probe_test(const context& ctx) {
         rec.add_probe(0, 10, cable_probe_point_state{0u, "expsyn", "g"});
         rec.add_probe(0, 20, cable_probe_point_state{1u, "expsyn", "g"});
 
-        std::vector<target_handle> targets;
-        std::vector<fvm_index_type> cell_to_intdom;
-        probe_association_map probe_map;
-
         fvm_cell lcell(*ctx);
-        lcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
+        auto fvm_info = lcell.initialize({0}, rec);
+        const auto& probe_map = fvm_info.probe_map;
+        const auto& targets = fvm_info.target_handles;
 
         EXPECT_EQ(2u, rec.get_probes(0).size());
         EXPECT_EQ(2u, probe_map.size());
@@ -384,10 +375,11 @@ void run_expsyn_g_cell_probe_test(const context& ctx) {
     unsigned n_expsyn = 0;
     for (unsigned bid = 0; bid<3u; ++bid) {
         for (unsigned j = 0; j<10; ++j) {
+            auto idx = (bid*10+j)*2;
             mlocation expsyn_loc{bid, 0.1*j};
-            d.place(expsyn_loc, "expsyn");
+            d.place(expsyn_loc, "expsyn", "syn"+std::to_string(idx));
             expsyn_target_loc_map[2*n_expsyn] = expsyn_loc;
-            d.place(mlocation{bid, 0.1*j+0.05}, "exp2syn");
+            d.place(mlocation{bid, 0.1*j+0.05}, "exp2syn", "syn"+std::to_string(idx+1));
             ++n_expsyn;
         }
     }
@@ -400,12 +392,10 @@ void run_expsyn_g_cell_probe_test(const context& ctx) {
         rec.add_probe(0, 0, cable_probe_point_state_cell{"expsyn", "g"});
         rec.add_probe(1, 0, cable_probe_point_state_cell{"expsyn", "g"});
 
-        std::vector<target_handle> targets;
-        std::vector<fvm_index_type> cell_to_intdom;
-        probe_association_map probe_map;
-
         fvm_cell lcell(*ctx);
-        lcell.initialize({0, 1}, rec, cell_to_intdom, targets, probe_map);
+        auto fvm_info = lcell.initialize({0, 1}, rec);
+        const auto& probe_map = fvm_info.probe_map;
+        const auto& targets = fvm_info.target_handles;
 
         // Send an event to each expsyn synapse with a weight = target+100*cell_gid, and
         // integrate for a tiny time step.
@@ -558,12 +548,9 @@ void run_ion_density_probe_test(const context& ctx) {
     // Probe (0, 12): ca external on whole cell.
     rec.add_probe(0, 0, cable_probe_ion_ext_concentration_cell{"ca"});
 
-    std::vector<target_handle> targets;
-    std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map probe_map;
-
     fvm_cell lcell(*ctx);
-    lcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
+    auto fvm_info = lcell.initialize({0}, rec);
+    const auto& probe_map = fvm_info.probe_map;
 
     // Should be no sodium ion instantiated on CV 0, so probe (0, 6) should
     // have been silently discared. Similarly, write_ca2 is not instantiated on
@@ -736,12 +723,9 @@ void run_partial_density_probe_test(const context& ctx) {
         rec.add_probe(1, 0, cable_probe_density_state{mlocation{0, tp.pos}, "param_as_state", "s"});
     }
 
-    std::vector<target_handle> targets;
-    std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map probe_map;
-
     fvm_cell lcell(*ctx);
-    lcell.initialize({0, 1}, rec, cell_to_intdom, targets, probe_map);
+    auto fvm_info = lcell.initialize({0, 1}, rec);
+    const auto& probe_map = fvm_info.probe_map;
 
     // There should be 10 probes on each cell, but only 10 in total in the probe map,
     // as only those probes that are in the mechanism support should have an entry.
@@ -792,7 +776,7 @@ void run_axial_and_ion_current_sampled_probe_test(const context& ctx) {
     cv_policy policy = cv_policy_fixed_per_branch(n_cv);
     d.set_default(policy);
 
-    d.place(mlocation{0, 0}, i_clamp(0.3));
+    d.place(mlocation{0, 0}, i_clamp(0.3), "clamp");
 
     // The time constant will be membrane capacitance / membrane conductance.
     // For τ = 0.1 ms, set conductance to 0.01 S/cm² and membrance capacitance
@@ -989,8 +973,8 @@ void run_v_sampled_probe_test(const context& ctx) {
     // samples at the same point on each cell will give the same value at
     // 0.3 ms, but different at 0.6 ms.
 
-    d0.place(mlocation{1, 1}, i_clamp::box(0, 0.5, 1.));
-    d1.place(mlocation{1, 1}, i_clamp::box(0, 1.0, 1.));
+    d0.place(mlocation{1, 1}, i_clamp::box(0, 0.5, 1.), "clamp0");
+    d1.place(mlocation{1, 1}, i_clamp::box(0, 1.0, 1.), "clamp1");
     mlocation probe_loc{1, 0.2};
 
     std::vector<cable_cell> cells = {{bs.morph, bs.labels, d0}, {bs.morph, bs.labels, d1}};
@@ -1040,7 +1024,7 @@ void run_total_current_probe_test(const context& ctx) {
     // to 0.01 F/m².
 
     const double tau = 0.1;     // [ms]
-    d0.place(mlocation{0, 0}, i_clamp(0.3));
+    d0.place(mlocation{0, 0}, i_clamp(0.3), "clamp0");
 
     d0.paint(reg::all(), mechanism_desc("ca_linear").set("g", 0.01)); // [S/cm²]
     d0.set_default(membrane_capacitance{0.01}); // [F/m²]
@@ -1161,13 +1145,13 @@ void run_stimulus_probe_test(const context& ctx) {
 
     decor d0, d1;
     d0.set_default(policy);
-    d0.place(mlocation{0, 0.5}, i_clamp::box(0., stim_until, 10.));
-    d0.place(mlocation{0, 0.5}, i_clamp::box(0., stim_until, 20.));
+    d0.place(mlocation{0, 0.5}, i_clamp::box(0., stim_until, 10.), "clamp0");
+    d0.place(mlocation{0, 0.5}, i_clamp::box(0., stim_until, 20.), "clamp1");
     double expected_stim0 = 30;
 
     d1.set_default(policy);
-    d1.place(mlocation{0, 1}, i_clamp::box(0., stim_until, 30.));
-    d1.place(mlocation{0, 1}, i_clamp::box(0., stim_until, -10.));
+    d1.place(mlocation{0, 1}, i_clamp::box(0., stim_until, 30.), "clamp0");
+    d1.place(mlocation{0, 1}, i_clamp::box(0., stim_until, -10.), "clamp1");
     double expected_stim1 = 20;
 
     std::vector<cable_cell> cells = {{m, {}, d0}, {m, {}, d1}};
@@ -1215,13 +1199,13 @@ void run_exact_sampling_probe_test(const context& ctx) {
             std::vector<cable_cell_description> cd;
             cd.assign(4, builder.make_cell());
 
-            cd[0].decorations.place(mlocation{1, 0.1}, "expsyn");
-            cd[1].decorations.place(mlocation{1, 0.1}, "exp2syn");
-            cd[2].decorations.place(mlocation{1, 0.9}, "expsyn");
-            cd[3].decorations.place(mlocation{1, 0.9}, "exp2syn");
+            cd[0].decorations.place(mlocation{1, 0.1}, "expsyn", "syn");
+            cd[1].decorations.place(mlocation{1, 0.1}, "exp2syn", "syn");
+            cd[2].decorations.place(mlocation{1, 0.9}, "expsyn", "syn");
+            cd[3].decorations.place(mlocation{1, 0.9}, "exp2syn", "syn");
 
-            cd[1].decorations.place(mlocation{1, 0.2}, gap_junction_site{});
-            cd[3].decorations.place(mlocation{1, 0.2}, gap_junction_site{});
+            cd[1].decorations.place(mlocation{1, 0.2}, gap_junction_site{}, "gj");
+            cd[3].decorations.place(mlocation{1, 0.2}, gap_junction_site{}, "gj");
 
             for (auto& d: cd) cells_.push_back(d);
         }
@@ -1240,18 +1224,12 @@ void run_exact_sampling_probe_test(const context& ctx) {
             return {cable_probe_membrane_voltage{mlocation{1, 0.5}}};
         }
 
-        cell_size_type num_targets(cell_gid_type) const override { return 1; }
-
-        cell_size_type num_gap_junction_sites(cell_gid_type gid) const override {
-            return gid==1 || gid==3;
-        }
-
         std::vector<gap_junction_connection> gap_junctions_on(cell_gid_type gid) const override {
             switch (gid) {
             case 1:
-                return {gap_junction_connection({3, 0}, 0, 1.)};
+                return {gap_junction_connection({3, "gj", lid_selection_policy::assert_univalent}, {"gj", lid_selection_policy::assert_univalent}, 1.)};
             case 3:
-                return {gap_junction_connection({1, 0}, 0, 1.)};
+                return {gap_junction_connection({1, "gj", lid_selection_policy::assert_univalent}, {"gj", lid_selection_policy::assert_univalent}, 1.)};
             default:
                 return {};
             }
@@ -1259,7 +1237,7 @@ void run_exact_sampling_probe_test(const context& ctx) {
 
         std::vector<event_generator> event_generators(cell_gid_type gid) const override {
             // Send a single event to cell i at 0.1*i milliseconds.
-            pse_vector spikes = {spike_event{0, 0.1*gid, 1.f}};
+            explicit_generator::lse_vector spikes = {{{"syn"}, 0.1*gid, 1.f}};
             return {explicit_generator(spikes)};
         }
 
diff --git a/test/unit/test_recipe.cpp b/test/unit/test_recipe.cpp
index cda30fb9..0ecd2e33 100644
--- a/test/unit/test_recipe.cpp
+++ b/test/unit/test_recipe.cpp
@@ -23,14 +23,10 @@ namespace {
     class custom_recipe: public recipe {
     public:
         custom_recipe(std::vector<cable_cell> cells,
-                      std::vector<cell_size_type> num_sources,
-                      std::vector<cell_size_type> num_targets,
                       std::vector<std::vector<cell_connection>> conns,
                       std::vector<std::vector<gap_junction_connection>> gjs,
                       std::vector<std::vector<arb::event_generator>> gens):
             num_cells_(cells.size()),
-            num_sources_(num_sources),
-            num_targets_(num_targets),
             connections_(conns),
             gap_junctions_(gjs),
             event_generators_(gens),
@@ -51,12 +47,6 @@ namespace {
         std::vector<cell_connection> connections_on(cell_gid_type gid) const override {
             return connections_.at(gid);
         }
-        cell_size_type num_sources(cell_gid_type gid) const override {
-            return num_sources_.at(gid);
-        }
-        cell_size_type num_targets(cell_gid_type gid) const override {
-            return num_targets_.at(gid);
-        }
         std::vector<arb::event_generator> event_generators(cell_gid_type gid) const override {
             return event_generators_.at(gid);
         }
@@ -68,7 +58,6 @@ namespace {
 
     private:
         cell_size_type num_cells_;
-        std::vector<cell_size_type> num_sources_, num_targets_;
         std::vector<std::vector<cell_connection>> connections_;
         std::vector<std::vector<gap_junction_connection>> gap_junctions_;
         std::vector<std::vector<arb::event_generator>> event_generators_;
@@ -86,76 +75,23 @@ namespace {
 
         // Add a num_detectors detectors to the cell.
         for (auto i: util::make_span(num_detectors)) {
-            decorations.place(arb::mlocation{0,(double)i/num_detectors}, arb::threshold_detector{10});
+            decorations.place(arb::mlocation{0,(double)i/num_detectors}, arb::threshold_detector{10}, "detector"+std::to_string(i));
         }
 
         // Add a num_synapses synapses to the cell.
         for (auto i: util::make_span(num_synapses)) {
-            decorations.place(arb::mlocation{0,(double)i/num_synapses}, "expsyn");
+            decorations.place(arb::mlocation{0,(double)i/num_synapses}, "expsyn", "synapse"+std::to_string(i));
         }
 
         // Add a num_gj gap_junctions to the cell.
         for (auto i: util::make_span(num_gj)) {
-            decorations.place(arb::mlocation{0,(double)i/num_gj}, arb::gap_junction_site{});
+            decorations.place(arb::mlocation{0,(double)i/num_gj}, arb::gap_junction_site{}, "gapjunction"+std::to_string(i));
         }
 
         return arb::cable_cell(tree, {}, decorations);
     }
 }
 
-// test assumes one domain
-TEST(recipe, num_sources)
-{
-    arb::proc_allocation resources;
-    if (auto nt = arbenv::get_env_num_threads()) {
-        resources.num_threads = nt;
-    }
-    else {
-        resources.num_threads = arbenv::thread_concurrency();
-    }
-    auto context = make_context(resources);
-    auto cell = custom_cell(1, 0, 0);
-
-    {
-        auto recipe_0 = custom_recipe({cell}, {1}, {0}, {{}}, {{}}, {{}});
-        auto decomp_0 = partition_load_balance(recipe_0, context);
-
-        EXPECT_NO_THROW(simulation(recipe_0, decomp_0, context));
-    }
-    {
-        auto recipe_1 = custom_recipe({cell}, {2}, {0}, {{}}, {{}}, {{}});
-        auto decomp_1 = partition_load_balance(recipe_1, context);
-
-        EXPECT_THROW(simulation(recipe_1, decomp_1, context), arb::bad_source_description);
-    }
-}
-
-TEST(recipe, num_targets)
-{
-    arb::proc_allocation resources;
-    if (auto nt = arbenv::get_env_num_threads()) {
-        resources.num_threads = nt;
-    }
-    else {
-        resources.num_threads = arbenv::thread_concurrency();
-    }
-    auto context = make_context(resources);
-    auto cell = custom_cell(0, 2, 0);
-
-    {
-        auto recipe_0 = custom_recipe({cell}, {0}, {2}, {{}}, {{}}, {{}});
-        auto decomp_0 = partition_load_balance(recipe_0, context);
-
-        EXPECT_NO_THROW(simulation(recipe_0, decomp_0, context));
-    }
-    {
-        auto recipe_1 = custom_recipe({cell}, {0}, {3}, {{}}, {{}}, {{}});
-        auto decomp_1 = partition_load_balance(recipe_1, context);
-
-        EXPECT_THROW(simulation(recipe_1, decomp_1, context), arb::bad_target_description);
-    }
-}
-
 TEST(recipe, gap_junctions)
 {
     arb::proc_allocation resources;
@@ -170,33 +106,34 @@ TEST(recipe, gap_junctions)
     auto cell_0 = custom_cell(0, 0, 3);
     auto cell_1 = custom_cell(0, 0, 3);
 
+    using policy = lid_selection_policy;
     {
-        std::vector<arb::gap_junction_connection> gjs_0 = {{{1, 1}, 0, 0.1},
-                                                           {{1, 2}, 1, 0.1},
-                                                           {{1, 0}, 2, 0.1}};
+        std::vector<arb::gap_junction_connection> gjs_0 = {{{1, "gapjunction1", policy::assert_univalent}, {"gapjunction0", policy::assert_univalent}, 0.1},
+                                                           {{1, "gapjunction2", policy::assert_univalent}, {"gapjunction1", policy::assert_univalent}, 0.1},
+                                                           {{1, "gapjunction0", policy::assert_univalent}, {"gapjunction2", policy::assert_univalent}, 0.1}};
 
-        std::vector<arb::gap_junction_connection> gjs_1 = {{{0, 0}, 1, 0.1},
-                                                           {{0, 1}, 2, 0.1},
-                                                           {{0, 2}, 0, 0.1}};
+        std::vector<arb::gap_junction_connection> gjs_1 = {{{0, "gapjunction0", policy::assert_univalent}, {"gapjunction1", policy::assert_univalent}, 0.1},
+                                                           {{0, "gapjunction1", policy::assert_univalent}, {"gapjunction2", policy::assert_univalent}, 0.1},
+                                                           {{0, "gapjunction2", policy::assert_univalent}, {"gapjunction0", policy::assert_univalent}, 0.1}};
 
-        auto recipe_0 = custom_recipe({cell_0, cell_1}, {0, 0}, {0, 0}, {{}, {}}, {gjs_0, gjs_1}, {{}, {}});
+        auto recipe_0 = custom_recipe({cell_0, cell_1}, {{}, {}}, {gjs_0, gjs_1}, {{}, {}});
         auto decomp_0 = partition_load_balance(recipe_0, context);
 
         EXPECT_NO_THROW(simulation(recipe_0, decomp_0, context));
     }
     {
-        std::vector<arb::gap_junction_connection> gjs_0 = {{{1, 1}, 0, 0.1},
-                                                           {{1, 2}, 1, 0.1},
-                                                           {{1, 5}, 2, 0.1}};
+        std::vector<arb::gap_junction_connection> gjs_0 = {{{1, "gapjunction1", policy::assert_univalent}, {"gapjunction0", policy::assert_univalent}, 0.1},
+                                                           {{1, "gapjunction2", policy::assert_univalent}, {"gapjunction1", policy::assert_univalent}, 0.1},
+                                                           {{1, "gapjunction5", policy::assert_univalent}, {"gapjunction2", policy::assert_univalent}, 0.1}};
 
-        std::vector<arb::gap_junction_connection> gjs_1 = {{{0, 0}, 1, 0.1},
-                                                           {{0, 1}, 2, 0.1},
-                                                           {{0, 2}, 5, 0.1}};
+        std::vector<arb::gap_junction_connection> gjs_1 = {{{0, "gapjunction0", policy::assert_univalent}, {"gapjunction1", policy::assert_univalent}, 0.1},
+                                                           {{0, "gapjunction1", policy::assert_univalent}, {"gapjunction2", policy::assert_univalent}, 0.1},
+                                                           {{0, "gapjunction2", policy::assert_univalent}, {"gapjunction5", policy::assert_univalent}, 0.1}};
 
-        auto recipe_1 = custom_recipe({cell_0, cell_1}, {0, 0}, {0, 0}, {{}, {}}, {gjs_0, gjs_1}, {{}, {}});
+        auto recipe_1 = custom_recipe({cell_0, cell_1}, {{}, {}}, {gjs_0, gjs_1}, {{}, {}});
         auto decomp_1 = partition_load_balance(recipe_1, context);
 
-        EXPECT_THROW(simulation(recipe_1, decomp_1, context), arb::bad_gj_connection_lid);
+        EXPECT_THROW(simulation(recipe_1, decomp_1, context), arb::bad_connection_label);
 
     }
 }
@@ -216,60 +153,60 @@ TEST(recipe, connections)
     auto cell_1 = custom_cell(2, 1, 0);
     std::vector<arb::cell_connection> conns_0, conns_1;
     {
-        conns_0 = {{{1, 0}, 0, 0.1, 0.1},
-                   {{1, 1}, 0, 0.1, 0.1},
-                   {{1, 0}, 1, 0.2, 0.4}};
+        conns_0 = {{{1, "detector0"}, {"synapse0"}, 0.1, 0.1},
+                   {{1, "detector1"}, {"synapse0"}, 0.1, 0.1},
+                   {{1, "detector0"}, {"synapse1"}, 0.2, 0.4}};
 
-        conns_1 = {{{0, 0}, 0, 0.1, 0.2},
-                   {{0, 0}, 0, 0.3, 0.1},
-                   {{0, 0}, 0, 0.1, 0.8}};
+        conns_1 = {{{0, "detector0"}, {"synapse0"}, 0.1, 0.2},
+                   {{0, "detector0"}, {"synapse0"}, 0.3, 0.1},
+                   {{0, "detector0"}, {"synapse0"}, 0.1, 0.8}};
 
-        auto recipe_0 = custom_recipe({cell_0, cell_1}, {1, 2}, {2, 1}, {conns_0, conns_1}, {{}, {}},  {{}, {}});
+        auto recipe_0 = custom_recipe({cell_0, cell_1}, {conns_0, conns_1}, {{}, {}},  {{}, {}});
         auto decomp_0 = partition_load_balance(recipe_0, context);
 
         EXPECT_NO_THROW(simulation(recipe_0, decomp_0, context));
     }
     {
-        conns_0 = {{{1, 0}, 0, 0.1, 0.1},
-                   {{2, 1}, 0, 0.1, 0.1},
-                   {{1, 0}, 1, 0.2, 0.4}};
+        conns_0 = {{{1, "detector0"}, {"synapse0"}, 0.1, 0.1},
+                   {{2, "detector1"}, {"synapse0"}, 0.1, 0.1},
+                   {{1, "detector0"}, {"synapse1"}, 0.2, 0.4}};
 
-        conns_1 = {{{0, 0}, 0, 0.1, 0.2},
-                   {{0, 0}, 0, 0.3, 0.1},
-                   {{0, 0}, 0, 0.1, 0.8}};
+        conns_1 = {{{0, "detector0"}, {"synapse0"}, 0.1, 0.2},
+                   {{0, "detector0"}, {"synapse0"}, 0.3, 0.1},
+                   {{0, "detector0"}, {"synapse0"}, 0.1, 0.8}};
 
-        auto recipe_1 = custom_recipe({cell_0, cell_1}, {1, 2}, {2, 1}, {conns_0, conns_1}, {{}, {}},  {{}, {}});
+        auto recipe_1 = custom_recipe({cell_0, cell_1}, {conns_0, conns_1}, {{}, {}},  {{}, {}});
         auto decomp_1 = partition_load_balance(recipe_1, context);
 
         EXPECT_THROW(simulation(recipe_1, decomp_1, context), arb::bad_connection_source_gid);
     }
     {
-        conns_0 = {{{1, 0}, 0, 0.1, 0.1},
-                   {{1, 1}, 0, 0.1, 0.1},
-                   {{1, 3}, 1, 0.2, 0.4}};
+        conns_0 = {{{1, "detector0"}, {"synapse0"}, 0.1, 0.1},
+                   {{1, "detector1"}, {"synapse0"}, 0.1, 0.1},
+                   {{1, "detector3"}, {"synapse1"}, 0.2, 0.4}};
 
-        conns_1 = {{{0, 0}, 0, 0.1, 0.2},
-                   {{0, 0}, 0, 0.3, 0.1},
-                   {{0, 0}, 0, 0.1, 0.8}};
+        conns_1 = {{{0, "detector0"}, {"synapse0"}, 0.1, 0.2},
+                   {{0, "detector0"}, {"synapse0"}, 0.3, 0.1},
+                   {{0, "detector0"}, {"synapse0"}, 0.1, 0.8}};
 
-        auto recipe_2 = custom_recipe({cell_0, cell_1}, {1, 2}, {2, 1}, {conns_0, conns_1}, {{}, {}},  {{}, {}});
+        auto recipe_2 = custom_recipe({cell_0, cell_1}, {conns_0, conns_1}, {{}, {}},  {{}, {}});
         auto decomp_2 = partition_load_balance(recipe_2, context);
 
-        EXPECT_THROW(simulation(recipe_2, decomp_2, context), arb::bad_connection_source_lid);
+        EXPECT_THROW(simulation(recipe_2, decomp_2, context), arb::bad_connection_label);
     }
     {
-        conns_0 = {{{1, 0}, 0, 0.1, 0.1},
-                   {{1, 1}, 0, 0.1, 0.1},
-                   {{1, 0}, 1, 0.2, 0.4}};
+        conns_0 = {{{1, "detector0"}, {"synapse0"}, 0.1, 0.1},
+                   {{1, "detector1"}, {"synapse0"}, 0.1, 0.1},
+                   {{1, "detector0"}, {"synapse1"}, 0.2, 0.4}};
 
-        conns_1 = {{{0, 0}, 0, 0.1, 0.2},
-                   {{0, 0}, 9, 0.3, 0.1},
-                   {{0, 0}, 0, 0.1, 0.8}};
+        conns_1 = {{{0, "detector0"}, {"synapse0"}, 0.1, 0.2},
+                   {{0, "detector0"}, {"synapse9"}, 0.3, 0.1},
+                   {{0, "detector0"}, {"synapse0"}, 0.1, 0.8}};
 
-        auto recipe_4 = custom_recipe({cell_0, cell_1}, {1, 2}, {2, 1}, {conns_0, conns_1}, {{}, {}},  {{}, {}});
+        auto recipe_4 = custom_recipe({cell_0, cell_1}, {conns_0, conns_1}, {{}, {}},  {{}, {}});
         auto decomp_4 = partition_load_balance(recipe_4, context);
 
-        EXPECT_THROW(simulation(recipe_4, decomp_4, context), arb::bad_connection_target_lid);
+        EXPECT_THROW(simulation(recipe_4, decomp_4, context), arb::bad_connection_label);
     }
 }
 
@@ -287,22 +224,22 @@ TEST(recipe, event_generators) {
     auto cell_1 = custom_cell(2, 1, 0);
     std::vector<arb::event_generator> gens_0, gens_1;
     {
-        gens_0 = {arb::explicit_generator(arb::pse_vector{{0, 1.0, 0.1}, {1, 2.0, 0.1}})};
+        gens_0 = {arb::explicit_generator({{{"synapse0"}, 1.0, 0.1}, {{"synapse1"}, 2.0, 0.1}})};
 
-        gens_1 = {arb::explicit_generator(arb::pse_vector{{0, 1.0, 0.1}})};
+        gens_1 = {arb::explicit_generator({{{"synapse0"}, 1.0, 0.1}})};
 
-        auto recipe_0 = custom_recipe({cell_0, cell_1}, {1, 2}, {2, 1}, {{}, {}}, {{}, {}},  {gens_0, gens_1});
+        auto recipe_0 = custom_recipe({cell_0, cell_1}, {{}, {}}, {{}, {}},  {gens_0, gens_1});
         auto decomp_0 = partition_load_balance(recipe_0, context);
 
         EXPECT_NO_THROW(simulation(recipe_0, decomp_0, context));
     }
     {
-        gens_0 = {arb::explicit_generator(arb::pse_vector{{0, 1.0, 0.1}, {3, 2.0, 0.1}})};
+        gens_0 = {arb::explicit_generator({{{"synapse0"}, 1.0, 0.1}, {{"synapse3"}, 2.0, 0.1}})};
         gens_1.clear();
 
-        auto recipe_0 = custom_recipe({cell_0, cell_1}, {1, 2}, {2, 1}, {{}, {}}, {{}, {}},  {gens_0, gens_1});
+        auto recipe_0 = custom_recipe({cell_0, cell_1}, {{}, {}}, {{}, {}},  {gens_0, gens_1});
         auto decomp_0 = partition_load_balance(recipe_0, context);
 
-        EXPECT_THROW(simulation(recipe_0, decomp_0, context), arb::bad_event_generator_target_lid);
+        EXPECT_THROW(simulation(recipe_0, decomp_0, context), arb::bad_connection_label);
     }
 }
diff --git a/test/unit/test_s_expr.cpp b/test/unit/test_s_expr.cpp
index 697e7b30..92ddcb1f 100644
--- a/test/unit/test_s_expr.cpp
+++ b/test/unit/test_s_expr.cpp
@@ -313,7 +313,7 @@ std::optional<T> eval_cast_variant(const std::any& a) {
 }
 
 using branch = std::tuple<int, int, std::vector<arb::msegment>>;
-using place_pair = std::pair<arb::locset, arb::placeable>;
+using place_tuple = std::tuple<arb::locset, arb::placeable, std::string>;
 using paint_pair = std::pair<arb::region, arb::paintable>;
 using locset_pair = std::pair<std::string, locset>;
 using region_pair = std::pair<std::string, region>;
@@ -377,10 +377,10 @@ std::ostream& operator<<(std::ostream& o, const paint_pair& p) {
     std::visit([&](auto&& x) {o << x;}, p.second);
     return o << ")";
 }
-std::ostream& operator<<(std::ostream& o, const place_pair& p) {
-    o << "(place " << p.first << " ";
-    std::visit([&](auto&& x) {o << x;}, p.second);
-    return o << ")";
+std::ostream& operator<<(std::ostream& o, const place_tuple& p) {
+    o << "(place " << std::get<0>(p) << " ";
+    std::visit([&](auto&& x) {o << x;}, std::get<1>(p));
+    return o << " \"" << std::get<2>(p) << "\")";
 }
 std::ostream& operator<<(std::ostream& o, const defaultable& p) {
     o << "(default ";
@@ -530,16 +530,16 @@ TEST(decor_expressions, round_tripping) {
         "(default (ion-reversal-potential-method \"ca\" (mechanism \"nernst/ca\")))"
     };
     auto decorate_place_literals = {
-        "(place (location 3 0.2) (current-clamp (envelope (10 0.5) (110 0.5) (110 0)) 0.5 0.25))",
-        "(place (terminal) (threshold-detector -10))",
-        "(place (root) (gap-junction-site))",
-        "(place (locset \"my!ls\") (mechanism \"expsyn\"))"};
+        "(place (location 3 0.2) (current-clamp (envelope (10 0.5) (110 0.5) (110 0)) 0.5 0.25) \"clamp\")",
+        "(place (terminal) (threshold-detector -10) \"detector\")",
+        "(place (root) (gap-junction-site) \"gap_junction\")",
+        "(place (locset \"my!ls\") (mechanism \"expsyn\") \"synapse\")"};
 
     for (auto l: decorate_paint_literals) {
         EXPECT_EQ(l, round_trip<paint_pair>(l));
     }
     for (auto l: decorate_place_literals) {
-        EXPECT_EQ(l, round_trip<place_pair>(l));
+        EXPECT_EQ(l, round_trip<place_tuple>(l));
     }
     for (auto l: decorate_default_literals) {
         EXPECT_EQ(l, round_trip<defaultable>(l));
@@ -606,14 +606,17 @@ TEST(decor, round_tripping) {
                                 "      (ion-internal-concentration \"ca\" 0.500000))\n"
                                 "    (place \n"
                                 "      (location 0 0)\n"
-                                "      (gap-junction-site))\n"
+                                "      (gap-junction-site)\n"
+                                "      \"gap-junction\")\n"
                                 "    (place \n"
                                 "      (location 0 0)\n"
-                                "      (threshold-detector 10.000000))\n"
+                                "      (threshold-detector 10.000000)\n"
+                                "      \"detector\")\n"
                                 "    (place \n"
                                 "      (location 0 0.5)\n"
                                 "      (mechanism \"expsyn\" \n"
-                                "        (\"tau\" 1.500000)))))";
+                                "        (\"tau\" 1.500000))\n"
+                                "      \"synapse\")))";
 
     EXPECT_EQ(component_str, round_trip_component(component_str.c_str()));
 }
@@ -821,7 +824,8 @@ TEST(cable_cell, round_tripping) {
                                 "            (10.000000 0.500000)\n"
                                 "            (110.000000 0.500000)\n"
                                 "            (110.000000 0.000000))\n"
-                                "          0.000000 0.000000)))))";
+                                "          0.000000 0.000000)\n"
+                                "        \"iclamp\"))))";
 
     EXPECT_EQ(component_str, round_trip_component(component_str.c_str()));
 
@@ -845,8 +849,9 @@ TEST(cable_cell_literals, errors) {
                      "(paint (tag 1) (mechanims hh))",       // invalid painting
                      "(paint (terminal) (membrance-capacitance 0.2))", // can't paint a locset
                      "(paint (tag 3))",                      // too few arguments
-                     "(place (locset) (gap-junction-site))", // invalid locset
-                     "(place (gap-junction-site) (location 0 1))",      // swapped argument order
+                     "(place (locset) (gap-junction-site) \"gj\")",        // invalid locset
+                     "(place (gap-junction-site) (location 0 1), \"gj\")", // swapped argument order
+                     "(place (location 0 1) (mechanism \"expsyn\"))",      // missing label
                      "(region-def my_region (tag 3))",       // unquoted region name
                      "(locset-def \"my_ls\" (tag 3))",       // invalid locset
                      "(locset-def \"my_ls\")",               // too few arguments
@@ -890,7 +895,7 @@ TEST(doc_expressions, parse) {
                      "(ion-reversal-potential-method \"ca\" (mechanism \"nernst/ca\"))",
                      "(current-clamp (envelope (0 10) (50 10) (50 0)) 40 0.25)",
                      "(paint (tag 1) (membrane-capacitance 0.02))",
-                     "(place (locset \"mylocset\") (threshold-detector 10))",
+                     "(place (locset \"mylocset\") (threshold-detector 10) \"mydetectors\")",
                      "(default (membrane-potential -65))",
                      "(segment 3 (point 0 0 0 5) (point 0 0 10 2) 1)"})
     {
@@ -910,8 +915,8 @@ TEST(doc_expressions, parse) {
                      "  (paint (region \"soma\") (membrane-potential -50.000000))\n"
                      "  (paint (all) (mechanism \"pas\"))\n"
                      "  (paint (tag 4) (mechanism \"Ih\" (\"gbar\" 0.001)))\n"
-                     "  (place (locset \"root\") (mechanism \"expsyn\"))\n"
-                     "  (place (terminal) (gap-junction-site)))",
+                     "  (place (locset \"root\") (mechanism \"expsyn\") \"root_synapse\")\n"
+                     "  (place (terminal) (gap-junction-site) \"terminal_gj\"))",
                      "(morphology\n"
                      "  (branch 0 -1\n"
                      "    (segment 0 (point 0 0 0 2) (point 4 0 0 2) 1)\n"
@@ -942,8 +947,8 @@ TEST(doc_expressions, parse) {
                      "    (paint (region \"my_soma\") (temperature-kelvin 270))\n"
                      "    (paint (region \"my_region\") (membrane-potential -50.000000))\n"
                      "    (paint (tag 4) (mechanism \"Ih\" (\"gbar\" 0.001)))\n"
-                     "    (place (locset \"root\") (mechanism \"expsyn\"))\n"
-                     "    (place (location 1 0.2) (gap-junction-site)))\n"
+                     "    (place (locset \"root\") (mechanism \"expsyn\") \"root_synapse\")\n"
+                     "    (place (location 1 0.2) (gap-junction-site) \"terminal_gj\"))\n"
                      "  (morphology\n"
                      "    (branch 0 -1\n"
                      "      (segment 0 (point 0 0 0 2) (point 4 0 0 2) 1)\n"
@@ -980,7 +985,7 @@ TEST(doc_expressions, parse) {
                             "  (meta-data (version \"" + arborio::acc_version() +"\"))\n"
                             "  (decor\n"
                             "    (default (membrane-potential -55.000000))\n"
-                            "    (place (locset \"root\") (mechanism \"expsyn\"))\n"
+                            "    (place (locset \"root\") (mechanism \"expsyn\") \"root_synapse\")\n"
                             "    (paint (region \"my_soma\") (temperature-kelvin 270))))",
                             "(arbor-component\n"
                             "  (meta-data (version \"" + arborio::acc_version() +"\"))\n"
@@ -997,7 +1002,7 @@ TEST(doc_expressions, parse) {
                             "      (locset-def \"root\" (root)))\n"
                             "    (decor\n"
                             "      (default (membrane-potential -55.000000))\n"
-                            "      (place (locset \"root\") (mechanism \"expsyn\"))\n"
+                            "      (place (locset \"root\") (mechanism \"expsyn\") \"root_synapse\")\n"
                             "      (paint (region \"my_soma\") (temperature-kelvin 270)))\n"
                             "    (morphology\n"
                             "       (branch 0 -1\n"
diff --git a/test/unit/test_simulation.cpp b/test/unit/test_simulation.cpp
index 04c41ab4..569c4fa4 100644
--- a/test/unit/test_simulation.cpp
+++ b/test/unit/test_simulation.cpp
@@ -22,10 +22,8 @@ struct play_spikes: public recipe {
 
     cell_size_type num_cells() const override { return spike_times_.size(); }
     cell_kind get_cell_kind(cell_gid_type) const override { return cell_kind::spike_source; }
-    cell_size_type num_sources(cell_gid_type) const override { return 1; }
-    cell_size_type num_targets(cell_gid_type) const override { return 0; }
     util::unique_any get_cell_description(cell_gid_type gid) const override {
-        return spike_source_cell{spike_times_.at(gid)};
+        return spike_source_cell("src", spike_times_.at(gid));
     }
 
     std::vector<schedule> spike_times_;
@@ -81,11 +79,9 @@ struct lif_chain: public recipe {
     cell_size_type num_cells() const override { return n_; }
 
     cell_kind get_cell_kind(cell_gid_type) const override { return cell_kind::lif; }
-    cell_size_type num_sources(cell_gid_type) const override { return 1; }
-    cell_size_type num_targets(cell_gid_type) const override { return 1; }
     util::unique_any get_cell_description(cell_gid_type) const override {
         // A hair-trigger LIF cell with tiny time constant and no refractory period.
-        lif_cell lif;
+        lif_cell lif("src", "tgt");
         lif.tau_m = 0.01;           // time constant (ms)
         lif.t_ref = 0;              // refactory period (ms)
         lif.V_th = lif.E_L + 0.001; // threshold voltage 1 µV higher than resting
@@ -94,7 +90,7 @@ struct lif_chain: public recipe {
 
     std::vector<cell_connection> connections_on(cell_gid_type target) const override {
         if (target) {
-            return {cell_connection({target-1, 0}, 0, weight_, delay_)};
+            return {cell_connection({target-1, "src"}, {"tgt"}, weight_, delay_)};
         }
         else {
             return {};
@@ -106,7 +102,7 @@ struct lif_chain: public recipe {
             return {};
         }
         else {
-            return {schedule_generator(0, weight_, triggers_)};
+            return {schedule_generator({"tgt"}, weight_, triggers_)};
         }
     }
 
diff --git a/test/unit/test_spike_source.cpp b/test/unit/test_spike_source.cpp
index b0703a4b..82a5173f 100644
--- a/test/unit/test_spike_source.cpp
+++ b/test/unit/test_spike_source.cpp
@@ -16,8 +16,9 @@ using ss_recipe = homogeneous_recipe<cell_kind::spike_source, spike_source_cell>
 // cell_kind enum value.
 TEST(spike_source, cell_kind)
 {
-    ss_recipe rec(1u, spike_source_cell{explicit_schedule({})});
-    spike_source_cell_group group({0}, rec);
+    ss_recipe rec(1u, spike_source_cell("src", explicit_schedule({})));
+    cell_label_range srcs, tgts;
+    spike_source_cell_group group({0}, rec, srcs, tgts);
 
     EXPECT_EQ(cell_kind::spike_source, group.get_cell_kind());
 }
@@ -39,8 +40,9 @@ static std::vector<time_type> spike_times(const std::vector<spike>& evs) {
 TEST(spike_source, matches_time_seq)
 {
     auto test_seq = [](schedule seq) {
-        ss_recipe rec(1u, spike_source_cell{seq});
-        spike_source_cell_group group({0}, rec);
+        ss_recipe rec(1u, spike_source_cell("src", seq));
+        cell_label_range srcs, tgts;
+        spike_source_cell_group group({0}, rec, srcs, tgts);
 
         // epoch ending at 10ms
         epoch ep(0, 0., 10.);
@@ -66,8 +68,9 @@ TEST(spike_source, matches_time_seq)
 TEST(spike_source, reset)
 {
     auto test_seq = [](schedule seq) {
-        ss_recipe rec(1u, spike_source_cell{seq});
-        spike_source_cell_group group({0}, rec);
+        ss_recipe rec(1u, spike_source_cell("src", seq));
+        cell_label_range srcs, tgts;
+        spike_source_cell_group group({0}, rec, srcs, tgts);
 
         // Advance for 10 ms and store generated spikes in spikes1.
         epoch ep(0, 0., 10.);
@@ -96,8 +99,9 @@ TEST(spike_source, exhaust)
 {
     // This test assumes that seq will exhaust itself before t=10 ms.
     auto test_seq = [](schedule seq) {
-        ss_recipe rec(1u, spike_source_cell{seq});
-        spike_source_cell_group group({0}, rec);
+        ss_recipe rec(1u, spike_source_cell("src", seq));
+        cell_label_range srcs, tgts;
+        spike_source_cell_group group({0}, rec, srcs, tgts);
 
         // epoch ending at 10ms
         epoch ep(0, 0., 10.);
diff --git a/test/unit/test_spikes.cpp b/test/unit/test_spikes.cpp
index b2b51586..ce47431f 100644
--- a/test/unit/test_spikes.cpp
+++ b/test/unit/test_spikes.cpp
@@ -221,9 +221,9 @@ TEST(SPIKES_TEST_CLASS, threshold_watcher_interpolation) {
     for (unsigned i = 0; i < 8; i++) {
         arb::decor decor;
         decor.set_default(arb::cv_policy_every_segment());
-        decor.place("\"mid\"", arb::threshold_detector{10});
-        decor.place("\"mid\"", arb::i_clamp::box(0.01+i*dt, duration, 0.5));
-        decor.place("\"mid\"", arb::mechanism_desc("hh"));
+        decor.place("\"mid\"", arb::threshold_detector{10}, "detector");
+        decor.place("\"mid\"", arb::i_clamp::box(0.01+i*dt, duration, 0.5), "clamp");
+        decor.place("\"mid\"", arb::mechanism_desc("expsyn"), "synapse");
 
         arb::cable_cell cell(morpho, dict, decor);
         cable1d_recipe rec({cell});
diff --git a/test/unit/test_synapses.cpp b/test/unit/test_synapses.cpp
index 60aa02cb..264eae23 100644
--- a/test/unit/test_synapses.cpp
+++ b/test/unit/test_synapses.cpp
@@ -34,9 +34,9 @@ TEST(synapses, add_to_cell) {
 
     auto description = make_cell_soma_only(false);
 
-    description.decorations.place(mlocation{0, 0.1}, "expsyn");
-    description.decorations.place(mlocation{0, 0.2}, "exp2syn");
-    description.decorations.place(mlocation{0, 0.3}, "expsyn");
+    description.decorations.place(mlocation{0, 0.1}, "expsyn", "synapse0");
+    description.decorations.place(mlocation{0, 0.2}, "exp2syn", "synapse1");
+    description.decorations.place(mlocation{0, 0.3}, "expsyn", "synapse2");
 
     cable_cell cell(description);
 
@@ -55,7 +55,7 @@ TEST(synapses, add_to_cell) {
     EXPECT_EQ("exp2syn", syns["exp2syn"][0].item.name());
 
     // adding a synapse to an invalid branch location should throw.
-    description.decorations.place(mlocation{1, 0.3}, "expsyn");
+    description.decorations.place(mlocation{1, 0.3}, "expsyn", "synapse3");
     EXPECT_THROW((cell=description), std::runtime_error);
 }
 
-- 
GitLab