Feature: High-level network specification
This PR implements a high-level network specification as proposed in #418. It does not include support for gap junctions to allow the use of domain decomposition for some distributed network generation.
The general idea is a DSL based on set algebra, which operates on the set of all possible connections, by selecting based on different criteria, such as the distance between cells or lists of labels. By operating on all possible connections, a separate definition of cell populations becomes unnecessary. An example for selecting all inter-cell connections with a certain source and destination label is:
(intersect (inter-cell) (source-label \"detector\") (destination-label \"syn\"))
For parameters such as weight and delay, a value can be defined in the DSL in a similar way with the usual mathematical operations available. An example would be:
(max 0.1 (exp (mul -0.5 (distance))))
The position of each connection site is calculated by resolving the local position on the cell and applying an isometry, which is provided by a new optional function of the recipe. In contrast to the usage of policies to select a member within a locset, each site is treated individually and can be distinguished by its position.
Internally, some steps have been implemented in an attempt to reduce the overhead of generating connections:
- Pre-select source and destination sites based on the selection to reduce the sampling space when possible
- If selection is limited to a maximum distance, use an octree for efficient spatial sampling
- When using MPI, only instantiate local cells and exchange source sites in a ring communication pattern to overlap communication and sampling. In addition, this reduces memory usage, since only the current and next source sites have to be stored in memory during the exchange process.
Custom selection and value functions can still be provided by storing the wrapped function in a dictionary with an associated label, which can then be used in the DSL.
Some challenges remain. In particular, how to handle combined explicit connections returned by connections_on and the new way to describe a network. Also, the use of non-blocking MPI is not easily integrated into the current context types, and the dry-run context is not supported so far.
Example
A (trimmed) example in Python, where a ring connection combined with random connections based on the distance:
class recipe(arbor.recipe):
def cell_isometry(self, gid):
# place cells with equal distance on a circle
radius = 500.0 # μm
angle = 2.0 * math.pi * gid / self.ncells
return arbor.isometry.translate(radius * math.cos(angle), radius * math.sin(angle), 0)
def network_description(self):
seed = 42
# create a chain
ring = f"(chain (gid-range 0 {self.ncells}))"
# connect front and back of chain to form ring
ring = f"(join {ring} (intersect (source-cell {self.ncells - 1}) (destination-cell 0)))"
# Create random connections with probability inversely proportional to the distance within a
# radius
max_dist = 400.0 # μm
probability = f"(div (sub {max_dist} (distance)) {max_dist})"
rand = f"(intersect (random {seed} {probability}) (distance-lt {max_dist}))"
# combine ring with random selection
s = f"(join {ring} {rand})"
# restrict to inter-cell connections and certain source / destination labels
s = f"(intersect {s} (inter-cell) (source-label \"detector\") (destination-label \"syn\"))"
# normal distributed weight with mean 0.02 μS, standard deviation 0.01 μS
# and truncated to [0.005, 0.035]
w = f"(truncated-normal-distribution {seed} 0.02 0.01 0.005 0.035)"
# fixed delay
d = "(scalar 5.0)" # ms delay
return arbor.network_description(s, w, d, {})
TODO
- [x] Export function to allow inspection of generated connections
- [x] Further testing of distributed network generation
- [x] Documentation
Docs @ https://arbor--2050.org.readthedocs.build/en/2050/
I think the idea of using a high level algebra to define networks is really really nice. However, I do not fully see how the PR in its current form would help with creating 'realistic' neural networks as none of the functions use things like soma-soma distances or placed synapse positions. Eg, if I generate 2000 random morphologically realistic cells with synapses randomly distributed over the axons and dendrites, I would like to connect up those within a 50um range with 10% probability. Not any random cell to any other random cell..
Again, I really like the approach, but I don't see yet who the target audience is. One of the use cases I see is generating large benchmarks, which is useful for Arbor developers looking at the scaling capacity. Or I guess generating small-world etc.. networks would also fit in the based method, making it useful for these network-science-on-neurons papers that look at the effect of topology on dynamics.
Are you planning on adding cell-location dependent network selection methods or is that intrinsically impossible in arbor?
The idea is to provide the building blocks for such cases, without having to extent arbor to use otherwise unnecessary information like a global cell position. So assuming the user has a way of generating or reading the cell position, a custom selection with the help of a uniform random network value can be created, for example:
class random_distance_selection:
def __init__(self):
self.uniform_rand = arbor.network_value.uniform_distribution(42, 0.0, 1.0)
def __call__(self, src, dest):
distance = norm(pos(src) - pos(dest))
if distance > 50:
return False
return self.uniform_rand(src, dest) < 0.1
This way, arbor takes care of necessary logic of generating repeatable random numbers for pairs of source and destination, as well as only sampling locally required connections.
Hi @AdhocMan,
sorry for adding to an already long list of comments, but this topic has been near and dear to my heart for a long time. Thanks for taking such good care of it!
Before I start delving into the details of the code, though, some high-level comments:
- I think the docs need some love, especially the motivation and a more prominent first example in that section. How about contrasting the 'old' way and the high-level DSL on for example the Brunel connectivity as an appetizer?
- I agree with Lennart at these spatial queries are the number one concern of real world network building. They need an easy to find explanation. A built-in solution would probably appreciated by users.
- I am unsure about the performance implications. In the old style each
gidsimply states its incoming connections and later resolves labels. That's basically $\mathcal{O}(1)$ per connection. In your proposal I think I see costs of $\mathcal{O}(N_\mathrm{cell})$. Is that correct? What's potentially worse tough is that the Python object is called via a trampolining from C++. See discussion on #1850; but the overhead is Bad. Do have some (scaling) numbers?
NB. 3 doesn't mean your proposal is infeasible or needs more work in this direction. Just that we should be aware of it and potentially add a note in the docs.
1. I think the docs need some love, especially the motivation and a more prominent first example in that section. How about contrasting the 'old' way and the high-level DSL on for example the Brunel connectivity as an appetizer? 2. I agree with Lennart at these spatial queries are the _number one_ concern of real world network building. They need an easy to find explanation. A built-in solution would probably appreciated by users. 3. I am unsure about the performance implications. In the old style each `gid` simply states its incoming connections and later resolves labels. That's basically O(1) per connection. In your proposal I think I see costs of O(Ncell). Is that correct? What's potentially worse tough is that the Python object is called via a trampolining from C++. See discussion on [Performance: Investigate setup times #1850](https://github.com/arbor-sim/arbor/issues/1850); but the overhead is Bad. Do have some (scaling) numbers?NB. 3 doesn't mean your proposal is infeasible or needs more work in this direction. Just that we should be aware of it and potentially add a note in the docs.
Thanks for the feedback.
In general, this new way of specifying connections is not meant as a replacement of the current way. For simple connection setups, the current style is likely a better fit. So when comparing performance, a more fair comparison would include the work required to know which connections to return. In particular, generating random connections between all cells with a non-uniform probability is inherently a $\mathcal{O}(N_{cell})$ operation per cell.
To address the case of spatial queries, I'm working on an extension of the implementation, that includes support for cell locations. It will not take cell structure into account, to avoid the complexities like cell orientation, local label resolution and locsets containing multiple points. Assuming that limiting connections to a maximum distance is a common use case, one can limit the number of cells that need to be sampled by using a spatial data structure (an octree for example). This will require to store location data of all cells, so memory could become an issue for very large models, but I don't see a good way of avoiding this.
Regarding the performance of call backs to Python: The idea is to provide functionality for common cases when possible, such that hopefully a custom selection is not necessary. However, the network generation happens before any label resolution and cell instantiation, so it's not possible to provide a selection based on things like cell type without help from the user. If you see a good way of avoiding call backs to Python for such cases, suggestions would be welcome. Otherwise, there could just be a recommendation to use C++ for large custom network generation. For the new spatial feature, I could imagine something similar to the inhomogeneous expressions, allowing the user to specify a probability function based on the distance between cells.
Once spatial extension is implemented, I'll have another go at the documentation.
Hi @AdhocMan,
I'd like to propose a way that solves the spatial query requirement and buys us much flexibility in the longer run. I am expecting people to start using advanced connectivity soon, as work is going on concerning large dynamically-reconnecting networks. That will naturally want to use spatial information.
So, here's the idea: We add a new callback on recipe that looks like this
std::map<std::string, std::any> metadata_on(cell_gid_type gid);
and returns freely configurable metadata. A default set can be implemented and merged with the user-supplied one (user taking precedence).
Now filters and operations can make use of these fields by selecting them as
(metadata "location" cell)
It's also extensible by the user who can the add their own callbacks and so on. Others that come to mind are the presence of a certain synapse type, belonging to a certain (sub-)population, ....
What do you think?
Here's another design-level bit of feedback: I'd like to isolate the user from the actual gid (and similar low-level bits and bobs) as much as possible.
Rationale for that is that this type of information makes recipes non-composable. Consider two populations -- possibly
even given as their own recipe each -- that we want to wire together. If we define them as ranges over gids, then
order matters and users need to care about which gid is which type/population. If, however, we define population as
some predicate on metadata, this hurdle disappears.
That these elaborate to actual sets of numbers at some point is a given, sure, but the user shouldn't handle those.
@AdhocMan How's the feature going? If you're stuck, please let me know, so we can get you going again :)
@AdhocMan How's the feature going? If you're stuck, please let me know, so we can get you going again :)
I don't have as much time as I was hoping for to work on this, but the spatial support is almost done. Mainly some more tests and adjustment of the documentation / examples left to do.
@thorstenhater Sorry, for the late reply to your suggestions. One of the design choices was to keep the new network generation feature independent of the recipe. This way any potential circular dependency is avoided and the network generation can easily be tested outside of a full simulation, for example to visualize connections first. So adding a meta data function to the recipe would imply major changes to the current design.
Regarding the use of gid when defining populations:
As far as I understand, the user has to know about gid when designing the recipe anyway, so I'm not sure how much of complication this adds. The focus of this design is not on the population, but on the network description through the network_selection and network_value types, which do not depend on gid and are easily transferable between recipes.
In principle, I like the idea of a meta data map, but I'm concerned about how efficient this would be in terms of performance and memory usage, since it would mean creating several strings and std::any objects for all cells on every rank. Only accessing a cell position through a call back would increase the number of call backs significantly again, since this needs to be accessed when generating connections for each cell. In contrast, by having all cell locations supplied in a single container allows for easy construction of an octree, which then can speed up network generation significantly. The spatial feature I'm working on relies on supplying a std::vector of coordinates for every range of cells instead.
The advantage of the user-defined metadata approach is that it is infinitely extensible. Overheads are in the range
of: 1x typeid for the tag and 1x void*, ie quite bearable, especially if we pull out the common one(s), like spatial
information. So, one could have this:
struct meta {
size_t gid;
vec3 pos;
quaternion q;
std::vector<std::string, std::any> user;
}
We use similar type-erased tags for probes, cell-information, and more. My strong advice is to think about at least one iteration of this further than this current instance and make an extensible system.
Note that simple position is not enough, as one might have to handle individual locations on the cell (locset)
which then needs to turned into a list of point via place_pwlin and a quaternion.
One of the design choices was to keep the new network generation feature independent of the recipe.
I think this might be a noble goal that raises more issues than it solves. Currently the network is encoded in the recipe to keep things localised. Why not keep it this way rather than adding another layer that goes against the grain of the current design?
I've updated the description to reflect to current state. It's not quite ready for a full review, but some feedback on the new general concept would be welcome.
Hi @AdhocMan,
this looks lovely, judging by the Python example. If you want some ideas on possible improvements, see below.
# create a chain
ring = f"(chain (gid-range 0 {self.ncells}))"
# connect front and back of chain to form ring
ring = f"(join {ring} (intersect (source-cell {self.ncells - 1}) (destination-cell 0)))"
# Create random connections with probability proportional to the inverse distance within a
# radius
# [TH] Note: comment and implementation seem to mismatch? I'd expect (div a b) ~ a/b and
# comment say 1/dist, yet implementation does (max - dist)/max?!
max_dist = 400.0 # μm
probability = f"(div (sub {max_dist} (distance)) {max_dist})"
# [TH] Why not (lt (distance) max_dist) instead of a specialised function?
# [TH] I assume here (distance) is stl
# (sqrt (sum (map ^2 (- (location gid) (location gid'))))
# where gid is our gid and gid' that of a potential target?
rand = f"(intersect (random {seed} {probability}) (distance-lt {max_dist}))"
# combine ring with random selection
s = f"(join {ring} {rand})"
# restrict to inter-cell connections and certain source / destination labels
s = f"(intersect {s} (inter-cell) (source-label \"detector\") (destination-label \"syn\"))"
# normal distributed weight with mean 0.02 μS, standard deviation 0.01 μS
# and truncated to [0.005, 0.035]
# [TH] again I'd personally prefer (clamp lo hi (normal-distribution mu sigma)) instead of a specialised
# function, but I see the value of convenience. Yet, it'll be really hard to remember which number goes where
# in the argument list.
w = f"(truncated-normal-distribution {seed} 0.02 0.01 0.005 0.035)"
# fixed delay
d = "(scalar 5.0)" # ms delay
return arbor.network_description(s, w, d, {})
In general, the heavy use of string interpolation gives me the idea of (ab)using the label dict (or something similar) to define variables within the DSL but that might be too much?!
d = A.label_dict()
d['num-cells'] = self.ncells
# make a ring by connecting two end of a chain
d['open'] = '(gid-range 0 num-cells)'
d['ring'] = '(join open (intersect (source-cell (sub num-cells 1)) (destination-cell 0)))'
# random connections with probability proportional to the 1/d
dict['seed'] = seed
dict['max-dist'] = 400 # um
dict['prop'] = '(div (sub max_dist (distance)) max-dist)'
dict['rand'] = '(intersect (random seed prop) (distance-lt max-dist))'
# join random and ring parts,
# then restrict to inter-cell connections and certain source / destination labels
dict['conns'] = '(intersect (join ring rand) (inter-cell) (source-label "detector") (destination-label "syn"))'
dict['weight'] = '(truncated-normal-distribution seed 0.02 0.01 0.005 0.035)'
dict['delay'] = '(scalar 5.0)' # ms
# build network passing in the dict
return A.network_description('conns', 'weight', 'delay', dict, {})
Throwing in some syntax sugar and my previous suggestions we'd arrive at
d = A.label_dict()
d['num-cells'] = self.ncells
# make a ring by connecting two end of a chain
d['open'] = '(gid-range 0 num-cells)'
d['ring'] = '(join open (intersect (source-cell (- num-cells 1)) (destination-cell 0)))'
# random connections with probability proportional to the 1/d
dict['seed'] = seed
dict['max-dist'] = 400 # um
dict['prop'] = '(/ (- max_dist (distance)) max-dist)'
dict['rand'] = '(intersect (random seed prop) (< (distance) max-dist))'
# join random and ring parts,
# then restrict to inter-cell connections and certain source / destination labels
dict['conns'] = '(intersect (join ring rand) (inter-cell) (source-label "detector") (destination-label "syn"))'
dict['weight'] = '(clamp 0.005 0.035 (normal-distribution seed 0.02 0.01))'
dict['delay'] = 5.0 # ms
# build network passing in the dict
return A.network_description('conns', 'weight', 'delay', dict, {})
Thanks for the feedback @thorstenhater To address the comments on the python example:
- I'll try to rephrase the comment about the proportionality
- The
(distance-lt ...)expression signals internally the use of the octree. With a general less-than function it would be difficult to establish any maximum distance beyond which no site will be selected. - The distance is not just between cells, but the distance between the actual locations on the cells of the potential target and source
- The truncated normal distribution is not quite the same as a normal distribution clamped to an interval. See https://en.wikipedia.org/wiki/Truncated_normal_distribution
I thought this might be useful, since a normal distribution may result in unwanted extreme values, but just clamping would lead to a disproportionate representation of the clamp values. To clamp, one could use the available
maxandminfunctions, but there is no dedicatedclamp.
Regrading the dictionary: There is already a dictionary 😉. It's not quite as general as you suggested, as it only allows to store network value and selection expressions. That's in part due to how s-expression are parsed internally, since there is only a distinction based on input but not on output. So it work similar to the label dictionary that we currently have. However, adapting your example to the dictionary functionality looks like this (although not quite without string interpolation):
dict = {}
# make a ring by connecting two end of a chain
dict['open'] = f'(gid-range 0 {self.ncells})'
dict['ring'] = f'(join (network-selection \"open\") (intersect (source-cell {self.ncells - 1}) (destination-cell 0)))'
# random connections with probability proportional to the 1/d
dict['prob'] = f'(div (sub {max_dist} (distance)) {max_dist})'
dict['rand'] = f'(intersect (random {seed} (network-value \"prob\")) (distance-lt {max_dist}))'
# join random and ring parts,
# then restrict to inter-cell connections and certain source / destination labels
dict['conns'] = '(intersect (join (network-selection \"ring\") (network-selection \"rand\")) (inter-cell) (source-label "detector") (destination-label "syn"))'
dict['weight'] = f'(truncated-normal-distribution {seed} 0.02 0.01 0.005 0.035)'
dict['delay'] = '(scalar 5.0)' # ms
# build network passing in the dict
return A.network_description('(network-selection \"conns\")', '(network-value \"weight\")', '(network-value \"delay\")', dict)
It might be possible to use a string directly instead of something like (network-value \"name\"), although not quite sure. The main reason for implementing the dictionary was to still allow the usage of custom functions by storing them in wrapping arbor.network_value.custom(...) and arbor.network_selection.custom(...) objects, which can then be used through the assigned label in the s-expressions.
Would this work?
dict['num-cells'] = f'(scalar {self.ncells})'
dict['open'] = '(gid-range 0 num-cells)'
?
The (distance-lt ...) expression signals internally the use of the octree. With a general less-than function it would be difficult to establish any maximum distance beyond which no site will be selected.
This is a very subtle interaction and will cause confusion almost certainly! Assume someone trying this
(exp (- (pow (distance) 2)))
vs the distance-lt and not getting acceleration. Wouldn't (distance) be a better and more general trigger?
Cool! Looks extensible and already covering all the obvious sorts of networks.
It did get a bit more difficult to read if you're not familiar with predicate notation. So first some questions to see if I followed along well. In the example:
-
ringdoes not define sites on cells, correct? -
randwill have evaluatedrandomfor what exactly, every cell or every connection site? - RE
# restrict to inter-cell connections and certain source- So, were connections between all connections sites on all cells defined above? i.e.
ringdefines connections between all conn sites between cells?
- So, were connections between all connections sites on all cells defined above? i.e.
-
network_description(): when it comes to weights and delays, what would be the way to define for instance a different delay forringandrand?
@thorstenhater: names in the dict will need to be quoted, "perhaps" "making" "things" "not" "terribly" "readable".
Would this work?
dict['num-cells'] = f'(scalar {self.ncells})' dict['open'] = '(gid-range 0 num-cells)'
You can generally store any string and both are valid expressions in this case. Although the first evaluates to a network value expression, which cannot be used to represent an integer as expected by something like (source-cell ...). However something like '5' could be stored in a string but not parsed as an expression for later use.
The (distance-lt ...) expression signals internally the use of the octree. With a general less-than function it would be difficult to establish any maximum distance beyond which no site will be selected.
This is a very subtle interaction and will cause confusion almost certainly! Assume someone trying this
(exp (- (pow (distance) 2)))vs the
distance-ltand not getting acceleration. Wouldn't(distance)be a better and more general trigger?
The octree optimization can only be used when the selection doesn't include connections beyond a certain distance, which is difficult to determine for a comparison of generalized functions as you suggested. Do you have some idea how to determine the support of such general functions or when a comparison of two general functions is always false beyond a certain distance? Any network value expression used for weight or delay is only evaluated if the connection is included in the selection, so one may still use such generalized distance expressions without penalty there.
It did get a bit more difficult to read if you're not familiar with predicate notation. So first some questions to see if I followed along well. In the example:
* `ring` does not define sites on cells, correct?
The selection expressions operate on the space of all possible connections. So the ring selection of the example will select all possible connections that satisfy the gid restrictions, hence select all source and destination sites of each cell in the ring. If each cell had two source and destination sites, there would be four incoming and four outgoing connections. You can restrict this to connections with certain labels as shown later in the example.
* `rand` will have evaluated `random` for what exactly, every cell or every connection site?
From all possible connections, a random subset will be selected. Therefore this is individually determined for each connection site combination.
* `network_description()`: when it comes to weights and delays, what would be the way to define for instance a different delay for `ring` and `rand`?
Right now, this is not quite possible unless one would write a custom function call-back. The idea so far is to describe the selection and parameters independently. However, if this kind of distinction is a common case, then one could possibility implement some kind of (if-else (inter-cell) (scalar 1.0) (scalar 2.0)) expression, which returns a network value depending on whether a connection is part of the given selection.
I've added the (if-else ...) expression for the network_value type, to enable different value calculations between selections.
This should (for now) be feature complete and is ready for review.
@Helveg Hi, maybe this is interesting to you and you could give us some feedback here.
Acknowledging the risk that I will the reviewer.... hashtag:Bump
This PR has dropped off my radar unfortunately. I've now merged the current master branch and I think it should be complete. So a review would be welcome.
@w-klijn could you give it a read? I'll take care of the technical bit, so focus on the API from the viewpoint of a domain scientist.
I just came across this PR in my inbox, looks great! I tried to port the brunel network to it but ran in some small/minor issues, maybe this is useful:
-
seedis missing in the documentation for random (listed now as(random p:real)) - it might be useful to list the 'return types' of the function in the documentation, eg.
(random p:real) -> network-value,(gid-range ..) -> gid-range - there is a space missing (very minor) in the argument count error:
No matches for 'if-else' with 3arguments:
Error reporting is a bit sparse in general (what is unknown, which of s, w, d is wrong?), eg I got this:
RuntimeError: error in label description: No matches for 'if-else' with 3arguments: (unknown unknown unknown)
There are 1 potential candidates:
Candidate 1 Returns the first network-value if a connection is the given network-selection and the second network-value otherwise. 3 arguments: (sel:network-selection, true_value:network-value, false_value:network_value) at :1:2
for what I thought would be a fine expression:
(if-else
(source-cell (gid-range 0 400))
(random 42 0.000125)
(random 42 0.0005)
)
So the condition gets reported as unknown even though its the branches that are of the wrong type, the working expression being:
(random
42
(if-else
(source-cell (gid-range 0 400))
(scalar 0.000125)
(scalar 0.0005)
)
)
which was a bit counterintuitve. Maybe an if-else for network selections would be nice
Thanks @thorstenhater and @llandsmeer for the review.
For one, shifted_by(-1) subsumes chain_reverse. In general, it adds more power for the user at essentially zero cost to us. No harm in keeping chain and chain_reverse, while adding cyclic_shift and using it to implement the other two.
There is no answer field for this to respond somehow. The gid_range has an optional step size parameter. So I think together with chain_reverse this additional power is already there. For complete control, the option to provide a custom ordering through a list of indices is also there.
@llandsmeer I've fixed the issues you mentioned and improved the error message. It should now include the correct type names. The addition of return types in the documentation would be inconsistent to how we have documented these expressions so far. Since they are grouped by return type though, I think it's not quite necessary.
The example you showed is quite specific, since the distinction between the two selections is just a parameter. The DSL for selections is designed more generally. So one would usually use operations like join and intersect. For you example, this would be something like:
condition = "(source-cell (gid-range 0 400)"
true_sel = "(random 42 0.000125)"
false_sel = "(random 42 0.0005)"
selection = f"(join (intersect {condition} {true_sel}) (intersect (complement {condition}) {false_sel}))"
Based from the discussion I know how complex the challenge is you have solved here. When reading the example code it is extremely impressive that you managed to get this in such a terse and succinct description.
The change introduces a complete new language with a complex grammar. The provided example with API does not do the work justice. I would strongly suggest to add a howto and more detail explanation how to use it. But, after merging as this functionality as it is is valuable and should not be lost. Take the effort to add a howto and detailed explanation and guide, after the fact. This is a important change with great potential.
Non blocking remarks:
-
install manual still uses pip (python3 has been default for 90% of the unix world). Not your problem though.
-
The documentation throws a lot of things to the user. (https://arbor--2050.org.readthedocs.build/en/2050/concepts/interconnectivity.html) I would try start with explaining that a new language will be used to define networks. I would suggest to build up to the existing example in smaller steps.
a. Would it be possible to add a smallest trivial example? Connect two neurons together with this language?
b. I would suggest to explain what is expected in the return values. (return A.network_description(s, w, d, {})) The single value output are not informative. (potentially this is explain else, but provide a link to this location.
Observation 3. The C++ and python examples have a difference p95 chain = ... vs c99 ring = .... The assignment in c++ I think is conceptually soso, as at the first assignment the content of 'ring' is a chain
@w-klijn Thanks for your feedback! The documentation certainly could be improved.
One important inclusion at the moment are intra-cell connections, so connections with the source and target on the same cell. To avoid generating such connections, one has to always include the (inter-cell) restriction, which might be easily forgotten.
I'm curious if you think intra-cell connections should be allowed or rather be excluded for ease of use?