diff --git a/arbor/CMakeLists.txt b/arbor/CMakeLists.txt
index 2af505ec75acb2918946940b2505dd8532780dd0..997128750b383b6e84e16880ad0179a7abd9b1da 100644
--- a/arbor/CMakeLists.txt
+++ b/arbor/CMakeLists.txt
@@ -31,7 +31,6 @@ set(arbor_sources
     memory/util.cpp
     morph/embed_pwlin.cpp
     morph/label_dict.cpp
-    morph/label_parse.cpp
     morph/locset.cpp
     morph/morphexcept.cpp
     morph/morphology.cpp
diff --git a/arbor/cv_policy.cpp b/arbor/cv_policy.cpp
index 65deddcede9cec00a801933c99b9bdbfbff9890a..11135cf7b16d1f41f7e6d827db61b1f64edbb2fb 100644
--- a/arbor/cv_policy.cpp
+++ b/arbor/cv_policy.cpp
@@ -1,4 +1,5 @@
 #include <utility>
+#include <ostream>
 #include <vector>
 
 #include <arbor/cable_cell.hpp>
@@ -35,6 +36,11 @@ struct cv_policy_plus_: cv_policy_base {
 
     region domain() const override { return join(lhs_.domain(), rhs_.domain()); }
 
+    std::ostream& print(std::ostream& os) override {
+        os << "(join " << lhs_ << ' ' << rhs_ << ')';
+        return os;
+    }
+
     cv_policy lhs_, rhs_;
 };
 
@@ -56,6 +62,11 @@ struct cv_policy_bar_: cv_policy_base {
 
     region domain() const override { return join(lhs_.domain(), rhs_.domain()); }
 
+    std::ostream& print(std::ostream& os) override {
+        os << "(replace " << lhs_ << ' ' << rhs_ << ')';
+        return os;
+    }
+
     cv_policy lhs_, rhs_;
 };
 
diff --git a/arbor/include/arbor/cv_policy.hpp b/arbor/include/arbor/cv_policy.hpp
index 4ddbdfcb95e7d8b26d326d1b6e764aac97789240..917610a1d18ff5ff1ced7f04ab28604453747080 100644
--- a/arbor/include/arbor/cv_policy.hpp
+++ b/arbor/include/arbor/cv_policy.hpp
@@ -65,6 +65,7 @@ struct cv_policy_base {
     virtual region domain() const = 0;
     virtual std::unique_ptr<cv_policy_base> clone() const = 0;
     virtual ~cv_policy_base() {}
+    virtual std::ostream& print(std::ostream&) = 0;
 };
 
 using cv_policy_base_ptr = std::unique_ptr<cv_policy_base>;
@@ -93,6 +94,10 @@ struct cv_policy {
         return policy_ptr->domain();
     }
 
+    friend std::ostream& operator<<(std::ostream& o, const cv_policy& p) {
+        return p.policy_ptr->print(o);
+    }
+
 private:
     cv_policy_base_ptr policy_ptr;
 };
@@ -117,6 +122,10 @@ struct cv_policy_explicit: cv_policy_base {
     cv_policy_base_ptr clone() const override;
     locset cv_boundary_points(const cable_cell&) const override;
     region domain() const override;
+    std::ostream& print(std::ostream& os) override {
+        os << "(explicit " << locs_ << ' ' << domain_ << ')';
+        return os;
+    }
 
 private:
     locset locs_;
@@ -130,6 +139,10 @@ struct cv_policy_single: cv_policy_base {
     cv_policy_base_ptr clone() const override;
     locset cv_boundary_points(const cable_cell&) const override;
     region domain() const override;
+    std::ostream& print(std::ostream& os) override {
+        os << "(single " << domain_ << ')';
+        return os;
+    }
 
 private:
     region domain_;
@@ -145,6 +158,10 @@ struct cv_policy_max_extent: cv_policy_base {
     cv_policy_base_ptr clone() const override;
     locset cv_boundary_points(const cable_cell&) const override;
     region domain() const override;
+    std::ostream& print(std::ostream& os) override {
+        os << "(max-extent " << max_extent_ << ' ' << domain_ << ' ' << flags_ << ')';
+        return os;
+    }
 
 private:
     double max_extent_;
@@ -162,6 +179,10 @@ struct cv_policy_fixed_per_branch: cv_policy_base {
     cv_policy_base_ptr clone() const override;
     locset cv_boundary_points(const cable_cell&) const override;
     region domain() const override;
+    std::ostream& print(std::ostream& os) override {
+        os << "(fixed-per-branch " << cv_per_branch_ << ' ' << domain_ << ' ' << flags_ << ')';
+        return os;
+    }
 
 private:
     unsigned cv_per_branch_;
@@ -176,6 +197,10 @@ struct cv_policy_every_segment: cv_policy_base {
     cv_policy_base_ptr clone() const override;
     locset cv_boundary_points(const cable_cell&) const override;
     region domain() const override;
+    std::ostream& print(std::ostream& os) override {
+        os << "(every-segment " << domain_ << ')';
+        return os;
+    }
 
 private:
     region domain_;
diff --git a/arbor/include/arbor/morph/label_parse.hpp b/arbor/include/arbor/morph/label_parse.hpp
deleted file mode 100644
index db3b21c18e0930645b7e0a1147b8259390b27f74..0000000000000000000000000000000000000000
--- a/arbor/include/arbor/morph/label_parse.hpp
+++ /dev/null
@@ -1,25 +0,0 @@
-#pragma once
-
-#include <any>
-
-#include <arbor/arbexcept.hpp>
-#include <arbor/morph/region.hpp>
-#include <arbor/s_expr.hpp>
-#include <arbor/util/expected.hpp>
-
-namespace arb {
-
-struct label_parse_error: arb::arbor_exception {
-    label_parse_error(const std::string& msg);
-};
-
-template <typename T>
-using parse_hopefully = arb::util::expected<T, label_parse_error>;
-
-parse_hopefully<std::any> parse_label_expression(const std::string&);
-parse_hopefully<std::any> parse_label_expression(const s_expr&);
-
-parse_hopefully<arb::region> parse_region_expression(const std::string& s);
-parse_hopefully<arb::locset> parse_locset_expression(const std::string& s);
-
-} // namespace arb
diff --git a/arbor/include/arbor/morph/locset.hpp b/arbor/include/arbor/morph/locset.hpp
index b66f268cdfa2f2ffba309f1ead5237134f49d206..71a60778434e19bcb3b35cd3f8c052098bd9580d 100644
--- a/arbor/include/arbor/morph/locset.hpp
+++ b/arbor/include/arbor/morph/locset.hpp
@@ -25,7 +25,8 @@ public:
     explicit locset(Impl&& impl):
         impl_(new wrap<Impl>(std::forward<Impl>(impl))) {}
 
-    template <typename Impl>
+    template <typename Impl,
+              typename = std::enable_if_t<std::is_base_of<locset_tag, std::decay_t<Impl>>::value>>
     explicit locset(const Impl& impl):
         impl_(new wrap<Impl>(impl)) {}
 
@@ -48,10 +49,6 @@ public:
     locset(mlocation other);
     locset(mlocation_list other);
 
-    // Implicitly convert string to named locset expression.
-    locset(const std::string& label);
-    locset(const char* label);
-
     template <typename Impl,
               typename = std::enable_if_t<std::is_base_of<locset_tag, std::decay_t<Impl>>::value>>
     locset& operator=(Impl&& other) {
@@ -164,7 +161,7 @@ locset on_branches(double pos);
 // s.t. dist(h, L) = r * max {dist(h, t) | t is a distal point in C}.
 locset on_components(double relpos, region reg);
 
-// Support of a locset (x s.t. x in locset).
+// Set of locations in the locset with duplicates removed, i.e. the support of the input multiset
 locset support(locset);
 
 } // namespace ls
diff --git a/arbor/include/arbor/morph/region.hpp b/arbor/include/arbor/morph/region.hpp
index 62afa9e0541013cf2b0ed2520c4672ac8779ca99..0f048860b3ca2b954bf68c0d63504e3b2a80fcb2 100644
--- a/arbor/include/arbor/morph/region.hpp
+++ b/arbor/include/arbor/morph/region.hpp
@@ -23,7 +23,8 @@ public:
     explicit region(Impl&& impl):
         impl_(new wrap<Impl>(std::forward<Impl>(impl))) {}
 
-    template <typename Impl>
+   template <typename Impl,
+              typename = std::enable_if_t<std::is_base_of<region_tag, std::decay_t<Impl>>::value>>
     explicit region(const Impl& impl):
         impl_(new wrap<Impl>(impl)) {}
 
@@ -47,7 +48,8 @@ public:
         return *this;
     }
 
-    template <typename Impl>
+    template <typename Impl,
+              typename = std::enable_if_t<std::is_base_of<region_tag, std::decay_t<Impl>>::value>>
     region& operator=(const Impl& other) {
         impl_ = new wrap<Impl>(other);
         return *this;
@@ -58,10 +60,6 @@ public:
     region(mextent);
     region(mcable_list);
 
-    // Implicitly convert string to named region expression.
-    region(const std::string& label);
-    region(const char* label);
-
     friend mextent thingify(const region& r, const mprovider& m) {
         return r.impl_->thingify(m);
     }
diff --git a/arbor/include/arbor/string_literals.hpp b/arbor/include/arbor/string_literals.hpp
deleted file mode 100644
index 056c25231abd0078387c15b12d0cbc2207885c41..0000000000000000000000000000000000000000
--- a/arbor/include/arbor/string_literals.hpp
+++ /dev/null
@@ -1,12 +0,0 @@
-#pragma once
-
-#include <string>
-
-namespace arb::literals {
-
-inline
-std::string operator "" _lab(const char* s, std::size_t) {
-    return std::string("\"") + s + "\"";
-}
-
-} // namespace arb
diff --git a/arbor/include/arbor/util/expected.hpp b/arbor/include/arbor/util/expected.hpp
index 947783bc5c970e78a8576e962614e3a55bb36824..45ab99bc06c435007b49deb87b6064d15705d554 100644
--- a/arbor/include/arbor/util/expected.hpp
+++ b/arbor/include/arbor/util/expected.hpp
@@ -288,7 +288,6 @@ struct expected {
         if (*this) return std::get<0>(data_);
         throw bad_expected_access(error());
     }
-
     T&& value() && {
         if (*this) return std::get<0>(std::move(data_));
         throw bad_expected_access(error());
@@ -298,6 +297,23 @@ struct expected {
         throw bad_expected_access(error());
     }
 
+    T& unwrap() & {
+        if (*this) return std::get<0>(data_);
+        throw error();
+    }
+    const T& unwrap() const& {
+        if (*this) return std::get<0>(data_);
+        throw error();
+    }
+    T&& unwrap() && {
+        if (*this) return std::get<0>(std::move(data_));
+        throw error();
+    }
+    const T&& unwrap() const&& {
+        if (*this) return std::get<0>(std::move(data_));
+        throw error();
+    }
+
     const E& error() const& { return std::get<1>(data_).value(); }
     E& error() & { return std::get<1>(data_).value(); }
 
@@ -505,7 +521,5 @@ template <typename T1, typename E1, typename T2, typename E2,
 inline bool operator!=(const expected<T1, E1>& a, const expected<T2, E2>& b) {
     return a? !b || !std::is_void_v<T2> || !std::is_void_v<T1>: b || a.error()!=b.error();
 }
-
-
 } // namespace util
 } // namespace arb
diff --git a/arbor/morph/locset.cpp b/arbor/morph/locset.cpp
index ea37fb008f41dc4eebdc478753917c475a1ecd46..8b15a2954df8deed91c616449ad27671cca6c118 100644
--- a/arbor/morph/locset.cpp
+++ b/arbor/morph/locset.cpp
@@ -3,7 +3,6 @@
 #include <numeric>
 
 #include <arbor/math.hpp>
-#include <arbor/morph/label_parse.hpp>
 #include <arbor/morph/locset.hpp>
 #include <arbor/morph/morphexcept.hpp>
 #include <arbor/morph/morphology.hpp>
@@ -152,7 +151,10 @@ std::ostream& operator<<(std::ostream& o, const segments_& x) {
 
 // Proportional location on every branch.
 
-struct on_branches_ { double pos; };
+struct on_branches_: locset_tag {
+    explicit on_branches_(double p): pos{p} {}
+    double pos;
+};
 
 locset on_branches(double pos) {
     return locset{on_branches_{pos}};
@@ -397,7 +399,10 @@ std::ostream& operator<<(std::ostream& o, const on_components_& x) {
 
 // Uniform locset.
 
-struct uniform_ {
+struct uniform_: locset_tag {
+    uniform_(const arb::region& reg_, unsigned left_, unsigned right_, uint64_t seed_):
+        reg{reg_}, left{left_}, right{right_}, seed{seed_}
+    {}
     region reg;
     unsigned left;
     unsigned right;
@@ -525,7 +530,8 @@ std::ostream& operator<<(std::ostream& o, const lsup_& x) {
 // Restrict a locset on to a region: returns all locations in the locset that
 // are also in the region.
 
-struct lrestrict_ {
+struct lrestrict_: locset_tag {
+    explicit lrestrict_(const locset& l, const region& r): ls{l}, reg{r} {}
     locset ls;
     region reg;
 };
@@ -588,15 +594,4 @@ locset::locset(mlocation_list ll) {
     *this = ls::location_list(std::move(ll));
 }
 
-locset::locset(const std::string& desc) {
-    if (auto r=parse_locset_expression(desc)) {
-        *this = *r;
-    }
-    else {
-        throw r.error();
-    }
-}
-
-locset::locset(const char* label): locset(std::string(label)) {}
-
 } // namespace arb
diff --git a/arbor/morph/region.cpp b/arbor/morph/region.cpp
index 64234f554fb3cd00cd36fe7394b4a9bb247c6457..539b4683c94025bf586bd73f38f09d1940a1371f 100644
--- a/arbor/morph/region.cpp
+++ b/arbor/morph/region.cpp
@@ -4,7 +4,6 @@
 #include <unordered_set>
 #include <vector>
 
-#include <arbor/morph/label_parse.hpp>
 #include <arbor/morph/locset.hpp>
 #include <arbor/morph/primitives.hpp>
 #include <arbor/morph/morphexcept.hpp>
@@ -215,7 +214,8 @@ std::ostream& operator<<(std::ostream& o, const all_& t) {
 
 // Region comprising points up to `distance` distal to a point in `start`.
 
-struct distal_interval_ {
+struct distal_interval_: region_tag {
+    distal_interval_(const locset& ls, double d): start{ls}, distance{d} {}
     locset start;
     double distance; //um
 };
@@ -289,7 +289,8 @@ std::ostream& operator<<(std::ostream& o, const distal_interval_& d) {
 
 // Region comprising points up to `distance` proximal to a point in `end`.
 
-struct proximal_interval_ {
+struct proximal_interval_: region_tag {
+    proximal_interval_(const locset& ls, double d): end{ls}, distance{d} {}
     locset end;
     double distance; //um
 };
@@ -361,7 +362,8 @@ mextent radius_cmp(const mprovider& p, region r, double val, comp_op op) {
 }
 
 // Region with all segments with radius less than r
-struct radius_lt_ {
+struct radius_lt_: region_tag {
+    radius_lt_(const region& rg, double d): reg{rg}, val{d} {}
     region reg;
     double val; //um
 };
@@ -379,7 +381,8 @@ std::ostream& operator<<(std::ostream& o, const radius_lt_& r) {
 }
 
 // Region with all segments with radius less than r
-struct radius_le_ {
+struct radius_le_: region_tag {
+    radius_le_(const region& rg, double d): reg{rg}, val{d} {}
     region reg;
     double val; //um
 };
@@ -397,7 +400,8 @@ std::ostream& operator<<(std::ostream& o, const radius_le_& r) {
 }
 
 // Region with all segments with radius greater than r
-struct radius_gt_ {
+struct radius_gt_: region_tag {
+    radius_gt_(const region& rg, double d): reg{rg}, val{d} {}
     region reg;
     double val; //um
 };
@@ -415,7 +419,8 @@ std::ostream& operator<<(std::ostream& o, const radius_gt_& r) {
 }
 
 // Region with all segments with radius greater than or equal to r
-struct radius_ge_ {
+struct radius_ge_: region_tag {
+    radius_ge_(const region& rg, double d): reg{rg}, val{d} {}
     region reg;
     double val; //um
 };
@@ -445,7 +450,8 @@ mextent projection_cmp(const mprovider& p, double v, comp_op op) {
 }
 
 // Region with all segments with projection less than val
-struct projection_lt_{
+struct projection_lt_: region_tag {
+    projection_lt_(double d): val{d} {}
     double val; //um
 };
 
@@ -462,7 +468,8 @@ std::ostream& operator<<(std::ostream& o, const projection_lt_& r) {
 }
 
 // Region with all segments with projection less than or equal to val
-struct projection_le_{
+struct projection_le_: region_tag {
+    projection_le_(double d): val{d} {}
     double val; //um
 };
 
@@ -479,7 +486,8 @@ std::ostream& operator<<(std::ostream& o, const projection_le_& r) {
 }
 
 // Region with all segments with projection greater than val
-struct projection_gt_ {
+struct projection_gt_: region_tag {
+    projection_gt_(double d): val{d} {}
     double val; //um
 };
 
@@ -496,7 +504,8 @@ std::ostream& operator<<(std::ostream& o, const projection_gt_& r) {
 }
 
 // Region with all segments with projection greater than val
-struct projection_ge_ {
+struct projection_ge_: region_tag {
+    projection_ge_(double d): val{d} {}
     double val; //um
 };
 
@@ -559,7 +568,8 @@ std::ostream& operator<<(std::ostream& o, const named_& x) {
 
 // Adds all cover points to a region.
 // Ensures that all valid representations of all fork points in the region are included.
-struct super_ {
+struct super_: region_tag {
+    explicit super_(const region& rg): reg{rg} {}
     region reg;
 };
 
@@ -745,17 +755,6 @@ region::region() {
 
 // Implicit constructors/converters.
 
-region::region(const std::string& desc) {
-    if (auto r=parse_region_expression(desc)) {
-        *this = *r;
-    }
-    else {
-        throw r.error();
-    }
-}
-
-region::region(const char* label): region(std::string(label)) {}
-
 region::region(mcable c) {
     *this = reg::cable(c.branch, c.prox_pos, c.dist_pos);
 }
diff --git a/arborio/CMakeLists.txt b/arborio/CMakeLists.txt
index 63855f42a7029aa50681f925d4d6ba2a775564b0..104afdacefb5553d3cb727ad51dc9adcff7376a0 100644
--- a/arborio/CMakeLists.txt
+++ b/arborio/CMakeLists.txt
@@ -3,6 +3,8 @@ set(arborio-sources
     neurolucida.cpp
     swcio.cpp
     cableio.cpp
+    cv_policy_parse.cpp
+    label_parse.cpp
 )
 if(ARB_WITH_NEUROML)
     list(APPEND arborio-sources
diff --git a/arborio/cableio.cpp b/arborio/cableio.cpp
index 998dff37682fc0ee4b8afe954443127da810fd4b..4440cfa743a97a9e19e87165ec52f582ebf9038f 100644
--- a/arborio/cableio.cpp
+++ b/arborio/cableio.cpp
@@ -2,13 +2,14 @@
 #include <sstream>
 #include <unordered_map>
 
-#include <arbor/morph/label_parse.hpp>
 #include <arbor/s_expr.hpp>
 #include <arbor/util/pp_util.hpp>
 #include <arbor/util/any_visitor.hpp>
 
+#include <arborio/label_parse.hpp>
 #include <arborio/cableio.hpp>
 
+#include "parse_helpers.hpp"
 #include "parse_s_expr.hpp"
 
 namespace arborio {
@@ -32,7 +33,6 @@ cableio_version_error::cableio_version_error(const std::string& version):
     arb::arbor_exception("Unsupported cable-cell format version `" + version + "`")
 {}
 
-struct nil_tag {};
 
 // Define s-expr makers for various types
 s_expr mksexp(const init_membrane_potential& p) {
@@ -86,7 +86,9 @@ s_expr mksexp(const msegment& seg) {
 }
 // This can be removed once cv_policy is removed from the decor.
 s_expr mksexp(const cv_policy& c) {
-    return s_expr();
+    std::stringstream s;
+    s << c;
+    return parse_s_expr(s.str());
 }
 s_expr mksexp(const decor& d) {
     auto round_trip = [](auto& x) {
@@ -179,36 +181,6 @@ std::ostream& write_component(std::ostream& o, const cable_cell_component& x) {
 
 // Anonymous namespace containing helper functions and types for parsing s-expr
 namespace {
-// Test whether a value wrapped in std::any can be converted to a target type
-template <typename T>
-bool match(const std::type_info& info) {
-    return info == typeid(T);
-}
-template <>
-bool match<double>(const std::type_info& info) {
-    return info == typeid(double) || info == typeid(int);
-}
-
-// Convert a value wrapped in a std::any to target type.
-template <typename T>
-T eval_cast(std::any arg) {
-    return std::move(std::any_cast<T&>(arg));
-}
-template <>
-double eval_cast<double>(std::any arg) {
-    if (arg.type()==typeid(int)) return std::any_cast<int>(arg);
-    return std::any_cast<double>(arg);
-}
-
-// Convert a value wrapped in a std::any to an optional std::variant type
-template <typename T, std::size_t I=0>
-std::optional<T> eval_cast_variant(const std::any& a) {
-    if constexpr (I<std::variant_size_v<T>) {
-        using var_type = std::variant_alternative_t<I, T>;
-        return match<var_type>(a.type())? eval_cast<var_type>(a): eval_cast_variant<T, I+1>(a);
-    }
-    return std::nullopt;
-}
 
 // Useful tuple aliases
 using envelope_tuple = std::tuple<double,double>;
@@ -358,142 +330,6 @@ cable_cell_component make_component(const meta_data& m, const T& d) {
     return cable_cell_component{m, d};
 }
 
-// Evaluator: member of make_call, make_arg_vec_call, make_mech_call, make_branch_call, make_unordered_call
-struct evaluator {
-    using any_vec = std::vector<std::any>;
-    using eval_fn = std::function<std::any(any_vec)>;
-    using args_fn = std::function<bool(const any_vec&)>;
-
-    eval_fn eval;
-    args_fn match_args;
-    const char* message;
-
-    evaluator(eval_fn f, args_fn a, const char* m):
-        eval(std::move(f)),
-        match_args(std::move(a)),
-        message(m)
-    {}
-
-    std::any operator()(any_vec args) {
-        return eval(std::move(args));
-    }
-};
-
-// Test whether a list of arguments passed as a std::vector<std::any> can be converted
-// to the types in Args.
-//
-// For example, the following would return true:
-//
-//  call_match<int, int, string>(vector<any(4), any(12), any(string("hello"))>)
-template <typename... Args>
-struct call_match {
-    template <std::size_t I, typename T, typename Q, typename... Rest>
-    bool match_args_impl(const std::vector<std::any>& args) const {
-        return match<T>(args[I].type()) && match_args_impl<I+1, Q, Rest...>(args);
-    }
-
-    template <std::size_t I, typename T>
-    bool match_args_impl(const std::vector<std::any>& args) const {
-        return match<T>(args[I].type());
-    }
-
-    template <std::size_t I>
-    bool match_args_impl(const std::vector<std::any>& args) const {
-        return true;
-    }
-
-    bool operator()(const std::vector<std::any>& args) const {
-        const auto nargs_in = args.size();
-        const auto nargs_ex = sizeof...(Args);
-        return nargs_in==nargs_ex? match_args_impl<0, Args...>(args): false;
-    }
-};
-// Evaluate a call to a function where the arguments are provided as a std::vector<std::any>.
-// The arguments are expanded and converted to the correct types, as specified by Args.
-template <typename... Args>
-struct call_eval {
-    using ftype = std::function<std::any(Args...)>;
-    ftype f;
-    call_eval(ftype f): f(std::move(f)) {}
-
-    template<std::size_t... I>
-    std::any expand_args_then_eval(const std::vector<std::any>& args, std::index_sequence<I...>) {
-        return f(eval_cast<Args>(std::move(args[I]))...);
-    }
-
-    std::any operator()(const std::vector<std::any>& args) {
-        return expand_args_then_eval(std::move(args), std::make_index_sequence<sizeof...(Args)>());
-    }
-};
-// Wrap call_match and call_eval in an evaluator
-template <typename... Args>
-struct make_call {
-    evaluator state;
-
-    template <typename F>
-    make_call(F&& f, const char* msg="call"):
-        state(call_eval<Args...>(std::forward<F>(f)), call_match<Args...>(), msg)
-    {}
-    operator evaluator() const {
-        return state;
-    }
-};
-
-// Test whether a list of arguments passed as a std::vector<std::any> can be converted
-// to a std::vector<std::variant<Args...>>.
-//
-// For example, the following would return true:
-//
-//  call_match<int, string>(vector<any(4), any(12), any(string("hello"))>)
-template <typename... Args>
-struct arg_vec_match {
-    template <typename T, typename Q, typename... Rest>
-    bool match_args_impl(const std::any& arg) const {
-        return match<T>(arg.type()) || match_args_impl<Q, Rest...>(arg);
-    }
-
-    template <typename T>
-    bool match_args_impl(const std::any& arg) const {
-        return match<T>(arg.type());
-    }
-
-    bool operator()(const std::vector<std::any>& args) const {
-        for (const auto& a: args) {
-            if (!match_args_impl<Args...>(a)) return false;
-        }
-        return true;
-    }
-};
-// Evaluate a call to a function where the arguments are provided as a std::vector<std::any>.
-// The arguments are converted to std::variant<Args...> and passed to the function as a std::vector.
-template <typename... Args>
-struct arg_vec_eval {
-    using ftype = std::function<std::any(std::vector<std::variant<Args...>>)>;
-    ftype f;
-    arg_vec_eval(ftype f): f(std::move(f)) {}
-
-    std::any operator()(const std::vector<std::any>& args) {
-        std::vector<std::variant<Args...>> vars;
-        for (const auto& a: args) {
-            vars.push_back(eval_cast_variant<std::variant<Args...>>(a).value());
-        }
-        return f(vars);
-    }
-};
-// Wrap arg_vec_match and arg_vec_eval in an evaluator
-template <typename... Args>
-struct make_arg_vec_call {
-    evaluator state;
-
-    template <typename F>
-    make_arg_vec_call(F&& f, const char* msg="argument vector"):
-        state(arg_vec_eval<Args...>(std::forward<F>(f)), arg_vec_match<Args...>(), msg)
-    {}
-    operator evaluator() const {
-        return state;
-    }
-};
-
 // Test whether a list of arguments passed as a std::vector<std::any> can be converted
 // to a string followed by any number of std::pair<std::string, double>
 struct mech_match {
@@ -654,26 +490,9 @@ parse_hopefully<std::vector<std::any>> eval_args(const s_expr& e, const eval_map
 
 parse_hopefully<std::any> eval(const s_expr& e, const eval_map& map, const eval_vec& vec) {
     if (e.is_atom()) {
-        auto& t = e.atom();
-        switch (t.kind) {
-        case tok::integer:
-            return {std::stoi(t.spelling)};
-        case tok::real:
-            return {std::stod(t.spelling)};
-        case tok::nil:
-            return {nil_tag()};
-        case tok::string:
-            return std::any{std::string(t.spelling)};
-            // An arbitrary symbol in a region/locset expression is an error, and is
-            // often a result of not quoting a label correctly.
-        case tok::symbol:
-            return util::unexpected(cableio_parse_error("Unexpected symbol "+e.atom().spelling, location(e)));
-        case tok::error:
-            return util::unexpected(cableio_parse_error("Unexpected term "+e.atom().spelling, location(e)));
-        default:
-            return util::unexpected(cableio_parse_error("Unexpected term "+e.atom().spelling, location(e)));
-        }
+        return eval_atom<cableio_parse_error>(e);
     }
+
     if (e.head().is_atom()) {
         // If this is not a symbol, parse as a tuple
         if (e.head().atom().kind != tok::symbol) {
diff --git a/arborio/cv_policy_parse.cpp b/arborio/cv_policy_parse.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..c013efe751499b4c34b3b0f1dba052cdb6ad8656
--- /dev/null
+++ b/arborio/cv_policy_parse.cpp
@@ -0,0 +1,204 @@
+#include <any>
+#include <limits>
+#include <optional>
+
+#include <arborio/label_parse.hpp>
+#include <arborio/cv_policy_parse.hpp>
+
+#include <arbor/arbexcept.hpp>
+#include <arbor/cv_policy.hpp>
+#include <arbor/s_expr.hpp>
+#include <arbor/util/expected.hpp>
+
+
+#include "parse_helpers.hpp"
+
+namespace arborio {
+
+cv_policy_parse_error::cv_policy_parse_error(const std::string& msg, const arb::src_location& loc):
+    arb::arbor_exception(concat("error in CV policy description: ", msg," at :", loc.line, ":", loc.column))
+{}
+
+cv_policy_parse_error::cv_policy_parse_error(const std::string& msg):
+    arb::arbor_exception(concat("error in CV policy description: ", msg))
+{}
+
+namespace {
+
+template<typename T> using parse_hopefully = arb::util::expected<T, cv_policy_parse_error>;
+
+std::unordered_multimap<std::string, evaluator>
+eval_map {{"default",
+           make_call<>([] () { return arb::cv_policy{arb::default_cv_policy()}; },
+                       "'default' with no arguments")},
+          {"every-segment",
+           make_call<>([] () { return arb::cv_policy{arb::cv_policy_every_segment()}; },
+                       "'every-segment' with no arguments")},
+          {"every-segment",
+           make_call<region>([] (const region& r) { return arb::cv_policy{arb::cv_policy_every_segment(r) }; },
+                             "'every-segment' with one argument (every-segment (reg:region))")},
+          {"fixed-per-branch",
+           make_call<int>([] (int i) { return arb::cv_policy{arb::cv_policy_fixed_per_branch(i) }; },
+                          "'every-segment' with one argument (fixed-per-branch (count:int))")},
+          {"fixed-per-branch",
+           make_call<int, region>([] (int i, const region& r) { return arb::cv_policy{arb::cv_policy_fixed_per_branch(i, r) }; },
+                                  "'every-segment' with two arguments (fixed-per-branch (count:int) (reg:region))")},
+          {"fixed-per-branch",
+           make_call<int, region, int>([] (int i, const region& r, int f) { return arb::cv_policy{arb::cv_policy_fixed_per_branch(i, r, f) }; },
+                                       "'fixed-per-branch' with three arguments (fixed-per-branch (count:int) (reg:region) (flags:int))")},
+          {"max-extent",
+           make_call<double>([] (double i) { return arb::cv_policy{arb::cv_policy_max_extent(i) }; },
+                             "'max-extent' with one argument (max-extent (length:double))")},
+          {"max-extent",
+           make_call<double, region>([] (double i, const region& r) { return arb::cv_policy{arb::cv_policy_max_extent(i, r) }; },
+                                     "'max-extent' with two arguments (max-extent (length:double) (reg:region))")},
+          {"max-extent",
+           make_call<double, region, int>([] (double i, const region& r, int f) { return arb::cv_policy{arb::cv_policy_max_extent(i, r, f) }; },
+                                          "'max-extent' with three arguments (max-extent (length:double) (reg:region) (flags:int))")},
+          {"single",
+           make_call<>([] () { return arb::cv_policy{arb::cv_policy_single()}; },
+                       "'single' with no arguments")},
+          {"single",
+           make_call<region>([] (const region& r) { return arb::cv_policy{arb::cv_policy_single(r) }; },
+                             "'single' with one argument (single (reg:region))")},
+          {"explicit",
+           make_call<locset>([] (const locset& l) { return arb::cv_policy{arb::cv_policy_explicit(l) }; },
+                             "'explicit' with one argument (explicit (ls:locset))")},
+          {"explicit",
+           make_call<locset, region>([] (const locset& l, const region& r) { return arb::cv_policy{arb::cv_policy_explicit(l, r) }; },
+                                     "'explicit' with two arguments (explicit (ls:locset) (reg:region))")},
+          {"join",
+           make_fold<cv_policy>([](cv_policy l, cv_policy r) { return l + r; },
+                                "'join' with at least 2 arguments: (join cv_policy cv_policy ...)")},
+          {"replace",
+           make_fold<cv_policy>([](cv_policy l, cv_policy r) { return l | r; },
+                                "'replace' with at least 2 arguments: (replace cv_policy cv_policy ...)")},
+};
+
+parse_hopefully<std::any> eval(const s_expr& e);
+
+parse_hopefully<std::vector<std::any>> eval_args(const s_expr& e) {
+    if (!e) return {std::vector<std::any>{}}; // empty argument list
+    std::vector<std::any> args;
+    for (auto& h: e) {
+        if (auto arg=eval(h)) {
+            args.push_back(std::move(*arg));
+        }
+        else {
+            return util::unexpected(std::move(arg.error()));
+        }
+    }
+    return args;
+}
+
+// Generate a string description of a function evaluation of the form:
+// Example output:
+//  'foo' with 1 argument: (real)
+//  'bar' with 0 arguments
+//  'cat' with 3 arguments: (locset region integer)
+// Where 'foo', 'bar' and 'cat' are the name of the function, and the
+// types (integer, real, region, locset) are inferred from the arguments.
+std::string eval_description(const char* name, const std::vector<std::any>& args) {
+    auto type_string = [](const std::type_info& t) -> const char* {
+        if (t==typeid(int))       return "integer";
+        if (t==typeid(double))    return "real";
+        if (t==typeid(region))    return "region";
+        if (t==typeid(locset))    return "locset";
+        if (t==typeid(cv_policy)) return "cv_policy";
+        return "unknown";
+    };
+
+    const auto nargs = args.size();
+    std::string msg = concat("'", name, "' with ", nargs, " argument", nargs!=1u ? "s" : "", ":");
+    if (nargs) {
+        msg += " (";
+        bool append_sep = false;
+        for (auto& a: args) {
+            if (append_sep) {
+                msg += " ";
+            }
+            msg += type_string(a.type());
+            append_sep = true;
+        }
+        msg += ")";
+    }
+    return msg;
+}
+
+// Evaluate an s expression.
+// On success the result is wrapped in std::any, where the result is one of:
+//      int:            an integer atom
+//      double:         a real atom
+//      cv_policy:      a discretization policy expression
+//
+// If there invalid input is detected, hopefully return value contains
+// a cv_policy_error_state with an error string and location.
+//
+// If there was an unexpected/fatal error, an exception will be thrown.
+parse_hopefully<std::any> eval(const s_expr& e) {
+    if (e.is_atom()) {
+        return eval_atom<cv_policy_parse_error>(e);
+    }
+
+    if (e.head().is_atom()) {
+        // This must be a function evaluation, where head is the function name, and
+        // tail is a list of arguments.
+
+        // Evaluate the arguments, and return error state if an error occurred.
+        auto args = eval_args(e.tail());
+        if (!args) {
+            return args.error();
+        }
+
+        // Find all candidate functions that match the name of the function.
+        auto& name = e.head().atom().spelling;
+        auto matches = eval_map.equal_range(name);
+
+        // if no matches found, maybe this is a morphology expression?
+        if (matches.first == matches.second) {
+            auto lbl = parse_label_expression(e);
+            if (lbl.has_value()) {
+                return { lbl.value() };
+            } else {
+                return util::unexpected(cv_policy_parse_error(lbl.error().what(), location(e)));
+            }
+        } else {
+            // Search for a candidate that matches the argument list.
+            for (auto i=matches.first; i!=matches.second; ++i) {
+                if (i->second.match_args(*args)) { // found a match: evaluate and return.
+                    return i->second.eval(*args);
+                }
+            }
+
+            // Unable to find a match: try to return a helpful error message.
+            const auto nc = std::distance(matches.first, matches.second);
+            auto msg = concat("No matches for ", eval_description(name.c_str(), *args), "\n  There are ", nc, " potential candiates", nc ? ":" : ".");
+            int count = 0;
+            for (auto i=matches.first; i!=matches.second; ++i) {
+                msg += concat("\n  Candidate ", ++count, ": ", i->second.message);
+            }
+
+            return util::unexpected(cv_policy_parse_error(msg, location(e)));
+        }
+    }
+
+    return util::unexpected(cv_policy_parse_error(
+                                concat("'", e, "' is not either integer, real expression of the form (op <args>)"),
+                                location(e)));
+}
+}
+
+parse_cv_policy_hopefully parse_cv_policy_expression(const std::string& s) {
+    if (auto e = eval(parse_s_expr(s))) {
+        if (e->type() == typeid(cv_policy)) {
+            return {std::move(std::any_cast<cv_policy&>(*e))};
+        }
+        return util::unexpected(
+                cv_policy_parse_error(concat("Invalid description: '", s, "' is not a valid CV policy expression.")));
+    }
+    else {
+        return util::unexpected(cv_policy_parse_error(std::string() + e.error().what()));
+    }
+}
+
+} // namespace arb
diff --git a/arborio/include/arborio/cv_policy_parse.hpp b/arborio/include/arborio/cv_policy_parse.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..8a899d468312426b02cfaf1558cf7da4735933cf
--- /dev/null
+++ b/arborio/include/arborio/cv_policy_parse.hpp
@@ -0,0 +1,32 @@
+#pragma once
+
+#include <any>
+#include <string>
+
+#include <arbor/cv_policy.hpp>
+#include <arbor/arbexcept.hpp>
+#include <arbor/util/expected.hpp>
+#include <arbor/s_expr.hpp>
+
+namespace arborio {
+
+struct cv_policy_parse_error: arb::arbor_exception {
+    explicit cv_policy_parse_error(const std::string& msg, const arb::src_location& loc);
+    explicit cv_policy_parse_error(const std::string& msg);
+};
+
+using parse_cv_policy_hopefully = arb::util::expected<arb::cv_policy, cv_policy_parse_error>;
+
+parse_cv_policy_hopefully parse_cv_policy_expression(const std::string& s);
+
+namespace literals {
+
+inline
+arb::cv_policy operator "" _cvp(const char* s, std::size_t) {
+    if (auto r = parse_cv_policy_expression(s)) return *r;
+    else throw r.error();
+}
+
+} // namespace literals
+
+} // namespace arb
diff --git a/arborio/include/arborio/label_parse.hpp b/arborio/include/arborio/label_parse.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..b51f9361e67e3c87cc9b8ff46b57fd102f30fa2f
--- /dev/null
+++ b/arborio/include/arborio/label_parse.hpp
@@ -0,0 +1,73 @@
+#pragma once
+
+#include <any>
+#include <string>
+
+#include <arbor/arbexcept.hpp>
+#include <arbor/morph/region.hpp>
+#include <arbor/morph/locset.hpp>
+#include <arbor/util/expected.hpp>
+
+#include <arbor/s_expr.hpp>
+
+namespace arborio {
+
+struct label_parse_error: arb::arbor_exception {
+    explicit label_parse_error(const std::string& msg, const arb::src_location& loc);
+    explicit label_parse_error(const std::string& msg): arb::arbor_exception(msg) {}
+};
+
+template <typename T>
+using parse_label_hopefully = arb::util::expected<T, label_parse_error>;
+
+parse_label_hopefully<std::any> parse_label_expression(const std::string&);
+parse_label_hopefully<std::any> parse_label_expression(const arb::s_expr&);
+
+parse_label_hopefully<arb::region> parse_region_expression(const std::string& s);
+parse_label_hopefully<arb::locset> parse_locset_expression(const std::string& s);
+
+namespace literals {
+
+struct morph_from_string {
+    morph_from_string(const std::string& s): str{s} {}
+    morph_from_string(const char* s): str{s} {}
+
+    std::string str;
+
+    operator arb::locset() const {
+        if (auto r = parse_locset_expression(str)) return *r;
+        else throw r.error();
+    }
+
+    operator arb::region() const {
+        if (auto r = parse_region_expression(str)) return *r;
+        else throw r.error();
+    }
+};
+
+struct morph_from_label {
+    morph_from_label(const std::string& s): str{s} {}
+    morph_from_label(const char* s): str{s} {}
+
+    std::string str;
+
+    operator arb::locset() const { return arb::ls::named(str); }
+    operator arb::region() const { return arb::reg::named(str); }
+};
+
+inline
+arb::locset operator "" _ls(const char* s, std::size_t) {
+    if (auto r = parse_locset_expression(s)) return *r;
+    else throw r.error();
+}
+
+inline
+arb::region operator "" _reg(const char* s, std::size_t) {
+    if (auto r = parse_region_expression(s)) return *r;
+    else throw r.error();
+}
+
+inline morph_from_string operator "" _morph(const char* s, std::size_t) { return {s}; }
+inline morph_from_label operator "" _lab(const char* s, std::size_t) { return {s}; }
+} // namespace literals
+} // namespace arborio
diff --git a/arbor/morph/label_parse.cpp b/arborio/label_parse.cpp
similarity index 58%
rename from arbor/morph/label_parse.cpp
rename to arborio/label_parse.cpp
index 01b11a2080833fa041a5adf278e3ee3d7defec7b..119342d3fa2f89b4a18574e2c78fc2cb52b8e38e 100644
--- a/arbor/morph/label_parse.cpp
+++ b/arborio/label_parse.cpp
@@ -1,183 +1,24 @@
 #include <any>
 #include <limits>
 
+#include <arborio/label_parse.hpp>
+
 #include <arbor/arbexcept.hpp>
-#include <arbor/morph/label_parse.hpp>
 #include <arbor/morph/locset.hpp>
 #include <arbor/morph/region.hpp>
-#include <arbor/s_expr.hpp>
+
 #include <arbor/util/expected.hpp>
 
-#include "util/strprintf.hpp"
+#include "parse_helpers.hpp"
 
-namespace arb {
+namespace arborio {
 
-label_parse_error::label_parse_error(const std::string& msg):
-    arb::arbor_exception(msg)
+label_parse_error::label_parse_error(const std::string& msg, const arb::src_location& loc):
+    arb::arbor_exception(concat("error in label description: ", msg," at :", loc.line, ":", loc.column))
 {}
 
-struct nil_tag {};
-
-template <typename T>
-bool match(const std::type_info& info) {
-    return info == typeid(T);
-}
-
-template <>
-bool match<double>(const std::type_info& info) {
-    return info == typeid(double) || info == typeid(int);
-}
-
-template <>
-bool match<arb::region>(const std::type_info& info) {
-    return info == typeid(arb::region) || info == typeid(nil_tag);
-}
-
-template <>
-bool match<arb::locset>(const std::type_info& info) {
-    return info == typeid(arb::locset) || info == typeid(nil_tag);
-}
-
-template <typename T>
-T eval_cast(std::any arg) {
-    return std::move(std::any_cast<T&>(arg));
-}
-
-template <>
-double eval_cast<double>(std::any arg) {
-    if (arg.type()==typeid(int)) return std::any_cast<int>(arg);
-    return std::any_cast<double>(arg);
-}
-
-template <>
-arb::region eval_cast<arb::region>(std::any arg) {
-    if (arg.type()==typeid(arb::region)) return std::any_cast<arb::region>(arg);
-    return arb::reg::nil();
-}
-
-template <>
-arb::locset eval_cast<arb::locset>(std::any arg) {
-    if (arg.type()==typeid(arb::locset)) return std::any_cast<arb::locset>(arg);
-    return arb::ls::nil();
-}
-
-template <typename... Args>
-struct call_eval {
-    using ftype = std::function<std::any(Args...)>;
-    ftype f;
-    call_eval(ftype f): f(std::move(f)) {}
-
-    template<std::size_t... I>
-    std::any expand_args_then_eval(std::vector<std::any> args, std::index_sequence<I...>) {
-        return f(eval_cast<Args>(std::move(args[I]))...);
-    }
-
-    std::any operator()(std::vector<std::any> args) {
-        return expand_args_then_eval(std::move(args), std::make_index_sequence<sizeof...(Args)>());
-    }
-};
-
-template <typename... Args>
-struct call_match {
-    template <std::size_t I, typename T, typename Q, typename... Rest>
-    bool match_args_impl(const std::vector<std::any>& args) const {
-        return match<T>(args[I].type()) && match_args_impl<I+1, Q, Rest...>(args);
-    }
-
-    template <std::size_t I, typename T>
-    bool match_args_impl(const std::vector<std::any>& args) const {
-        return match<T>(args[I].type());
-    }
-
-    template <std::size_t I>
-    bool match_args_impl(const std::vector<std::any>& args) const {
-        return true;
-    }
-
-    bool operator()(const std::vector<std::any>& args) const {
-        const auto nargs_in = args.size();
-        const auto nargs_ex = sizeof...(Args);
-        return nargs_in==nargs_ex? match_args_impl<0, Args...>(args): false;
-    }
-};
 
-template <typename T>
-struct fold_eval {
-    using fold_fn = std::function<T(T, T)>;
-    fold_fn f;
-
-    using anyvec = std::vector<std::any>;
-    using iterator = anyvec::iterator;
-
-    fold_eval(fold_fn f): f(std::move(f)) {}
-
-    T fold_impl(iterator left, iterator right) {
-        if (std::distance(left,right)==1u) {
-            return eval_cast<T>(std::move(*left));
-        }
-        return f(eval_cast<T>(std::move(*left)), fold_impl(left+1, right));
-    }
-
-    std::any operator()(anyvec args) {
-        return fold_impl(args.begin(), args.end());
-    }
-};
-
-template <typename T>
-struct fold_match {
-    using anyvec = std::vector<std::any>;
-    bool operator()(const anyvec& args) const {
-        if (args.size()<2u) return false;
-        for (auto& a: args) {
-            if (!match<T>(a.type())) return false;
-        }
-        return true;
-    }
-};
-
-struct evaluator {
-    using any_vec = std::vector<std::any>;
-    using eval_fn = std::function<std::any(any_vec)>;
-    using args_fn = std::function<bool(const any_vec&)>;
-
-    eval_fn eval;
-    args_fn match_args;
-    const char* message;
-
-    evaluator(eval_fn f, args_fn a, const char* m):
-        eval(std::move(f)),
-        match_args(std::move(a)),
-        message(m)
-    {}
-};
-
-template <typename... Args>
-struct make_call {
-    evaluator state;
-
-    template <typename F>
-    make_call(F&& f, const char* msg="call"):
-        state(call_eval<Args...>(std::forward<F>(f)), call_match<Args...>(), msg)
-    {}
-
-    operator evaluator() const {
-        return state;
-    }
-};
-
-template <typename T>
-struct make_fold {
-    evaluator state;
-
-    template <typename F>
-    make_fold(F&& f, const char* msg="fold"):
-        state(fold_eval<T>(std::forward<F>(f)), fold_match<T>(), msg)
-    {}
-
-    operator evaluator() const {
-        return state;
-    }
-};
+namespace {
 
 std::unordered_multimap<std::string, evaluator> eval_map {
     // Functions that return regions
@@ -268,9 +109,9 @@ std::unordered_multimap<std::string, evaluator> eval_map {
                 "'sum' with at least 2 arguments: (locset locset [...locset])")},
 };
 
-parse_hopefully<std::any> eval(const s_expr& e);
+parse_label_hopefully<std::any> eval(const s_expr& e);
 
-parse_hopefully<std::vector<std::any>> eval_args(const s_expr& e) {
+parse_label_hopefully<std::vector<std::any>> eval_args(const s_expr& e) {
     if (!e) return {std::vector<std::any>{}}; // empty argument list
     std::vector<std::any> args;
     for (auto& h: e) {
@@ -302,15 +143,12 @@ std::string eval_description(const char* name, const std::vector<std::any>& args
     };
 
     const auto nargs = args.size();
-    std::string msg =
-        util::pprintf("'{}' with {} argument{}",
-                      name, nargs,
-                      nargs==0?"s": nargs==1u?":": "s:");
+    std::string msg = concat("'", name, "' with ", nargs, "argument", nargs!=1u?"s:" : ":");
     if (nargs) {
         msg += " (";
         bool first = true;
         for (auto& a: args) {
-            msg += util::pprintf("{}{}", first?"":" ", type_string(a.type()));
+            msg += concat(first?"":" ", type_string(a.type()));
             first = false;
         }
         msg += ")";
@@ -318,9 +156,6 @@ std::string eval_description(const char* name, const std::vector<std::any>& args
     return msg;
 }
 
-label_parse_error parse_error(std::string const& msg, src_location loc) {
-    return {util::pprintf("error in label description at {}: {}.", loc, msg)};
-}
 // Evaluate an s expression.
 // On success the result is wrapped in std::any, where the result is one of:
 //      int         : an integer atom
@@ -333,29 +168,9 @@ label_parse_error parse_error(std::string const& msg, src_location loc) {
 // a label_error_state with an error string and location.
 //
 // If there was an unexpected/fatal error, an exception will be thrown.
-parse_hopefully<std::any> eval(const s_expr& e) {
+parse_label_hopefully<std::any> eval(const s_expr& e) {
     if (e.is_atom()) {
-        auto& t = e.atom();
-        switch (t.kind) {
-            case tok::integer:
-                return {std::stoi(t.spelling)};
-            case tok::real:
-                return {std::stod(t.spelling)};
-            case tok::nil:
-                return {nil_tag()};
-            case tok::string:
-                return std::any{std::string(t.spelling)};
-            // An arbitrary symbol in a region/locset expression is an error, and is
-            // often a result of not quoting a label correctly.
-            case tok::symbol:
-                return util::unexpected(parse_error(
-                        util::pprintf("Unexpected symbol '{}' in a region or locset definition. If '{}' is a label, it must be quoted {}{}{}", e, e, '"', e, '"'),
-                        location(e)));
-            case tok::error:
-                return util::unexpected(parse_error(e.atom().spelling, location(e)));
-            default:
-                return util::unexpected(parse_error(util::pprintf("Unexpected term '{}' in a region or locset definition", e), location(e)));
-        }
+        return eval_atom<label_parse_error>(e);
     }
     if (e.head().is_atom()) {
         // This must be a function evaluation, where head is the function name, and
@@ -380,28 +195,29 @@ parse_hopefully<std::any> eval(const s_expr& e) {
 
         // Unable to find a match: try to return a helpful error message.
         const auto nc = std::distance(matches.first, matches.second);
-        auto msg = util::pprintf("No matches for {}", eval_description(name.c_str(), *args));
-        msg += util::pprintf("\n  There are {} potential candiates{}", nc, nc?":":".");
+        auto msg = concat("No matches for ", eval_description(name.c_str(), *args), "\n  There are ", nc, " potential candidates", nc?":":".");
         int count = 0;
         for (auto i=matches.first; i!=matches.second; ++i) {
-            msg += util::pprintf("\n  Candidate {}  {}", ++count, i->second.message);
+            msg += concat("\n  Candidate ", ++count, "  ", i->second.message);
         }
-        return util::unexpected(parse_error(msg, location(e)));
+        return util::unexpected(label_parse_error(msg, location(e)));
     }
 
-    return util::unexpected(parse_error(
-            util::pprintf("'{}' is not either integer, real expression of the form (op <args>)", e),
-            location(e)));
+    return util::unexpected(label_parse_error(
+                                concat("'", e, "' is not either integer, real expression of the form (op <args>)"),
+                                location(e)));
 }
 
-parse_hopefully<std::any> parse_label_expression(const std::string& e) {
+} // namespace
+
+parse_label_hopefully<std::any> parse_label_expression(const std::string& e) {
     return eval(parse_s_expr(e));
 }
-parse_hopefully<std::any> parse_label_expression(const s_expr& s) {
+parse_label_hopefully<std::any> parse_label_expression(const s_expr& s) {
     return eval(s);
 }
 
-parse_hopefully<arb::region> parse_region_expression(const std::string& s) {
+parse_label_hopefully<arb::region> parse_region_expression(const std::string& s) {
     if (auto e = eval(parse_s_expr(s))) {
         if (e->type() == typeid(region)) {
             return {std::move(std::any_cast<region&>(*e))};
@@ -411,14 +227,14 @@ parse_hopefully<arb::region> parse_region_expression(const std::string& s) {
         }
         return util::unexpected(
                 label_parse_error(
-                util::pprintf("Invalid region description: '{}' is neither a valid region expression or region label string.", s)));
+                    concat("Invalid region description: '", s ,"' is neither a valid region expression or region label string.")));
     }
     else {
         return util::unexpected(label_parse_error(std::string()+e.error().what()));
     }
 }
 
-parse_hopefully<arb::locset> parse_locset_expression(const std::string& s) {
+parse_label_hopefully<arb::locset> parse_locset_expression(const std::string& s) {
     if (auto e = eval(parse_s_expr(s))) {
         if (e->type() == typeid(locset)) {
             return {std::move(std::any_cast<locset&>(*e))};
@@ -427,13 +243,12 @@ parse_hopefully<arb::locset> parse_locset_expression(const std::string& s) {
             return {ls::named(std::move(std::any_cast<std::string&>(*e)))};
         }
         return util::unexpected(
-                label_parse_error(
-                util::pprintf("Invalid locset description: '{}' is neither a valid locset expression or locset label string.", s)));
+            label_parse_error(
+                    concat("Invalid region description: '", s ,"' is neither a valid locset expression or locset label string.")));
     }
     else {
         return util::unexpected(label_parse_error(std::string()+e.error().what()));
     }
 }
 
-} // namespace arb
-
+} // namespace arborio
diff --git a/arborio/parse_helpers.hpp b/arborio/parse_helpers.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..655a65df6a9db497dc1479655bfcf0a1064f665a
--- /dev/null
+++ b/arborio/parse_helpers.hpp
@@ -0,0 +1,271 @@
+#pragma once
+
+#include <any>
+#include <string>
+#include <sstream>
+
+#include <arbor/assert.hpp>
+#include <arbor/arbexcept.hpp>
+#include <arbor/util/expected.hpp>
+#include <arbor/morph/locset.hpp>
+#include <arbor/morph/region.hpp>
+
+namespace arborio {
+using namespace arb;
+
+struct nil_tag {};
+
+// Check typeinfo against expected types
+template <typename T>
+bool match(const std::type_info& info) { return info == typeid(T); }
+template <> inline
+bool match<double>(const std::type_info& info) { return info == typeid(double) || info == typeid(int); }
+
+// Convert a value wrapped in a std::any to target type.
+template <typename T>
+T eval_cast(std::any arg) {
+    return std::move(std::any_cast<T&>(arg));
+}
+template <> inline
+double eval_cast<double>(std::any arg) {
+    if (arg.type()==typeid(int)) return std::any_cast<int>(arg);
+    return std::any_cast<double>(arg);
+}
+
+template <> inline
+arb::region eval_cast<arb::region>(std::any arg) {
+    if (arg.type()==typeid(arb::region)) return std::any_cast<arb::region>(arg);
+    return arb::reg::nil();
+}
+
+template <> inline
+arb::locset eval_cast<arb::locset>(std::any arg) {
+    if (arg.type()==typeid(arb::locset)) return std::any_cast<arb::locset>(arg);
+    return arb::ls::nil();
+}
+
+// Test whether a list of arguments passed as a std::vector<std::any> can be converted
+// to the types in Args.
+//
+// For example, the following would return true:
+//
+//  call_match<int, int, string>(vector<any(4), any(12), any(string("hello"))>)
+template <typename... Args>
+struct call_match {
+    template <std::size_t I, typename T, typename Q, typename... Rest>
+    bool match_args_impl(const std::vector<std::any>& args) const {
+        return match<T>(args[I].type()) && match_args_impl<I+1, Q, Rest...>(args);
+    }
+
+    template <std::size_t I, typename T>
+    bool match_args_impl(const std::vector<std::any>& args) const {
+        return match<T>(args[I].type());
+    }
+
+    template <std::size_t I>
+    bool match_args_impl(const std::vector<std::any>& args) const {
+        return true;
+    }
+
+    bool operator()(const std::vector<std::any>& args) const {
+        const auto nargs_in = args.size();
+        const auto nargs_ex = sizeof...(Args);
+        return nargs_in==nargs_ex? match_args_impl<0, Args...>(args): false;
+    }
+};
+
+template <typename T>
+struct fold_eval {
+    using fold_fn = std::function<T(T, T)>;
+    fold_fn f;
+
+    using anyvec = std::vector<std::any>;
+    using iterator = anyvec::iterator;
+
+    fold_eval(fold_fn f): f(std::move(f)) {}
+
+    T fold_impl(iterator left, iterator right) {
+        if (std::distance(left,right)==1u) {
+            return eval_cast<T>(std::move(*left));
+        }
+        return f(eval_cast<T>(std::move(*left)), fold_impl(left+1, right));
+    }
+
+    std::any operator()(anyvec args) {
+        return fold_impl(args.begin(), args.end());
+    }
+};
+
+template <typename T>
+struct fold_match {
+    using anyvec = std::vector<std::any>;
+    bool operator()(const anyvec& args) const {
+        if (args.size()<2u) return false;
+        for (auto& a: args) {
+            if (!match<T>(a.type())) return false;
+        }
+        return true;
+    }
+};
+
+// Evaluator: member of make_call, make_arg_vec_call, make_mech_call, make_branch_call, make_unordered_call
+struct evaluator {
+    using any_vec = std::vector<std::any>;
+    using eval_fn = std::function<std::any(any_vec)>;
+    using args_fn = std::function<bool(const any_vec&)>;
+
+    eval_fn eval;
+    args_fn match_args;
+    const char* message;
+
+    evaluator(eval_fn f, args_fn a, const char* m):
+        eval(std::move(f)),
+        match_args(std::move(a)),
+        message(m)
+    {}
+};
+
+// Evaluate a call to a function where the arguments are provided as a std::vector<std::any>.
+// The arguments are expanded and converted to the correct types, as specified by Args.
+template <typename... Args>
+struct call_eval {
+    using ftype = std::function<std::any(Args...)>;
+    ftype f;
+    call_eval(ftype f): f(std::move(f)) {}
+
+    template<std::size_t... I>
+    std::any expand_args_then_eval(const std::vector<std::any>& args, std::index_sequence<I...>) {
+        return f(eval_cast<Args>(std::move(args[I]))...);
+    }
+
+    std::any operator()(const std::vector<std::any>& args) {
+        return expand_args_then_eval(std::move(args), std::make_index_sequence<sizeof...(Args)>());
+    }
+};
+
+// Wrap call_match and call_eval in an evaluator
+template <typename... Args>
+struct make_call {
+    evaluator state;
+
+    template <typename F>
+    make_call(F&& f, const char* msg="call"):
+        state(call_eval<Args...>(std::forward<F>(f)), call_match<Args...>(), msg)
+    {}
+
+    operator evaluator() const {
+        return state;
+    }
+};
+
+template <typename T>
+struct make_fold {
+    evaluator state;
+
+    template <typename F>
+    make_fold(F&& f, const char* msg="fold"):
+        state(fold_eval<T>(std::forward<F>(f)), fold_match<T>(), msg)
+    {}
+
+    operator evaluator() const {
+        return state;
+    }
+};
+
+// Test whether a list of arguments passed as a std::vector<std::any> can be converted
+// to a std::vector<std::variant<Args...>>.
+//
+// For example, the following would return true:
+//
+//  call_match<int, string>(vector<any(4), any(12), any(string("hello"))>)
+template <typename... Args>
+struct arg_vec_match {
+    template <typename T, typename Q, typename... Rest>
+    bool match_args_impl(const std::any& arg) const {
+        return match<T>(arg.type()) || match_args_impl<Q, Rest...>(arg);
+    }
+
+    template <typename T>
+    bool match_args_impl(const std::any& arg) const {
+        return match<T>(arg.type());
+    }
+
+    bool operator()(const std::vector<std::any>& args) const {
+        for (const auto& a: args) {
+            if (!match_args_impl<Args...>(a)) return false;
+        }
+        return true;
+    }
+};
+
+// Convert a value wrapped in a std::any to an optional std::variant type
+template <typename T, std::size_t I=0>
+std::optional<T> eval_cast_variant(const std::any& a) {
+    if constexpr (I<std::variant_size_v<T>) {
+        using var_type = std::variant_alternative_t<I, T>;
+        return match<var_type>(a.type())? eval_cast<var_type>(a): eval_cast_variant<T, I+1>(a);
+    }
+    return std::nullopt;
+}
+
+// Evaluate a call to a function where the arguments are provided as a std::vector<std::any>.
+// The arguments are converted to std::variant<Args...> and passed to the function as a std::vector.
+template <typename... Args>
+struct arg_vec_eval {
+    using ftype = std::function<std::any(std::vector<std::variant<Args...>>)>;
+    ftype f;
+    arg_vec_eval(ftype f): f(std::move(f)) {}
+
+    std::any operator()(const std::vector<std::any>& args) {
+        std::vector<std::variant<Args...>> vars;
+        for (const auto& a: args) {
+            vars.push_back(eval_cast_variant<std::variant<Args...>>(a).value());
+        }
+        return f(vars);
+    }
+};
+
+// Wrap arg_vec_match and arg_vec_eval in an evaluator
+template <typename... Args>
+struct make_arg_vec_call {
+    evaluator state;
+
+    template <typename F>
+    make_arg_vec_call(F&& f, const char* msg="argument vector"):
+        state(arg_vec_eval<Args...>(std::forward<F>(f)), arg_vec_match<Args...>(), msg)
+    {}
+    operator evaluator() const {
+        return state;
+    }
+};
+
+template<typename... Ts>
+std::string concat(Ts... ts) {
+    std::stringstream ss;
+    (ss << ... << ts);
+    return ss.str();
+}
+
+// try to parse an atom
+template<typename E>
+util::expected<std::any, E> eval_atom(const s_expr& e) {
+    arb_assert(e.is_atom());
+    auto& t = e.atom();
+    switch (t.kind) {
+        case tok::integer:
+            return {std::stoi(t.spelling)};
+        case tok::real:
+            return {std::stod(t.spelling)};
+        case tok::nil:
+            return {nil_tag()};
+        case tok::string:
+            return {std::string(t.spelling)};
+        case tok::symbol:
+            return util::unexpected(E(concat("Unexpected symbol '", e, "' in definition."), location(e)));
+        case tok::error:
+            return util::unexpected(E(e.atom().spelling, location(e)));
+        default:
+            return util::unexpected(E(concat("Unexpected term '", e, "' in definition"), location(e)));
+    }
+}
+}
diff --git a/doc/concepts/morphology.rst b/doc/concepts/morphology.rst
index 055978e6d94233c378d95e222bb842f8af35c581..13ffcd4bc1134930682aabb69b4d1ae1b679e0fc 100644
--- a/doc/concepts/morphology.rst
+++ b/doc/concepts/morphology.rst
@@ -693,6 +693,24 @@ and *B*, while *A* | *B* is a policy which gives all the boundary points from
 The domain of *A* + *B* and *A* | *B* is the union of the domains of *A* and
 *B*.
 
+Reading CV policies from strings
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+CV policies can also be converted to and from strings, using an S-Expression-based
+DSL. Constructors are
+
+* ``(single <optional:region>)``
+* ``(max-extent <double> <optional:region> <optional:flags>)``
+* ``(fixed-per-branch <int> <optional:region> <optional:flags>)``
+* ``(explicit <locset> <optional:region>)``
+
+with the obvious correspondences. The composition operators are
+
+* ``(join <cv-policy> <cv-policy> ...)`` equivalent to ``+``
+* ``(replace <cv-policy> <cv-policy> ...)`` equivalent to ``|``
+
+and take arbitrary many policies.
+
 API
 ---
 
diff --git a/example/dryrun/CMakeLists.txt b/example/dryrun/CMakeLists.txt
index 4cd7a797ad7ddf8201b8fa46081342f4d5719fef..e36fe972b931cfd86ae9f62e577d5c1b6cb029ec 100644
--- a/example/dryrun/CMakeLists.txt
+++ b/example/dryrun/CMakeLists.txt
@@ -1,4 +1,4 @@
 add_executable(dryrun EXCLUDE_FROM_ALL dryrun.cpp)
 add_dependencies(examples dryrun)
 
-target_link_libraries(dryrun PRIVATE arbor arborenv arbor-sup ${json_library_name})
+target_link_libraries(dryrun PRIVATE arbor arborio arborenv arbor-sup ${json_library_name})
diff --git a/example/dryrun/branch_cell.hpp b/example/dryrun/branch_cell.hpp
index 77f71c1417f58fd5616a496a148f93597027807d..0cae8cf07f0580c7c15d21569b2e262ff178d172 100644
--- a/example/dryrun/branch_cell.hpp
+++ b/example/dryrun/branch_cell.hpp
@@ -5,16 +5,17 @@
 
 #include <nlohmann/json.hpp>
 
+#include <arborio/label_parse.hpp>
+
 #include <arbor/cable_cell.hpp>
 #include <arbor/cable_cell_param.hpp>
 #include <arbor/common_types.hpp>
 #include <arbor/morph/segment_tree.hpp>
-#include <arbor/string_literals.hpp>
 
 #include <string>
 #include <sup/json_params.hpp>
 
-using namespace arb::literals;
+using namespace arborio::literals;
 
 // Parameters used to generate the random cell morphologies.
 struct cell_parameters {
diff --git a/example/dryrun/dryrun.cpp b/example/dryrun/dryrun.cpp
index bdfa32e7058baf3e0030651ed8a198ab35ff7bbf..81773a97c143f771cb3ee30a428530cbceca35da 100644
--- a/example/dryrun/dryrun.cpp
+++ b/example/dryrun/dryrun.cpp
@@ -11,6 +11,8 @@
 
 #include <nlohmann/json.hpp>
 
+#include <arborio/label_parse.hpp>
+
 #include <arbor/common_types.hpp>
 #include <arbor/context.hpp>
 #include <arbor/cable_cell.hpp>
@@ -35,6 +37,8 @@
 #include <arborenv/with_mpi.hpp>
 #endif
 
+using namespace arborio::literals;
+
 struct run_params {
     std::string name = "default";
     bool dry_run = false;
diff --git a/example/gap_junctions/CMakeLists.txt b/example/gap_junctions/CMakeLists.txt
index 9aee5fbe3528a018c838e280c4059bb5d34fbf99..3e13f79c97d361d9e0b6ddde49768cbf6aef768f 100644
--- a/example/gap_junctions/CMakeLists.txt
+++ b/example/gap_junctions/CMakeLists.txt
@@ -1,4 +1,4 @@
 add_executable(gap_junctions EXCLUDE_FROM_ALL gap_junctions.cpp)
 add_dependencies(examples gap_junctions)
 
-target_link_libraries(gap_junctions PRIVATE arbor arborenv arbor-sup ${json_library_name})
+target_link_libraries(gap_junctions PRIVATE arbor arborio arborenv arbor-sup ${json_library_name})
diff --git a/example/gap_junctions/gap_junctions.cpp b/example/gap_junctions/gap_junctions.cpp
index 97206b037c9974c8ed340b141d05f8791b31b766..08a67b5c79d16179064cd6c8ff6920c1ec7b7166 100644
--- a/example/gap_junctions/gap_junctions.cpp
+++ b/example/gap_junctions/gap_junctions.cpp
@@ -10,6 +10,8 @@
 
 #include <nlohmann/json.hpp>
 
+#include <arborio/label_parse.hpp>
+
 #include <arbor/assert_macro.hpp>
 #include <arbor/cable_cell.hpp>
 #include <arbor/common_types.hpp>
@@ -30,6 +32,8 @@
 #include <sup/json_meter.hpp>
 #include <sup/json_params.hpp>
 
+using namespace arborio::literals;
+
 #ifdef ARB_MPI_ENABLED
 #include <mpi.h>
 #include <arborenv/with_mpi.hpp>
@@ -299,10 +303,10 @@ arb::cable_cell gj_cell(cell_gid_type gid, unsigned ncell, double stim_duration)
     pas["g"] =  1.0/12000.0;
 
     // Paint density channels on all parts of the cell
-    decor.paint("(all)", nax);
-    decor.paint("(all)", kdrmt);
-    decor.paint("(all)", kamt);
-    decor.paint("(all)", pas);
+    decor.paint("(all)"_reg, nax);
+    decor.paint("(all)"_reg, kdrmt);
+    decor.paint("(all)"_reg, kamt);
+    decor.paint("(all)"_reg, pas);
 
     // Add a spike detector to the soma.
     decor.place(arb::mlocation{0,0}, arb::threshold_detector{10}, "detector");
diff --git a/example/generators/CMakeLists.txt b/example/generators/CMakeLists.txt
index 2cd0c230ce747446e280039c71c38e5fe86bca84..a7422836ea5c53d122be653a9d559e392198006f 100644
--- a/example/generators/CMakeLists.txt
+++ b/example/generators/CMakeLists.txt
@@ -1,4 +1,4 @@
 add_executable(generators EXCLUDE_FROM_ALL generators.cpp)
 add_dependencies(examples generators)
 
-target_link_libraries(generators PRIVATE arbor arbor-sup ${json_library_name})
+target_link_libraries(generators PRIVATE arbor arborio arbor-sup ${json_library_name})
diff --git a/example/generators/generators.cpp b/example/generators/generators.cpp
index 96db4f046836b962574309a1743299f25f15308c..7fe3ea440d71fa17ac31f975c3a439396458aaa0 100644
--- a/example/generators/generators.cpp
+++ b/example/generators/generators.cpp
@@ -14,6 +14,8 @@
 
 #include <nlohmann/json.hpp>
 
+#include <arborio/label_parse.hpp>
+
 #include <arbor/context.hpp>
 #include <arbor/common_types.hpp>
 #include <arbor/domain_decomposition.hpp>
@@ -31,6 +33,8 @@ using arb::cell_member_type;
 using arb::cell_kind;
 using arb::time_type;
 
+using namespace arborio::literals;
+
 // Writes voltage trace as a json file.
 void write_trace_json(const arb::trace_data<double>& trace);
 
@@ -56,7 +60,7 @@ public:
         labels.set("soma", arb::reg::tagged(1));
 
         arb::decor decor;
-        decor.paint("\"soma\"", "pas");
+        decor.paint("soma"_lab, "pas");
 
         // Add one synapse at the soma.
         // This synapse will be the target for all events, from both
diff --git a/example/ring/CMakeLists.txt b/example/ring/CMakeLists.txt
index 953ca291846dfe841171f5d7ccf3717c1c769f0b..e72d6bdfebd4a8f35fcd77b41a56bbaeae744d39 100644
--- a/example/ring/CMakeLists.txt
+++ b/example/ring/CMakeLists.txt
@@ -1,4 +1,4 @@
 add_executable(ring EXCLUDE_FROM_ALL ring.cpp)
 add_dependencies(examples ring)
 
-target_link_libraries(ring PRIVATE arbor arborenv arbor-sup ${json_library_name})
+target_link_libraries(ring PRIVATE arbor arborio arborenv arbor-sup ${json_library_name})
diff --git a/example/ring/branch_cell.hpp b/example/ring/branch_cell.hpp
index 1a243a4d1ca4f00af69c25e66da04c81492eb1e8..789684adbd323fdce5a2a6c67d25bb6c52fe86ca 100644
--- a/example/ring/branch_cell.hpp
+++ b/example/ring/branch_cell.hpp
@@ -5,16 +5,17 @@
 
 #include <nlohmann/json.hpp>
 
+#include <arborio/label_parse.hpp>
+
 #include <arbor/cable_cell.hpp>
 #include <arbor/cable_cell_param.hpp>
 #include <arbor/common_types.hpp>
 #include <arbor/morph/segment_tree.hpp>
-#include <arbor/string_literals.hpp>
 
 #include <string>
 #include <sup/json_params.hpp>
 
-using namespace arb::literals;
+using namespace arborio::literals;
 
 // Parameters used to generate the random cell morphologies.
 struct cell_parameters {
diff --git a/example/ring/ring.cpp b/example/ring/ring.cpp
index 3e34d80b01086f188a5e9e0c6321b5127eaedebc..5e9c187e9e87a16225296aaed4069af573589c51 100644
--- a/example/ring/ring.cpp
+++ b/example/ring/ring.cpp
@@ -11,6 +11,8 @@
 
 #include <nlohmann/json.hpp>
 
+#include <arborio/label_parse.hpp>
+
 #include <arbor/assert_macro.hpp>
 #include <arbor/common_types.hpp>
 #include <arbor/cable_cell.hpp>
diff --git a/example/single/single.cpp b/example/single/single.cpp
index ea6cf74b9a9213abd77e793513f968f944e210e3..b7d3cff22789c673b295f81a55fa463463be9b25 100644
--- a/example/single/single.cpp
+++ b/example/single/single.cpp
@@ -6,6 +6,8 @@
 #include <string>
 #include <vector>
 
+#include <arborio/label_parse.hpp>
+
 #include <arbor/load_balance.hpp>
 #include <arbor/cable_cell.hpp>
 #include <arbor/morph/morphology.hpp>
@@ -17,6 +19,8 @@
 
 #include <tinyopt/tinyopt.h>
 
+using namespace arborio::literals;
+
 struct options {
     std::string swc_file;
     double t_end = 20;
@@ -64,8 +68,8 @@ struct single_recipe: public arb::recipe {
         arb::decor decor;
 
         // Add HH mechanism to soma, passive channels to dendrites.
-        decor.paint("\"soma\"", "hh");
-        decor.paint("\"dend\"", "pas");
+        decor.paint("soma"_lab, "hh");
+        decor.paint("dend"_lab, "pas");
 
         // Add synapse to last branch.
 
diff --git a/python/cable_probes.cpp b/python/cable_probes.cpp
index a8543a07e4ca570bc02792b432a0ebc88ab340b6..4ba583e322fd236b7f4297dc172875c1bce1b98c 100644
--- a/python/cable_probes.cpp
+++ b/python/cable_probes.cpp
@@ -5,6 +5,8 @@
 #include <pybind11/pytypes.h>
 #include <pybind11/stl.h>
 
+#include <arborio/label_parse.hpp>
+
 #include <arbor/cable_cell.hpp>
 #include <arbor/morph/locset.hpp>
 #include <arbor/recipe.hpp>
@@ -134,7 +136,7 @@ void register_probe_meta_maps(pyarb_global_ptr g) {
 // (Probe tag value is implicitly left at zero.)
 
 arb::probe_info cable_probe_membrane_voltage(const char* where) {
-    return arb::cable_probe_membrane_voltage{arb::locset(where)};
+    return arb::cable_probe_membrane_voltage{arborio::parse_locset_expression(where).unwrap()};
 }
 
 arb::probe_info cable_probe_membrane_voltage_cell() {
@@ -142,11 +144,11 @@ arb::probe_info cable_probe_membrane_voltage_cell() {
 }
 
 arb::probe_info cable_probe_axial_current(const char* where) {
-    return arb::cable_probe_axial_current{arb::locset(where)};
+    return arb::cable_probe_axial_current{arborio::parse_locset_expression(where).unwrap()};
 }
 
 arb::probe_info cable_probe_total_ion_current_density(const char* where) {
-    return arb::cable_probe_total_ion_current_density{arb::locset(where)};
+    return arb::cable_probe_total_ion_current_density{arborio::parse_locset_expression(where).unwrap()};
 }
 
 arb::probe_info cable_probe_total_ion_current_cell() {
@@ -162,7 +164,7 @@ arb::probe_info cable_probe_stimulus_current_cell() {
 }
 
 arb::probe_info cable_probe_density_state(const char* where, const char* mechanism, const char* state) {
-    return arb::cable_probe_density_state{arb::locset(where), mechanism, state};
+    return arb::cable_probe_density_state{arborio::parse_locset_expression(where).unwrap(), mechanism, state};
 };
 
 arb::probe_info cable_probe_density_state_cell(const char* mechanism, const char* state) {
@@ -178,7 +180,7 @@ arb::probe_info cable_probe_point_state_cell(const char* mechanism, const char*
 }
 
 arb::probe_info cable_probe_ion_current_density(const char* where, const char* ion) {
-    return arb::cable_probe_ion_current_density{arb::locset(where), ion};
+    return arb::cable_probe_ion_current_density{arborio::parse_locset_expression(where).unwrap(), ion};
 }
 
 arb::probe_info cable_probe_ion_current_cell(const char* ion) {
@@ -186,7 +188,7 @@ arb::probe_info cable_probe_ion_current_cell(const char* ion) {
 }
 
 arb::probe_info cable_probe_ion_int_concentration(const char* where, const char* ion) {
-    return arb::cable_probe_ion_int_concentration{arb::locset(where), ion};
+    return arb::cable_probe_ion_int_concentration{arborio::parse_locset_expression(where).unwrap(), ion};
 }
 
 arb::probe_info cable_probe_ion_int_concentration_cell(const char* ion) {
@@ -194,7 +196,7 @@ arb::probe_info cable_probe_ion_int_concentration_cell(const char* ion) {
 }
 
 arb::probe_info cable_probe_ion_ext_concentration(const char* where, const char* ion) {
-    return arb::cable_probe_ion_ext_concentration{arb::locset(where), ion};
+    return arb::cable_probe_ion_ext_concentration{arborio::parse_locset_expression(where).unwrap(), ion};
 }
 
 arb::probe_info cable_probe_ion_ext_concentration_cell(const char* ion) {
diff --git a/python/cells.cpp b/python/cells.cpp
index d591032bbf5ac7205130e2f981d4f76f74865cc8..9d745cde1246973bf318f1bf885d6a58584a0007 100644
--- a/python/cells.cpp
+++ b/python/cells.cpp
@@ -11,11 +11,14 @@
 #include <pybind11/pybind11.h>
 #include <pybind11/stl.h>
 
+#include <arborio/cv_policy_parse.hpp>
+#include <arborio/label_parse.hpp>
+
 #include <arbor/benchmark_cell.hpp>
 #include <arbor/cable_cell.hpp>
 #include <arbor/lif_cell.hpp>
+#include <arbor/cv_policy.hpp>
 #include <arbor/morph/label_dict.hpp>
-#include <arbor/morph/label_parse.hpp>
 #include <arbor/morph/locset.hpp>
 #include <arbor/morph/region.hpp>
 #include <arbor/morph/segment_tree.hpp>
@@ -75,23 +78,23 @@ std::string to_string(const arb::cable_cell_global_properties& props) {
 //
 
 arb::cv_policy make_cv_policy_single(const std::string& reg) {
-    return arb::cv_policy_single(reg);
+    return arb::cv_policy_single(arborio::parse_region_expression(reg).unwrap());
 }
 
 arb::cv_policy make_cv_policy_explicit(const std::string& locset, const std::string& reg) {
-    return arb::cv_policy_explicit(locset, reg);
+    return arb::cv_policy_explicit(arborio::parse_locset_expression(locset).unwrap(), arborio::parse_region_expression(reg).unwrap());
 }
 
 arb::cv_policy make_cv_policy_every_segment(const std::string& reg) {
-    return arb::cv_policy_every_segment(reg);
+    return arb::cv_policy_every_segment(arborio::parse_region_expression(reg).unwrap());
 }
 
 arb::cv_policy make_cv_policy_fixed_per_branch(unsigned cv_per_branch, const std::string& reg) {
-    return arb::cv_policy_fixed_per_branch(cv_per_branch, reg);
+    return arb::cv_policy_fixed_per_branch(cv_per_branch, arborio::parse_region_expression(reg).unwrap());
 }
 
 arb::cv_policy make_cv_policy_max_extent(double cv_length, const std::string& reg) {
-    return arb::cv_policy_max_extent(cv_length, reg);
+    return arb::cv_policy_max_extent(cv_length, arborio::parse_region_expression(reg).unwrap());
 }
 
 // Helper for finding a mechanism description in a Python object.
@@ -264,13 +267,22 @@ void register_cells(pybind11::module& m) {
     pybind11::class_<arb::cv_policy> cv_policy(m, "cv_policy",
             "Describes the rules used to discretize (compartmentalise) a cable cell morphology.");
     cv_policy
+        .def(pybind11::init([](const std::string& s) { return arborio::parse_cv_policy_expression(s).unwrap(); }))
         .def_property_readonly("domain",
                                [](const arb::cv_policy& p) {return util::pprintf("{}", p.domain());},
                                "The domain on which the policy is applied.")
         .def(pybind11::self + pybind11::self)
         .def(pybind11::self | pybind11::self)
-        .def("__repr__", [](const arb::cv_policy& p) {return "(cv-policy)";})
-        .def("__str__",  [](const arb::cv_policy& p) {return "(cv-policy)";});
+        .def("__repr__", [](const arb::cv_policy& p) {
+            std::stringstream ss;
+            ss << p;
+            return ss.str();
+        })
+        .def("__str__", [](const arb::cv_policy& p) {
+            std::stringstream ss;
+            ss << p;
+            return ss.str();
+        });
 
     m.def("cv_policy_explicit",
           &make_cv_policy_explicit,
@@ -488,13 +500,13 @@ void register_cells(pybind11::module& m) {
         // Paint mechanisms.
         .def("paint",
             [](arb::decor& dec, const char* region, const arb::mechanism_desc& d) {
-                dec.paint(region, d);
+                dec.paint(arborio::parse_region_expression(region).unwrap(), d);
             },
             "region"_a, "mechanism"_a,
             "Associate a mechanism with a region.")
         .def("paint",
             [](arb::decor& dec, const char* region, const char* mech_name) {
-                dec.paint(region, arb::mechanism_desc(mech_name));
+                dec.paint(arborio::parse_region_expression(region).unwrap(), arb::mechanism_desc(mech_name));
             },
             "region"_a, "mechanism"_a,
             "Associate a mechanism with a region.")
@@ -505,10 +517,11 @@ void register_cells(pybind11::module& m) {
                optional<double> Vm, optional<double> cm,
                optional<double> rL, optional<double> tempK)
             {
-                if (Vm) dec.paint(region, arb::init_membrane_potential{*Vm});
-                if (cm) dec.paint(region, arb::membrane_capacitance{*cm});
-                if (rL) dec.paint(region, arb::axial_resistivity{*rL});
-                if (tempK) dec.paint(region, arb::temperature_K{*tempK});
+                auto r = arborio::parse_region_expression(region).unwrap();
+                if (Vm) dec.paint(r, arb::init_membrane_potential{*Vm});
+                if (cm) dec.paint(r, arb::membrane_capacitance{*cm});
+                if (rL) dec.paint(r, arb::axial_resistivity{*rL});
+                if (tempK) dec.paint(r, arb::temperature_K{*tempK});
             },
             pybind11::arg_v("region", "the region label or description."),
             pybind11::arg_v("Vm",    pybind11::none(), "initial membrane voltage [mV]."),
@@ -520,9 +533,10 @@ void register_cells(pybind11::module& m) {
         .def("paint",
             [](arb::decor& dec, const char* region, const char* name,
                optional<double> int_con, optional<double> ext_con, optional<double> rev_pot) {
-                if (int_con) dec.paint(region, arb::init_int_concentration{name, *int_con});
-                if (ext_con) dec.paint(region, arb::init_ext_concentration{name, *ext_con});
-                if (rev_pot) dec.paint(region, arb::init_reversal_potential{name, *rev_pot});
+                auto r = arborio::parse_region_expression(region).unwrap();
+                if (int_con) dec.paint(r, arb::init_int_concentration{name, *int_con});
+                if (ext_con) dec.paint(r, arb::init_ext_concentration{name, *ext_con});
+                if (rev_pot) dec.paint(r, arb::init_reversal_potential{name, *rev_pot});
             },
             "region"_a, "ion_name"_a,
             pybind11::arg_v("int_con", pybind11::none(), "Initial internal concentration [mM]"),
@@ -532,13 +546,13 @@ void register_cells(pybind11::module& m) {
         // Place synapses
         .def("place",
             [](arb::decor& dec, const char* locset, const arb::mechanism_desc& d, const char* label_name) {
-                return dec.place(locset, d, label_name); },
+                return dec.place(arborio::parse_locset_expression(locset).unwrap(), 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, const char* label_name) {
-                return dec.place(locset, mech_name, label_name);
+                return dec.place(arborio::parse_locset_expression(locset).unwrap(), mech_name, label_name);
             },
             "locations"_a, "mechanism"_a, "label"_a,
             "Place one instance of the synapse described by 'mechanism' on each location in 'locations'."
@@ -546,7 +560,7 @@ void register_cells(pybind11::module& m) {
         // Place gap junctions.
         .def("place",
             [](arb::decor& dec, const char* locset, const arb::gap_junction_site& site, const char* label_name) {
-                return dec.place(locset, site, label_name);
+                return dec.place(arborio::parse_locset_expression(locset).unwrap(), site, label_name);
             },
             "locations"_a, "gapjunction"_a, "label"_a,
             "Place one gap junction site labeled 'label' on each location in 'locations'."
@@ -554,7 +568,7 @@ void register_cells(pybind11::module& m) {
         // Place current clamp stimulus.
         .def("place",
             [](arb::decor& dec, const char* locset, const arb::i_clamp& stim, const char* label_name) {
-                return dec.place(locset, stim, label_name);
+                return dec.place(arborio::parse_locset_expression(locset).unwrap(), stim, label_name);
             },
             "locations"_a, "iclamp"_a, "label"_a,
             "Add a current stimulus at each location in locations."
@@ -562,7 +576,7 @@ void register_cells(pybind11::module& m) {
         // Place spike detector.
         .def("place",
             [](arb::decor& dec, const char* locset, const arb::threshold_detector& d, const char* label_name) {
-                return dec.place(locset, d, label_name);
+                return dec.place(arborio::parse_locset_expression(locset).unwrap(), d, label_name);
             },
             "locations"_a, "detector"_a, "label"_a,
             "Add a voltage spike detector at each location in locations."
@@ -571,7 +585,6 @@ void register_cells(pybind11::module& m) {
             [](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"));
 
-
     // arb::cable_cell
 
     pybind11::class_<arb::cable_cell> cable_cell(m, "cable_cell",
@@ -593,11 +606,11 @@ void register_cells(pybind11::module& m) {
             "The number of unbranched cable sections in the morphology.")
         // Get locations associated with a locset label.
         .def("locations",
-            [](arb::cable_cell& c, const char* label) {return c.concrete_locset(label);},
+            [](arb::cable_cell& c, const char* label) {return c.concrete_locset(arb::ls::named(label));},
             "label"_a, "The locations of the cell morphology for a locset label.")
         // Get cables associated with a region label.
         .def("cables",
-            [](arb::cable_cell& c, const char* label) {return c.concrete_region(label).cables();},
+            [](arb::cable_cell& c, const char* label) {return c.concrete_region(arb::reg::named(label)).cables();},
             "label"_a, "The cable segments of the cell morphology for a region label.")
         // Stringification
         .def("__repr__", [](const arb::cable_cell&){return "<arbor.cable_cell>";})
diff --git a/python/example/single_cell_swc.py b/python/example/single_cell_swc.py
index f229b623a503d3072ea5606a79a6a0d808f528d6..525d90e890c1c992253e1668ed97a117332e88d7 100755
--- a/python/example/single_cell_swc.py
+++ b/python/example/single_cell_swc.py
@@ -66,7 +66,7 @@ decor.discretization(policy)
 # Combine morphology with region and locset definitions to make a cable cell.
 cell = arbor.cable_cell(morpho, labels, decor)
 
-print(cell.locations('"axon_end"'))
+print(cell.locations('axon_end'))
 
 # Make single cell model.
 m = arbor.single_cell_model(cell)
diff --git a/python/morphology.cpp b/python/morphology.cpp
index 7493fc886a1e3342f77ab27560f8b02a19084ce5..5a99baadd449c64c08214888ddd0049082321b53 100644
--- a/python/morphology.cpp
+++ b/python/morphology.cpp
@@ -12,6 +12,7 @@
 #include <arbor/morph/segment_tree.hpp>
 #include <arbor/version.hpp>
 
+#include <arborio/label_parse.hpp>
 #include <arborio/swcio.hpp>
 #include <arborio/neurolucida.hpp>
 
diff --git a/python/proxy.hpp b/python/proxy.hpp
index 640db753574443439a6de3909dba343c348fa9af..8f3003f13ee7dbc6dcf767495a27bd3e08b3bffd 100644
--- a/python/proxy.hpp
+++ b/python/proxy.hpp
@@ -2,8 +2,9 @@
 
 #include <any>
 
+#include <arborio/label_parse.hpp>
+
 #include <arbor/morph/label_dict.hpp>
-#include <arbor/morph/label_parse.hpp>
 
 #include "strprintf.hpp"
 
@@ -54,7 +55,7 @@ struct label_dict_proxy {
         //  * the description is well-formed, but describes neither a region or locset.
         try{
             // Evaluate the s-expression to build a region/locset.
-            auto result = arb::parse_label_expression(desc);
+            auto result = arborio::parse_label_expression(desc);
             if (!result) { // an error parsing / evaluating description.
                 throw result.error();
             }
diff --git a/python/single_cell_model.cpp b/python/single_cell_model.cpp
index 64b416898a2f4431536e6d35bb8ebbb4b74d54f2..e639067895dcc8617bea81ef0635e5ba97836c0d 100644
--- a/python/single_cell_model.cpp
+++ b/python/single_cell_model.cpp
@@ -7,6 +7,8 @@
 #include <pybind11/pybind11.h>
 #include <pybind11/stl.h>
 
+#include <arborio/label_parse.hpp>
+
 #include <arbor/cable_cell.hpp>
 #include <arbor/load_balance.hpp>
 #include <arbor/recipe.hpp>
@@ -235,7 +237,7 @@ void register_single_cell(pybind11::module& m) {
              "Run model from t=0 to t=tfinal ms.")
         .def("probe",
             [](single_cell_model& m, const char* what, const char* where, double frequency) {
-                m.probe(what, where, frequency);},
+                m.probe(what, arborio::parse_locset_expression(where).unwrap(), frequency);},
             "what"_a, "where"_a, "frequency"_a,
             "Sample a variable on the cell.\n"
             " what:      Name of the variable to record (currently only 'voltage').\n"
diff --git a/test/common_cells.cpp b/test/common_cells.cpp
index 94422df0879c4a70918c3e5577061e8fb7fd69df..49db2965ff2ecff3e0517c0548b547d55dcf9ae7 100644
--- a/test/common_cells.cpp
+++ b/test/common_cells.cpp
@@ -1,8 +1,9 @@
-#include <arbor/string_literals.hpp>
+#include <arborio/label_parse.hpp>
 #include "arbor/morph/morphology.hpp"
 #include "common_cells.hpp"
 
 namespace arb {
+using namespace arborio::literals;
 
 // Generate a segment tree from a sequence of points and parent index.
 arb::segment_tree segments_from_points(
@@ -175,7 +176,6 @@ cable_cell_description soma_cell_builder::make_cell() const {
  */
 
 cable_cell_description make_cell_soma_only(bool with_stim) {
-    using namespace arb::literals;
     soma_cell_builder builder(18.8/2.0);
 
     auto c = builder.make_cell();
@@ -209,7 +209,6 @@ cable_cell_description make_cell_soma_only(bool with_stim) {
  */
 
 cable_cell_description make_cell_ball_and_stick(bool with_stim) {
-    using namespace arb::literals;
     soma_cell_builder builder(12.6157/2.0);
     builder.add_branch(0, 200, 1.0/2, 1.0/2, 4, "dend");
 
@@ -246,7 +245,6 @@ cable_cell_description make_cell_ball_and_stick(bool with_stim) {
  */
 
 cable_cell_description make_cell_ball_and_3stick(bool with_stim) {
-    using namespace arb::literals;
     soma_cell_builder builder(12.6157/2.0);
     builder.add_branch(0, 100, 0.5, 0.5, 4, "dend");
     builder.add_branch(1, 100, 0.5, 0.5, 4, "dend");
diff --git a/test/unit/test_cable_cell.cpp b/test/unit/test_cable_cell.cpp
index 882f40824a6fdfbc3258b40eeb054872c7496b8c..9019330c56736c830457990d7626a74075a94d57 100644
--- a/test/unit/test_cable_cell.cpp
+++ b/test/unit/test_cable_cell.cpp
@@ -3,10 +3,11 @@
 
 #include <arbor/cable_cell.hpp>
 #include <arbor/cable_cell_param.hpp>
-#include <arbor/string_literals.hpp>
+
+#include <arborio/label_parse.hpp>
 
 using namespace arb;
-using namespace arb::literals;
+using namespace arborio::literals;
 
 TEST(cable_cell, lid_ranges) {
 
@@ -21,7 +22,7 @@ TEST(cable_cell, lid_ranges) {
     arb::morphology morph(tree);
 
     label_dict dict;
-    dict.set("term", locset("(terminal)"));
+    dict.set("term", "(terminal)"_ls);
 
     decor decorations;
 
diff --git a/test/unit/test_fvm_layout.cpp b/test/unit/test_fvm_layout.cpp
index ed621761bc989a88e1f020f058706a6f5b0b1cab..1f58d34e7f208cfcdb686b81672310b2f442193b 100644
--- a/test/unit/test_fvm_layout.cpp
+++ b/test/unit/test_fvm_layout.cpp
@@ -2,10 +2,11 @@
 #include <string>
 #include <vector>
 
+#include <arborio/label_parse.hpp>
+
 #include <arbor/cable_cell.hpp>
 #include <arbor/math.hpp>
 #include <arbor/mechcat.hpp>
-
 #include "arbor/cable_cell_param.hpp"
 #include "arbor/morph/morphology.hpp"
 #include "arbor/morph/segment_tree.hpp"
@@ -22,6 +23,7 @@
 
 using namespace std::string_literals;
 using namespace arb;
+using namespace arborio::literals;
 
 using util::make_span;
 using util::count_along;
@@ -54,8 +56,8 @@ namespace {
             builder.add_branch(0, 200, 1.0/2, 1.0/2, 4, "dend");
 
             auto description = builder.make_cell();
-            description.decorations.paint("\"soma\"", "hh");
-            description.decorations.paint("\"dend\"", "pas");
+            description.decorations.paint("soma"_lab, "hh");
+            description.decorations.paint("dend"_lab, "pas");
             description.decorations.place(builder.location({1,1}), i_clamp{5, 80, 0.3}, "clamp");
 
             s.builders.push_back(std::move(builder));
@@ -97,8 +99,8 @@ namespace {
             auto b3 = b.add_branch(1, 180, 0.35, 0.35, 4, "dend");
             auto desc = b.make_cell();
 
-            desc.decorations.paint("\"soma\"", "hh");
-            desc.decorations.paint("\"dend\"", "pas");
+            desc.decorations.paint("soma"_lab, "hh");
+            desc.decorations.paint("dend"_lab, "pas");
 
             using ::arb::reg::branch;
             auto c1 = reg::cable(b1-1, b.location({b1, 0}).pos, 1);
@@ -563,10 +565,10 @@ TEST(fvm_layout, density_norm_area) {
     hh_3["gl"] = seg3_gl;
 
     auto desc = builder.make_cell();
-    desc.decorations.paint("\"soma\"", std::move(hh_0));
-    desc.decorations.paint("\"reg1\"", std::move(hh_1));
-    desc.decorations.paint("\"reg2\"", std::move(hh_2));
-    desc.decorations.paint("\"reg3\"", std::move(hh_3));
+    desc.decorations.paint("soma"_lab, std::move(hh_0));
+    desc.decorations.paint("reg1"_lab, std::move(hh_1));
+    desc.decorations.paint("reg2"_lab, std::move(hh_2));
+    desc.decorations.paint("reg3"_lab, std::move(hh_3));
 
     std::vector<cable_cell> cells{desc};
 
@@ -712,7 +714,7 @@ TEST(fvm_layout, density_norm_area_partial) {
 
 TEST(fvm_layout, valence_verify) {
     auto desc = soma_cell_builder(6).make_cell();
-    desc.decorations.paint("\"soma\"", "test_cl_valence");
+    desc.decorations.paint("soma"_lab, "test_cl_valence");
     std::vector<cable_cell> cells{desc};
 
     cable_cell_global_properties gprop;
@@ -841,9 +843,9 @@ TEST(fvm_layout, revpot) {
     builder.add_branch(1, 200, 0.5, 0.5, 1, "dend");
     builder.add_branch(1, 100, 0.5, 0.5, 1, "dend");
     auto desc = builder.make_cell();
-    desc.decorations.paint("\"soma\"", "read_eX/c");
-    desc.decorations.paint("\"soma\"", "read_eX/a");
-    desc.decorations.paint("\"dend\"", "read_eX/a");
+    desc.decorations.paint("soma"_lab, "read_eX/c");
+    desc.decorations.paint("soma"_lab, "read_eX/a");
+    desc.decorations.paint("dend"_lab, "read_eX/a");
 
     std::vector<cable_cell_description> descriptions{desc, desc};
 
diff --git a/test/unit/test_fvm_lowered.cpp b/test/unit/test_fvm_lowered.cpp
index d8f04096b0a9e9e379a38a405bbb52c5c371f71e..e26e6d90435ba2267e8ec4948ba3b1c05cdf7616 100644
--- a/test/unit/test_fvm_lowered.cpp
+++ b/test/unit/test_fvm_lowered.cpp
@@ -5,6 +5,8 @@
 
 #include "../gtest.h"
 
+#include <arborio/label_parse.hpp>
+
 #include <arbor/common_types.hpp>
 #include <arbor/domain_decomposition.hpp>
 #include <arbor/fvm_types.hpp>
@@ -15,7 +17,6 @@
 #include <arbor/sampling.hpp>
 #include <arbor/simulation.hpp>
 #include <arbor/schedule.hpp>
-#include <arbor/string_literals.hpp>
 #include <arbor/util/any_ptr.hpp>
 
 #include <arborenv/concurrency.hpp>
@@ -37,6 +38,7 @@
 #include "../simple_recipes.hpp"
 
 using namespace std::string_literals;
+using namespace arborio::literals;
 
 using backend = arb::multicore::backend;
 using fvm_cell = arb::fvm_lowered_cell_impl<backend>;
@@ -573,7 +575,7 @@ TEST(fvm_lowered, read_valence) {
 
         soma_cell_builder builder(6);
         auto cell = builder.make_cell();
-        cell.decorations.paint("\"soma\"", "test_ca_read_valence");
+        cell.decorations.paint("soma"_lab, "test_ca_read_valence");
         cable1d_recipe rec(cable_cell{cell});
         rec.catalogue() = make_unit_test_catalogue();
 
@@ -596,7 +598,7 @@ TEST(fvm_lowered, read_valence) {
         // Check ion renaming.
         soma_cell_builder builder(6);
         auto cell = builder.make_cell();
-        cell.decorations.paint("\"soma\"", "cr_read_valence");
+        cell.decorations.paint("soma"_lab, "cr_read_valence");
         cable1d_recipe rec(cable_cell{cell});
         rec.catalogue() = make_unit_test_catalogue();
         rec.catalogue() = make_unit_test_catalogue();
@@ -684,7 +686,6 @@ TEST(fvm_lowered, ionic_concentrations) {
 }
 
 TEST(fvm_lowered, ionic_currents) {
-    using namespace arb::literals;
     arb::proc_allocation resources;
     if (auto nt = arbenv::get_env_num_threads()) {
         resources.num_threads = nt;
diff --git a/test/unit/test_mc_cell_group.cpp b/test/unit/test_mc_cell_group.cpp
index e72a3a5360e64699ce1117b4a73aaa6ab0eb289c..035c7d6758b21dda75e779177cb6489a69f8ed14 100644
--- a/test/unit/test_mc_cell_group.cpp
+++ b/test/unit/test_mc_cell_group.cpp
@@ -1,7 +1,8 @@
 #include "../gtest.h"
 
 #include <arbor/common_types.hpp>
-#include <arbor/string_literals.hpp>
+
+#include <arborio/label_parse.hpp>
 
 #include "epoch.hpp"
 #include "fvm_lowered_cell.hpp"
@@ -13,7 +14,7 @@
 #include "../simple_recipes.hpp"
 
 using namespace arb;
-using namespace arb::literals;
+using namespace arborio::literals;
 
 namespace {
     execution_context context;
diff --git a/test/unit/test_morph_expr.cpp b/test/unit/test_morph_expr.cpp
index 2ff804d23543be61cf74057ba3505fa8bc2f9cdd..2aebcd16e1973b902ec80c96f5dacec053df86d8 100644
--- a/test/unit/test_morph_expr.cpp
+++ b/test/unit/test_morph_expr.cpp
@@ -2,6 +2,8 @@
 
 #include <vector>
 
+#include <arborio/label_parse.hpp>
+
 #include <arbor/morph/embed_pwlin.hpp>
 #include <arbor/morph/locset.hpp>
 #include <arbor/morph/morphexcept.hpp>
@@ -9,7 +11,6 @@
 #include <arbor/morph/mprovider.hpp>
 #include <arbor/morph/primitives.hpp>
 #include <arbor/morph/region.hpp>
-#include <arbor/string_literals.hpp>
 
 #include "util/span.hpp"
 #include "util/strprintf.hpp"
@@ -19,7 +20,7 @@
 #include "morph_pred.hpp"
 
 using namespace arb;
-using namespace arb::literals;
+using namespace arborio::literals;
 using embedding = embed_pwlin;
 
 using testing::region_eq;
@@ -124,7 +125,7 @@ TEST(locset, thingify_named) {
         label_dict dict;
         dict.set("banana", banana);
         dict.set("cake", cake);
-        dict.set("topping", locset("(locset \"fruit\")"));
+        dict.set("topping", locset("fruit"_lab));
         dict.set("fruit", sum(locset("banana"_lab), locset("topping"_lab)));
 
         EXPECT_THROW(mprovider(morphology(sm), dict), circular_definition);
@@ -157,7 +158,7 @@ TEST(region, thingify_named) {
         dict.set("banana", banana);
         dict.set("cake", cake);
         dict.set("topping", region("fruit"_lab));
-        dict.set("fruit", region("(region \"strawberry\")"));
+        dict.set("fruit", "(region \"strawberry\")"_reg);
 
         EXPECT_THROW(mprovider(morphology(sm), dict), unbound_name);
     }
@@ -166,7 +167,7 @@ TEST(region, thingify_named) {
         dict.set("banana", banana);
         dict.set("cake", cake);
         dict.set("topping", region("fruit"_lab));
-        dict.set("fruit", join(region("(region \"cake\")"), region("topping"_lab)));
+        dict.set("fruit", join("(region \"cake\")"_reg, region("topping"_lab)));
 
         EXPECT_THROW(mprovider(morphology(sm), dict), circular_definition);
     }
diff --git a/test/unit/test_morph_stitch.cpp b/test/unit/test_morph_stitch.cpp
index a26b35ea58ea8e5428e37a3ed21bf312bbf91af2..94d4d049ccb70165fe4a4b7f1392d350c689472c 100644
--- a/test/unit/test_morph_stitch.cpp
+++ b/test/unit/test_morph_stitch.cpp
@@ -8,13 +8,14 @@
 #include <arbor/morph/place_pwlin.hpp>
 #include <arbor/morph/primitives.hpp>
 #include <arbor/morph/stitch.hpp>
-#include <arbor/string_literals.hpp>
+
+#include <arborio/label_parse.hpp>
 
 #include "../test/gtest.h"
 #include "morph_pred.hpp"
 
 using namespace arb;
-using namespace arb::literals;
+using namespace arborio::literals;
 using testing::region_eq;
 
 TEST(morph, stitch_none_or_one) {
@@ -147,8 +148,8 @@ TEST(morph, stitch_two) {
             ASSERT_EQ(p2, seg1.dist);
             EXPECT_TRUE(region_eq(p, "stitch:0"_lab, join(reg::segment(0), reg::segment(1))));
             EXPECT_TRUE(region_eq(p, "stitch:1"_lab, reg::segment(2)));
-            EXPECT_TRUE(region_eq(p, "(segment 2)", reg::segment(2)));
-            EXPECT_TRUE(region_eq(p, "(region \"stitch:1\")", reg::segment(2)));
+            EXPECT_TRUE(region_eq(p, "(segment 2)"_reg, reg::segment(2)));
+            EXPECT_TRUE(region_eq(p, "(region \"stitch:1\")"_reg, reg::segment(2)));
         }
     }
 }
diff --git a/test/unit/test_s_expr.cpp b/test/unit/test_s_expr.cpp
index 92ddcb1ffaa1539ddc0b9d8b0242dadf0c3898ea..01dd22da1fee2e5a1434f90d24e2b998572ea8e8 100644
--- a/test/unit/test_s_expr.cpp
+++ b/test/unit/test_s_expr.cpp
@@ -5,15 +5,20 @@
 
 #include <arbor/morph/region.hpp>
 #include <arbor/morph/locset.hpp>
-#include <arbor/morph/label_parse.hpp>
+#include <arbor/cv_policy.hpp>
+
 #include <arbor/s_expr.hpp>
 
+#include <arborio/cv_policy_parse.hpp>
 #include <arborio/cableio.hpp>
+#include <arborio/label_parse.hpp>
 
 #include "parse_s_expr.hpp"
 #include "util/strprintf.hpp"
 
 using namespace arb;
+using namespace arborio;
+using namespace arborio::literals;
 using namespace std::string_literals;
 
 TEST(s_expr, atoms) {
@@ -174,6 +179,15 @@ std::string round_trip_label(const char* in) {
     }
 }
 
+std::string round_trip_cv(const char* in) {
+    if (auto x = parse_cv_policy_expression(in)) {
+        return util::pprintf("{}", std::any_cast<cv_policy>(*x));
+    }
+    else {
+        return x.error().what();
+    }
+}
+
 std::string round_trip_region(const char* in) {
     if (auto x = parse_region_expression(in)) {
         return util::pprintf("{}", std::any_cast<arb::region>(*x));
@@ -192,6 +206,44 @@ std::string round_trip_locset(const char* in) {
     }
 }
 
+
+TEST(cv_policies, round_tripping) {
+    auto literals = {"(every-segment (tag 42))",
+                     "(fixed-per-branch 23 (segment 0) 1)",
+                     "(max-extent 23.1 (segment 0) 1)",
+                     "(single (segment 0))",
+                     "(explicit (terminal) (segment 0))",
+                     "(join (every-segment (tag 42)) (single (segment 0)))",
+                     "(replace (every-segment (tag 42)) (single (segment 0)))",
+    };
+    for (const auto& literal: literals) {
+        EXPECT_EQ(literal, round_trip_cv(literal));
+    }
+}
+
+TEST(cv_policies, literals) {
+    EXPECT_NO_THROW("(every-segment (tag 42))"_cvp);
+    EXPECT_NO_THROW("(fixed-per-branch 23 (segment 0) 1)"_cvp);
+    EXPECT_NO_THROW("(max-extent 23.1 (segment 0) 1)"_cvp);
+    EXPECT_NO_THROW("(single (segment 0))"_cvp);
+    EXPECT_NO_THROW("(explicit (terminal) (segment 0))"_cvp);
+    EXPECT_NO_THROW("(join (every-segment (tag 42)) (single (segment 0)))"_cvp);
+    EXPECT_NO_THROW("(replace (every-segment (tag 42)) (single (segment 0)))"_cvp);
+}
+
+TEST(cv_policies, bad) {
+    auto check = [](const std::string& s) {
+        auto cv = parse_cv_policy_expression(s);
+        if (!cv.has_value()) throw cv.error();
+        return cv.value();
+    };
+
+    EXPECT_THROW(check("(every-segment (tag 42) 1)"), cv_policy_parse_error); // extra arg
+    EXPECT_THROW(check("(every-segment (terminal))"), cv_policy_parse_error); // locset instead of region
+    EXPECT_THROW(check("(every-segment"), cv_policy_parse_error);             // missing paren
+    EXPECT_THROW(check("(tag 42)"), cv_policy_parse_error);                   // not a cv_policy
+}
+
 TEST(regloc, round_tripping) {
     EXPECT_EQ("(cable 3 0 1)", round_trip_label<arb::region>("(branch 3)"));
     EXPECT_EQ("(intersect (tag 1) (intersect (tag 2) (tag 3)))", round_trip_label<arb::region>("(intersect (tag 1) (tag 2) (tag 3))"));
@@ -362,9 +414,6 @@ std::ostream& operator<<(std::ostream& o, const mechanism_desc& m) {
 std::ostream& operator<<(std::ostream& o, const ion_reversal_potential_method& p) {
     return o << "(ion-reversal-potential-method \"" << p.ion << "\" " << p.method << ')';
 }
-std::ostream& operator<<(std::ostream& o, const cv_policy&) {
-    return o;
-}
 std::ostream& operator<<(std::ostream& o, const branch& b) {
     o << "(branch " << std::to_string(std::get<0>(b)) << " " << std::to_string(std::get<1>(b));
     for (auto s: std::get<2>(b)) {
diff --git a/test/unit/test_spikes.cpp b/test/unit/test_spikes.cpp
index ce47431f7c65abae0c5c55560c82f0f1c286d58f..292c1fa3b1b96ddd653cbed991dbb51b8fc1bcc0 100644
--- a/test/unit/test_spikes.cpp
+++ b/test/unit/test_spikes.cpp
@@ -1,5 +1,7 @@
 #include "../gtest.h"
 
+#include <arborio/label_parse.hpp>
+
 #include <arborenv/concurrency.hpp>
 #include <arborenv/gpu_env.hpp>
 
@@ -14,6 +16,7 @@
 #include <simple_recipes.hpp>
 
 using namespace arb;
+using namespace arborio::literals;
 
 // This source is included in `test_spikes_gpu.cpp`, which defines
 // USE_BACKEND to override the default `multicore::backend`
@@ -221,9 +224,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}, "detector");
-        decor.place("\"mid\"", arb::i_clamp::box(0.01+i*dt, duration, 0.5), "clamp");
-        decor.place("\"mid\"", arb::mechanism_desc("expsyn"), "synapse");
+        decor.place("mid"_lab, arb::threshold_detector{10}, "detector");
+        decor.place("mid"_lab, arb::i_clamp::box(0.01+i*dt, duration, 0.5), "clamp");
+        decor.place("mid"_lab, arb::mechanism_desc("expsyn"), "synapse");
 
         arb::cable_cell cell(morpho, dict, decor);
         cable1d_recipe rec({cell});