Skip to content

Draft: Archetype invariant#23658

Draft
SoyokazeRoom201 wants to merge 6 commits intobevyengine:mainfrom
SoyokazeRoom201:archetype-invariant
Draft

Draft: Archetype invariant#23658
SoyokazeRoom201 wants to merge 6 commits intobevyengine:mainfrom
SoyokazeRoom201:archetype-invariant

Conversation

@SoyokazeRoom201
Copy link
Copy Markdown

@SoyokazeRoom201 SoyokazeRoom201 commented Apr 4, 2026

Objective

Testing

  • Unit tests in constraint.rs
  • An example in root component_constraints.rs

Benches

ecs::components::component_constraints::spawn_no_constraint/static
                        time:   [46.431 µs 46.789 µs 47.186 µs]
                        change: [−0.6489% +0.2602% +1.2847%] (p = 0.58 > 0.05)
                        No change in performance detected.
Found 11 outliers among 100 measurements (11.00%)
  7 (7.00%) high mild
  4 (4.00%) high severe

ecs::components::component_constraints::spawn_with_simple_constraint/static
                        time:   [45.218 µs 45.430 µs 45.662 µs]
                        change: [−3.8112% −2.8830% −2.0931%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 2 outliers among 100 measurements (2.00%)
  2 (2.00%) high mild

ecs::components::component_constraints::spawn_with_complex_constraint/static
                        time:   [49.908 µs 50.004 µs 50.114 µs]
                        change: [−3.1074% −2.2942% −1.6381%] (p = 0.00 < 0.05)
                        Performance has improved.

ecs::components::component_constraints::spawn_chain_10_constraint/static
                        time:   [80.067 µs 80.195 µs 80.339 µs]
                        change: [−6.9384% −6.0254% −5.1765%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 4 outliers among 100 measurements (4.00%)
  2 (2.00%) high mild
  2 (2.00%) high severe

ecs::components::component_constraints::spawn_chain_10_no_constraint/static
                        time:   [84.889 µs 85.395 µs 86.068 µs]
                        change: [−1.3570% +0.1209% +1.8026%] (p = 0.88 > 0.05)
                        No change in performance detected.
Found 8 outliers among 100 measurements (8.00%)
  8 (8.00%) high severe

Notes

  • TODOs provides possible directions for solving these two problems:

c) integration with query overlap detection
d) integration with scheduling and ambiguity checking

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 4, 2026

Welcome, new contributor!

Please make sure you've read our contributing guide, as well as our policy regarding AI usage, and we look forward to reviewing your pull request shortly ✨

@SoyokazeRoom201 SoyokazeRoom201 marked this pull request as draft April 4, 2026 04:30
@alice-i-cecile alice-i-cecile added A-ECS Entities, components, systems, and events X-Needs-SME This type of work requires an SME to approve it. S-Needs-Goal This should have a C-Goal and should not continue until it has one labels Apr 4, 2026
@github-project-automation github-project-automation bot moved this to Needs SME Triage in ECS Apr 4, 2026
@alice-i-cecile alice-i-cecile added the D-Complex Quite challenging from either a design or technical perspective. Ask for help! label Apr 4, 2026
@Freyja-moth
Copy link
Copy Markdown
Contributor

Looks good so far. However there are a some of edgecases that aren't documented from what I can tell.

Recursion

What happens to recursive only's, e.g:

#[derive(Component)]
#[constraint(only(Armour))]
pub struct Knight;

#[derive(Component)]
#[constraint(only(Cost))]
pub struct Armour;

#[derive(Component)]
pub struct Cost;

Can knight only exist with Armour? or only with Armour and Cost?

Enforcement

What happens when you attempt to break an archetype invariant? Does the order matter?

For example, if you had

#[derive(Component)]
#[constraint(forbid(Dead))]
pub struct Alive;

#[derive(Component)]
#[constraint(forbid(Alive))]
pub struct Dead;

And tried to insert Dead into an entity that already has Alive, which one would survive?

I think both have their uses, it's probably a good idea to allow users specify the behaviour.

/// Inserting `Alive` into an entity with `Dead` will "bounce" and `Dead` will remain.
/// Unfortunately death is quite permanent.
#[derive(Component)]
#[constraint(forbid(Dead))]
pub struct Alive;

/// Inserting `Dead` into an entity with `Alive` will overwrite it and `Dead` will remain.
#[derive(Component)]
#[constraint(forbid(Alive, overwrite))]
pub struct Dead;

A similar system could also be extended to require, where users can specify if removing the component removes all of it's required components, or if it can only be removed once all required components are first removed.

@SoyokazeRoom201
Copy link
Copy Markdown
Author

SoyokazeRoom201 commented Apr 4, 2026

Looks good so far. However there are a some of edgecases that aren't documented from what I can tell.

Recursion

What happens to recursive only's, e.g:

#[derive(Component)]
#[constraint(only(Armour))]
pub struct Knight;

#[derive(Component)]
#[constraint(only(Cost))]
pub struct Armour;

#[derive(Component)]
pub struct Cost;

Can knight only exist with Armour? or only with Armour and Cost?

Enforcement

What happens when you attempt to break an archetype invariant? Does the order matter?

For example, if you had

#[derive(Component)]
#[constraint(forbid(Dead))]
pub struct Alive;

#[derive(Component)]
#[constraint(forbid(Alive))]
pub struct Dead;

And tried to insert Dead into an entity that already has Alive, which one would survive?

I think both have their uses, it's probably a good idea to allow users specify the behaviour.

/// Inserting `Alive` into an entity with `Dead` will "bounce" and `Dead` will remain.
/// Unfortunately death is quite permanent.
#[derive(Component)]
#[constraint(forbid(Dead))]
pub struct Alive;

/// Inserting `Dead` into an entity with `Alive` will overwrite it and `Dead` will remain.
#[derive(Component)]
#[constraint(forbid(Alive, overwrite))]
pub struct Dead;

A similar system could also be extended to require, where users can specify if removing the component removes all of it's required components, or if it can only be removed once all required components are first removed.

  1. "only" is a universal quantifier, which is a syntax sugar that distinguishes itself from the four original primitives. Its meaning is that "this archetype can only have these Components",

i.e. [constraint(and(forbid(AllOtherComponents))] (I didn't clearly describe the behavior in the design document, add it now). It is consistent with the meaning of the "whitelist".

spawn((Armour, Knight)); // Err, because it is seperate checking, `Armour` does not have `Cost`
spawn((Armour, Cost));    // Ok
spawn((Armour, Cost, Knight)) // Err
  1. Under the current circumstances, the "order" is indeed relied. The chained calls may cause confusion for users because each time it is actually a separate operation without any state transition or "transactional".
spawn((..., Alive)); // Ok
spawn((..., Dead)); // Ok
entity_mut(e).remove(Alive).insert(Dead); // If this entity contains `Alive`, also Ok.
entity_mut(e).insert(Dead).remove(Alive); // If this entity contains `Alive`, it will Err because temporary Archetype is reject to create. And it will also remove `Alive` if validation pass...

Ohh, the current circumstance seems to lack transactional functionality.

But, this problem can be easily solved without using transaction, using XOR.

#[drive(Component)]
#[constraint(or(and(require(Alive), forbid(Dead)), and(require(Dead), forbid(Alive))))] // i.e #[constraint(xor(Alive, Dead))]
struct State;
  1. I think Verification and Hooks(Operations) must be orthogonal to each other. This is an experience that has been summarized by numerous query languages. Think of this example:
#[constraint(forbid(B, overwrite))]
struct A;

#[constraint(forbid(A, overwrite))]
struct B;

What if command.spawn((A, B));, is it the same the output between command.spawn((B, A));? Which one survive? Can we rely on the Bundle order?

I think it is better to use Observer to make it more explicit:

world.register_constraint_check::<A>(|err: On<ComponentConstraintError>| {});

Or a new hook?

#[derive(Component)]
#[component(on_constraint_check = ...)]

That is to say, why does the existing #[require(B)] (not a constraint) allow users to delete it later. If it conveys the meaning of "constraint", will lead to unexpected behavior. It is a side effect, not a really "constraint/invariant".

This is some of my thought!

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 D-Complex Quite challenging from either a design or technical perspective. Ask for help! S-Needs-Goal This should have a C-Goal and should not continue until it has one X-Needs-SME This type of work requires an SME to approve it.

Projects

Status: Needs SME Triage

Development

Successfully merging this pull request may close these issues.

3 participants