Skip to content

Conversation

ElliottjPierce
Copy link
Contributor

@ElliottjPierce ElliottjPierce commented Apr 3, 2025

Objective

There are two problems this aims to solve.

First, Entity::index is currently a u32. That means there are u32::MAX + 1 possible entities. Not only is that awkward, but it also make Entity allocation more difficult. I discovered this while working on remote entity reservation, but even on main, Entities doesn't handle the u32::MAX + 1 entity very well. It can not be batch reserved because that iterator uses exclusive ranges, which has a maximum upper bound of u32::MAX - 1. In other words, having u32::MAX as a valid index can be thought of as a bug right now. We either need to make that invalid (this PR), which makes Entity allocation cleaner and makes remote reservation easier (because the length only needs to be u32 instead of u64, which, in atomics is a big deal), or we need to take another pass at Entities to make it handle the u32::MAX index properly.

Second, TableRow, ArchetypeRow and EntityIndex (a type alias for u32) all have u32 as the underlying type. That means using these as the index type in a SparseSet uses 64 bits for the sparse list because it stores Option<IndexType>. By using NonMaxU32 here, we cut the memory of that list in half. To my knowledge, EntityIndex is the only thing that would really benefit from this niche. TableRow and ArchetypeRow I think are not stored in an Option in bulk. But if they ever are, this would help. Additionally this ensures TableRow::INVALID and ArchetypeRow::INVALID never conflict with an actual row, which in a nice bonus.

As a related note, if we do components as entities where ComponentId becomes Entity, the the SparseSet<ComponentId> will see a similar memory improvement too.

Solution

Create a new type EntityRow that wraps NonMaxU32, similar to TableRow and ArchetypeRow.
Change Entity::index to this type.

Downsides

NonMax is implemented as a NonZero with a binary inversion. That means accessing and storing the value takes one more instruction. I don't think that's a big deal, but it's worth mentioning.

As a consequence, to_bits uses transmute to skip the inversion which keeps it a nop. But that also means that ordering has now flipped. In other words, higher indices are considered less than lower indices. I don't think that's a problem, but it's also worth mentioning.

Alternatives

We could keep the index as a u32 type and just document that u32::MAX is invalid, modifying Entities to ensure it never gets handed out. (But that's not enforced by the type system.) We could still take advantage of the niche here in ComponentSparseSet. We'd just need some unsafe manual conversions, which is probably fine, but opens up the possibility for correctness problems later.

We could change Entities to fully support the u32::MAX index. (But that makes Entities more complex and potentially slightly slower.)

Testing

  • CI
  • A few tests were changed because they depend on different ordering and to_bits values.

Future Work

  • It might be worth removing the niche on Entity::generation since there is now a different niche.
  • We could move Entity::generation into it's own type too for clarity.
  • We should change ComponentSparseSet to take advantage of the new niche. (This PR doesn't change that yet.)
  • Consider removing or updating Identifier. This is only used for Entity, so it might be worth combining since Entity is now more unique.

@alice-i-cecile alice-i-cecile added A-ECS Entities, components, systems, and events C-Code-Quality A section of code that is hard to understand or change D-Complex Quite challenging from either a design or technical perspective. Ask for help! S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Apr 3, 2025
glam = "0.29"
rand = "0.8"
rand_chacha = "0.3"
nonmax = { version = "0.5", default-features = false }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there value in using NonMax over NonZero (I can see the value of banning u32::MAX from being a valid Entity but that doesn't require that the missing value be there)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we do u32, we just need to document that u32::MAX is invalid. We can go that route, but that means there won't be a niche in things like TableRow or anything. (Or at least, it would be more work to convert types, etc.)

If we do u32::NonZero, we loose both index 0 and u32::MAX. We still need careful documentation, and we need to make sure we put a dummy value at index 0 anywhere we are using it as an index.

If we do u32::NonZero but map it to a non max somehow, then we're effectively just reinventing NonMax.

So out of those options I went with NonMax here. But that's not to say we can't do otherwise. If you have strong opinions otherwise, feel free to make your case.

Copy link
Contributor

@Guvante Guvante Apr 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I only called it out since the PR focused solely on wanting a niche but didn't go into why this value was chosen.

IMHO I would lean towards banning 0 as a dummy (many similar setups just use 0 as explicitly none) and either fixing the places that can't handle Max or banning it. I only say that because I think the code here to use NonMax efficiently isn't worth the trade off of preserving 0 that isn't really used.

However I don't think this set of changes is bad and so wouldn't be against this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's fair. We just need to put a niche in the EntityRow, marking one value as invalid. Then the total number of ids possible is within u32::MAX. That's the goal.

So we could make 0 the niche, create dummy values for every relevant list, communicate that to users, and map 0..u32::MAX onto 1..=u32::MAX in the allocator. That would work. Pro: no op to access the index. Cons: need dummy values + extra allocation + extra math in entity allocator + invalidates some serialized data.

This pr makes the max the niche, keeping everything but the bit layout exactly the same. Pro: very simple. Cons: binary ! to access + invalidates a lot of serialized data.

It's just a trade off. If the consensus is different, I'm 100% fine with that. But at the end of the day, we just need to pick one.

@NthTensor NthTensor added the M-Needs-Migration-Guide A breaking change to Bevy's public API that needs to be noted in a migration guide label Apr 4, 2025
Copy link
Contributor

github-actions bot commented Apr 4, 2025

It looks like your PR is a breaking change, but you didn't provide a migration guide.

Please review the instructions for writing migration guides, then expand or revise the content in the migration guides directory to reflect your changes.

@atlv24
Copy link
Contributor

atlv24 commented May 7, 2025

group                                                                                                  main                                    the_pr
-----                                                                                                  ----                                    ------
all_added_detection/5000_entities_ecs::change_detection::Table                                         1.00      3.2±0.17µs        ? ?/sec     1.15      3.7±0.12µs        ? ?/sec
all_changed_detection/50000_entities_ecs::change_detection::Table                                      1.00     31.2±0.57µs        ? ?/sec     1.16     36.2±0.74µs        ? ?/sec
all_changed_detection/5000_entities_ecs::change_detection::Table                                       1.00      3.2±0.10µs        ? ?/sec     1.16      3.7±0.18µs        ? ?/sec
busy_systems/03x_entities_09_systems                                                                   1.15    181.6±2.29µs        ? ?/sec     1.00    157.8±1.96µs        ? ?/sec
contrived/05x_entities_03_systems                                                                      1.26     54.7±1.11µs        ? ?/sec     1.00     43.5±1.81µs        ? ?/sec
empty_archetypes/for_each/100                                                                          1.00  1642.6±89.87ns        ? ?/sec     1.16  1898.2±167.09ns        ? ?/sec
empty_archetypes/for_each/10000                                                                        1.84     15.6±0.46µs        ? ?/sec     1.00      8.5±0.29µs        ? ?/sec
empty_archetypes/iter/10000                                                                            2.04     14.4±0.51µs        ? ?/sec     1.00      7.1±0.38µs        ? ?/sec
empty_archetypes/par_for_each/10                                                                       1.17      8.3±0.60µs        ? ?/sec     1.00      7.1±0.40µs        ? ?/sec
empty_archetypes/par_for_each/100                                                                      1.25      8.9±0.56µs        ? ?/sec     1.00      7.1±0.50µs        ? ?/sec
empty_archetypes/par_for_each/1000                                                                     1.58     14.9±0.52µs        ? ?/sec     1.00      9.4±1.19µs        ? ?/sec
empty_archetypes/par_for_each/10000                                                                    1.40     27.0±0.96µs        ? ?/sec     1.00     19.3±0.82µs        ? ?/sec
empty_systems/100_systems                                                                              1.00     63.9±0.98µs        ? ?/sec     1.17     74.5±0.77µs        ? ?/sec
entity_hash/entity_set_lookup_miss_gen/10000                                                           1.00     36.9±1.56µs 258.7 MElem/sec    1.64     60.5±1.24µs 157.8 MElem/sec
entity_hash/entity_set_lookup_miss_gen/3162                                                            1.00     11.9±0.51µs 254.4 MElem/sec    1.23     14.6±0.60µs 206.0 MElem/sec
event_propagation/single_event_type_no_listeners                                                       1.19    333.7±9.80µs        ? ?/sec     1.00   280.1±16.74µs        ? ?/sec
events_send/size_16_events_100                                                                         1.18    154.2±3.02ns        ? ?/sec     1.00    130.4±3.59ns        ? ?/sec
events_send/size_4_events_100                                                                          1.64    138.5±2.38ns        ? ?/sec     1.00     84.5±1.68ns        ? ?/sec
events_send/size_4_events_1000                                                                         1.32  1113.6±20.78ns        ? ?/sec     1.00   842.8±20.64ns        ? ?/sec
few_changed_detection/5000_entities_ecs::change_detection::Sparse                                      1.00      5.2±0.39µs        ? ?/sec     1.17      6.1±0.32µs        ? ?/sec
iter_fragmented/base                                                                                   1.27    437.9±7.09ns        ? ?/sec     1.00    345.2±7.28ns        ? ?/sec
multiple_archetypes_none_changed_detection/5_archetypes_1000_entities_ecs::change_detection::Sparse    1.00      3.3±0.13µs        ? ?/sec     1.15      3.7±0.13µs        ? ?/sec
none_changed_detection/50000_entities_ecs::change_detection::Sparse                                    1.00     32.3±1.69µs        ? ?/sec     1.16     37.6±2.60µs        ? ?/sec
none_changed_detection/5000_entities_ecs::change_detection::Sparse                                     1.00      3.2±0.13µs        ? ?/sec     1.16      3.7±0.15µs        ? ?/sec
par_iter_simple/hybrid                                                                                 1.20    76.8±13.11µs        ? ?/sec     1.00     64.1±6.33µs        ? ?/sec
par_iter_simple/with_0_fragment                                                                        1.33     42.2±0.93µs        ? ?/sec     1.00     31.8±1.50µs        ? ?/sec
par_iter_simple/with_1000_fragment                                                                     1.27     51.5±2.61µs        ? ?/sec     1.00     40.7±1.89µs        ? ?/sec
par_iter_simple/with_100_fragment                                                                      1.31     43.9±1.44µs        ? ?/sec     1.00     33.5±1.51µs        ? ?/sec
par_iter_simple/with_10_fragment                                                                       1.35     43.7±1.75µs        ? ?/sec     1.00     32.3±1.50µs        ? ?/sec
run_condition/yes/100_systems                                                                          1.00     63.1±0.76µs        ? ?/sec     1.18     74.7±0.87µs        ? ?/sec
run_condition/yes_using_query/100_systems                                                              1.00     63.4±0.77µs        ? ?/sec     1.18     74.6±0.80µs        ? ?/sec
run_condition/yes_using_query/10_systems                                                               1.00      7.0±0.09µs        ? ?/sec     1.15      8.0±0.37µs        ? ?/sec
run_condition/yes_using_resource/1000_systems                                                          1.00   704.3±14.35µs        ? ?/sec     1.16   816.0±12.68µs        ? ?/sec
run_condition/yes_using_resource/100_systems                                                           1.00     63.6±1.04µs        ? ?/sec     1.18     74.7±0.67µs        ? ?/sec
world_entity/50000_entities                                                                            1.71    441.8±5.25µs        ? ?/sec     1.00    258.7±6.76µs        ? ?/sec
world_get/50000_entities_sparse                                                                        1.83    316.8±2.73µs        ? ?/sec     1.00    172.7±6.56µs        ? ?/sec
world_get/50000_entities_table                                                                         1.64    309.3±7.08µs        ? ?/sec     1.00    188.2±7.12µs        ? ?/sec

@atlv24 atlv24 added S-Ready-For-Final-Review This PR has been approved by the community. It's ready for a maintainer to consider merging it and removed S-Needs-Review Needs reviewer attention (from anyone!) to move forward M-Needs-Migration-Guide A breaking change to Bevy's public API that needs to be noted in a migration guide labels May 7, 2025
Co-authored-by: atlv <email@atlasdostal.com>
@alice-i-cecile alice-i-cecile added this pull request to the merge queue May 7, 2025
Merged via the queue into bevyengine:main with commit 0b48587 May 7, 2025
36 checks passed
@rparrett rparrett added M-Needs-Migration-Guide A breaking change to Bevy's public API that needs to be noted in a migration guide and removed M-Needs-Migration-Guide A breaking change to Bevy's public API that needs to be noted in a migration guide labels May 7, 2025
github-merge-queue bot pushed a commit that referenced this pull request May 8, 2025
# Objective

This is a followup to #18704 . There's lots more followup work, but this
is the minimum to unblock #18670, etc.

This direction has been given the green light by Alice
[here](#18704 (comment)).

## Solution

I could have split this over multiple PRs, but I figured skipping
straight here would be easiest for everyone and would unblock things the
quickest.

This removes the now no longer needed `identifier` module and makes
`Entity::generation` go from `NonZeroU32` to `struct
EntityGeneration(u32)`.

## Testing

CI

---------

Co-authored-by: Mark Nokalt <marknokalt@live.com>
@ElliottjPierce ElliottjPierce mentioned this pull request May 8, 2025
andrewzhurov pushed a commit to andrewzhurov/bevy that referenced this pull request May 17, 2025
# Objective

There are two problems this aims to solve. 

First, `Entity::index` is currently a `u32`. That means there are
`u32::MAX + 1` possible entities. Not only is that awkward, but it also
make `Entity` allocation more difficult. I discovered this while working
on remote entity reservation, but even on main, `Entities` doesn't
handle the `u32::MAX + 1` entity very well. It can not be batch reserved
because that iterator uses exclusive ranges, which has a maximum upper
bound of `u32::MAX - 1`. In other words, having `u32::MAX` as a valid
index can be thought of as a bug right now. We either need to make that
invalid (this PR), which makes Entity allocation cleaner and makes
remote reservation easier (because the length only needs to be u32
instead of u64, which, in atomics is a big deal), or we need to take
another pass at `Entities` to make it handle the `u32::MAX` index
properly.

Second, `TableRow`, `ArchetypeRow` and `EntityIndex` (a type alias for
u32) all have `u32` as the underlying type. That means using these as
the index type in a `SparseSet` uses 64 bits for the sparse list because
it stores `Option<IndexType>`. By using `NonMaxU32` here, we cut the
memory of that list in half. To my knowledge, `EntityIndex` is the only
thing that would really benefit from this niche. `TableRow` and
`ArchetypeRow` I think are not stored in an `Option` in bulk. But if
they ever are, this would help. Additionally this ensures
`TableRow::INVALID` and `ArchetypeRow::INVALID` never conflict with an
actual row, which in a nice bonus.

As a related note, if we do components as entities where `ComponentId`
becomes `Entity`, the the `SparseSet<ComponentId>` will see a similar
memory improvement too.

## Solution

Create a new type `EntityRow` that wraps `NonMaxU32`, similar to
`TableRow` and `ArchetypeRow`.
Change `Entity::index` to this type.

## Downsides

`NonMax` is implemented as a `NonZero` with a binary inversion. That
means accessing and storing the value takes one more instruction. I
don't think that's a big deal, but it's worth mentioning.

As a consequence, `to_bits` uses `transmute` to skip the inversion which
keeps it a nop. But that also means that ordering has now flipped. In
other words, higher indices are considered less than lower indices. I
don't think that's a problem, but it's also worth mentioning.

## Alternatives

We could keep the index as a u32 type and just document that `u32::MAX`
is invalid, modifying `Entities` to ensure it never gets handed out.
(But that's not enforced by the type system.) We could still take
advantage of the niche here in `ComponentSparseSet`. We'd just need some
unsafe manual conversions, which is probably fine, but opens up the
possibility for correctness problems later.

We could change `Entities` to fully support the `u32::MAX` index. (But
that makes `Entities` more complex and potentially slightly slower.)

## Testing

- CI
- A few tests were changed because they depend on different ordering and
`to_bits` values.

## Future Work

- It might be worth removing the niche on `Entity::generation` since
there is now a different niche.
- We could move `Entity::generation` into it's own type too for clarity.
- We should change `ComponentSparseSet` to take advantage of the new
niche. (This PR doesn't change that yet.)
- Consider removing or updating `Identifier`. This is only used for
`Entity`, so it might be worth combining since `Entity` is now more
unique.

---------

Co-authored-by: atlv <email@atlasdostal.com>
Co-authored-by: Zachary Harrold <zac@harrold.com.au>
andrewzhurov pushed a commit to andrewzhurov/bevy that referenced this pull request May 17, 2025
…9121)

# Objective

This is a followup to bevyengine#18704 . There's lots more followup work, but this
is the minimum to unblock bevyengine#18670, etc.

This direction has been given the green light by Alice
[here](bevyengine#18704 (comment)).

## Solution

I could have split this over multiple PRs, but I figured skipping
straight here would be easiest for everyone and would unblock things the
quickest.

This removes the now no longer needed `identifier` module and makes
`Entity::generation` go from `NonZeroU32` to `struct
EntityGeneration(u32)`.

## Testing

CI

---------

Co-authored-by: Mark Nokalt <marknokalt@live.com>
github-merge-queue bot pushed a commit that referenced this pull request May 26, 2025
# Objective

Since #18704 is done, we can track the length of unique entity row
collections with only a `u32` and identify an index within that
collection with only a `NonMaxU32`. This leaves an opportunity for
performance improvements.

## Solution

- Use `EntityRow` in sparse sets.
- Change table, entity, and query lengths to be `u32` instead of
`usize`.
- Keep `batching` module `usize` based since that is reused for events,
which may exceed `u32::MAX`.
- Change according `Range<usize>` to `Range<u32>`. This is more
efficient and helps justify safety.
- Change `ArchetypeRow` and `TableRow` to wrap `NonMaxU32` instead of
`u32`.

Justifying `NonMaxU32::new_unchecked` everywhere is predicated on this
safety comment in `Entities::set`: "`location` must be valid for the
entity at `index` or immediately made valid afterwards before handing
control to unknown code." This ensures no entity is in two table rows
for example. That fact is used to argue uniqueness of the entity rows in
each table, archetype, sparse set, query, etc. So if there's no
duplicates, and a maximum total entities of `u32::MAX` none of the
corresponding row ids / indexes can exceed `NonMaxU32`.

## Testing

CI

---------

Co-authored-by: Christian Hughes <9044780+ItsDoot@users.noreply.github.com>
Shatur added a commit to simgine/bevy_replicon that referenced this pull request Sep 28, 2025
For details see bevyengine/bevy#19121
bevyengine/bevy#18704

The niche is now in the index, which makes the compression logic even
simpler.

The index now represented by NonMaxU32, which internally represented as
NonZeroU32 with all bits reversed, so we have to xor the bits.

I opened a PR to make it more ergonomic and avoid us relying on the
internal layout: bevyengine/bevy#21246
github-merge-queue bot pushed a commit that referenced this pull request Sep 28, 2025
# Objective

Entity serialization is necessary for networking. Entities can exist
inside components and events. After deserialization, we simply map
remote entities to local entities.

To serialize entities efficiently, we split them into index and
generation, which benefits from varint serialization. #19121 and #18704
changed the entity layout, and I like the new layout a lot. We can now
use the extra bit from the index to store whether the generation is zero
or not, avoiding the need to serialize the generation entirely.

However, constructing new entities requires relying on the internal
layout, which is not very ergonomic. For example, here is how an entity
with index = 1 and generation = 1 can be created:

```rust
let expected_entity = Entity::from_bits((1 ^ u32::MAX) as u64 | (1 << 32));
```

## Solution

- Make `Entity::from_raw_and_generation` public. While at it, I also
removed outdated comment.
- Add `EntityRow::from_raw_u32` to make the initialization nicer.

## Testing

- It's a trivial change, but I re-used `EntityRow::from_raw_u32` in unit
tests to simplify them.

## Notes

I'd probably rename `Entity::from_raw_and_generation` into
`Entity::from_row_and_generation` or
`Entity::from_index_and_generation`.
mockersf pushed a commit that referenced this pull request Sep 28, 2025
# Objective

Entity serialization is necessary for networking. Entities can exist
inside components and events. After deserialization, we simply map
remote entities to local entities.

To serialize entities efficiently, we split them into index and
generation, which benefits from varint serialization. #19121 and #18704
changed the entity layout, and I like the new layout a lot. We can now
use the extra bit from the index to store whether the generation is zero
or not, avoiding the need to serialize the generation entirely.

However, constructing new entities requires relying on the internal
layout, which is not very ergonomic. For example, here is how an entity
with index = 1 and generation = 1 can be created:

```rust
let expected_entity = Entity::from_bits((1 ^ u32::MAX) as u64 | (1 << 32));
```

## Solution

- Make `Entity::from_raw_and_generation` public. While at it, I also
removed outdated comment.
- Add `EntityRow::from_raw_u32` to make the initialization nicer.

## Testing

- It's a trivial change, but I re-used `EntityRow::from_raw_u32` in unit
tests to simplify them.

## Notes

I'd probably rename `Entity::from_raw_and_generation` into
`Entity::from_row_and_generation` or
`Entity::from_index_and_generation`.
mockersf pushed a commit that referenced this pull request Sep 28, 2025
# Objective

Entity serialization is necessary for networking. Entities can exist
inside components and events. After deserialization, we simply map
remote entities to local entities.

To serialize entities efficiently, we split them into index and
generation, which benefits from varint serialization. #19121 and #18704
changed the entity layout, and I like the new layout a lot. We can now
use the extra bit from the index to store whether the generation is zero
or not, avoiding the need to serialize the generation entirely.

However, constructing new entities requires relying on the internal
layout, which is not very ergonomic. For example, here is how an entity
with index = 1 and generation = 1 can be created:

```rust
let expected_entity = Entity::from_bits((1 ^ u32::MAX) as u64 | (1 << 32));
```

## Solution

- Make `Entity::from_raw_and_generation` public. While at it, I also
removed outdated comment.
- Add `EntityRow::from_raw_u32` to make the initialization nicer.

## Testing

- It's a trivial change, but I re-used `EntityRow::from_raw_u32` in unit
tests to simplify them.

## Notes

I'd probably rename `Entity::from_raw_and_generation` into
`Entity::from_row_and_generation` or
`Entity::from_index_and_generation`.
Shatur added a commit to simgine/bevy_replicon that referenced this pull request Sep 30, 2025
For details see bevyengine/bevy#19121
bevyengine/bevy#18704

The niche is now in the index, which makes the compression logic even
simpler.

The index now represented by NonMaxU32, which internally represented as
NonZeroU32 with all bits reversed, so we have to xor the bits.

I opened a PR to make it more ergonomic and avoid us relying on the
internal layout: bevyengine/bevy#21246
Shatur added a commit to simgine/bevy_replicon that referenced this pull request Oct 1, 2025
For details see bevyengine/bevy#19121
bevyengine/bevy#18704

The niche is now in the index, which makes the compression logic even
simpler.
Shatur added a commit to simgine/bevy_replicon that referenced this pull request Oct 1, 2025
For details see bevyengine/bevy#19121
bevyengine/bevy#18704

The niche is now in the index, which makes the compression logic even
simpler.
Shatur added a commit to simgine/bevy_replicon that referenced this pull request Oct 2, 2025
For details see bevyengine/bevy#19121
bevyengine/bevy#18704

The niche is now in the index, which makes the compression logic even
simpler.
Shatur added a commit to simgine/bevy_replicon that referenced this pull request Oct 3, 2025
* Bump Bevy version
* Migrate to the new Entity layout
For details see bevyengine/bevy#19121
bevyengine/bevy#18704
The niche is now in the index, which makes the compression logic even
simpler.
* Migrate to the new `SystemParam` changes
For details see bevyengine/bevy#16885
bevyengine/bevy#19143
* Remove `*_trigger_targets`
* Simplify fns logic
We no longer need custom sed/de to additionally serialize targets. This
allows us to express things a bit nicer using conversion traits.
* Rename all "event" into "message".
* Rename all "trigger" into "event".
* Rename "resend locally" into just "send locally"
Fits better.
* Split channel methods

---------

Co-authored-by: UkoeHB <37489173+UkoeHB@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-ECS Entities, components, systems, and events C-Code-Quality A section of code that is hard to understand or change D-Complex Quite challenging from either a design or technical perspective. Ask for help! S-Ready-For-Final-Review This PR has been approved by the community. It's ready for a maintainer to consider merging it X-Contentious There are nontrivial implications that should be thought through
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants