Skip to content

feat(atlas): implement recompute_dirty with TargetId-based invalidation#19

Merged
Briany4717 merged 7 commits into
mainfrom
feat/atlas-recompute-dirty
May 19, 2026
Merged

feat(atlas): implement recompute_dirty with TargetId-based invalidation#19
Briany4717 merged 7 commits into
mainfrom
feat/atlas-recompute-dirty

Conversation

@Briany4717

@Briany4717 Briany4717 commented May 19, 2026

Copy link
Copy Markdown
Owner

What does this PR do?

Implements selective layout recomputation in the Atlas, wired to the
Evaluator's EvaluatorTick::collect_dirty() output via TargetIds and
the TargetKind discriminant. Closes the Evaluator → Atlas loop
described in RFC-0001 2.2 and 4.1.

New surface area:

  • frame::TargetKind#[repr(u16)] enum used to filter the broadcast
    TargetId stream by owning subsystem. Phase 1 defines only
    AtlasNode; future subsystems extend the enum.
  • LayoutAtlas::mark_dirty_all(&[TargetId]) — broadcast/event-bus API.
    Filters silently by kind and generation; foreign or stale entries
    are ignored.
  • LayoutAtlas::recompute_dirty(viewport) — incremental layout pass.
    Stays in Computed state.
  • LayoutAtlas::next_target_index() — exposes the index that the next
    added node will receive, so callers can build TargetIds before the
    node exists.
  • LayoutAtlas::current_generation() — current view generation,
    incremented by clear() (wraps via wrapping_add).
  • ContainerStyle::new(Option<f32>, Option<f32>) — public constructor,
    required because the struct is #[non_exhaustive].

Linked issue

Closes #16
Refs #1

Tasks

  • TargetKind::AtlasNode = 1 discriminant in frame.rs.
  • LayoutAtlas::mark_dirty_all(&[TargetId]) (batch broadcast,
    stronger than the per-node mark_dirty(node: AtlasNodeId)
    originally proposed in the issue).
  • LayoutAtlas::recompute_dirty(viewport).
  • Benchmark full vs incremental across small, medium, and large
    trees — and across flat vs deep shapes, since speedup depends on
    tree shape.

Acceptance criteria

  • A signal mutating one leaf produces exactly one TargetId
    one mark_dirty call.
    Verified by
    signal_mutation_propagates_to_atlas_via_target_id, the
    end-to-end test that exercises Signal → tick → mark_dirty_all →
    recompute_dirty.
  • Incremental recompute is at least 5× faster than full recompute
    on a 100-node tree.
    Verified in the deep-tree benchmarks (see
    Performance section). The flat-tree case does not hit 5× —
    this is a property of Flexbox, not a regression, and is
    documented below.
  • State-machine contract preserved. mark_dirty_all and
    recompute_dirty panic in Building; mutations remain forbidden
    in Computed.

Design decisions

Recorded here for review, all agreed before implementation:

  1. TargetKind is #[repr(u16)]. Guarantees the in-memory layout
    matches the TargetId bit packing, so TargetKind::Foo as u16 is
    a zero-cost cast usable in const fn contexts.

  2. TargetId.generation carries the Atlas view generation. Each
    LayoutAtlas keeps a current_generation: u16 that increments on
    clear() via wrapping_add. After u16::MAX clears the value
    wraps; the collision probability with a stale TargetId surviving
    that long is statistically negligible. Documented on clear().

  3. Broadcast/event-bus dispatch. mark_dirty_all accepts the full
    Vec<TargetId> produced by EvaluatorTick::collect_dirty().
    Foreign kinds and stale generations are silently filtered. The Logic
    thread can fan-out the same slice to multiple subsystems without
    coordination.

  4. Vec<AtlasNodeId> for index → node mapping. Cache-friendly,
    O(1) lookup, indexed by nodes_by_index.len() at insertion time.

  5. recompute_dirty returns Result<(), AtlasError>. No partial
    geometry diff. Callers re-run populate_frame after, which is O(N)
    over contiguous memory and measured in microseconds.

Performance

Benchmarks on release build, all measuring layout cost after marking
one real leaf dirty (not the root — that would be cache-reuse, not
invalidation).

Acceptance criterion: ~100-node tree

Shape Nodes Full Incremental Speedup
100 leaves, depth=2 111 45.8 µs 7.8 µs 5.9×

Meets the issue's "at least 5×" target.

Real-world UI hierarchies

Shape Nodes Full Incremental Speedup
depth=3 branch=5 (small panel, 125 leaves) 156 112 µs 22.5 µs 5.0×
depth=4 branch=5 (medium app, 625 leaves) 781 616 µs 115 µs 5.4×
depth=5 branch=5 (IDE, 3125 leaves) 3906 3114 µs 568 µs 5.5×

Speedup plateaus around 5–6× because the chain of ancestors above a
dirty leaf must re-evaluate its flex pass; siblings of the dirty leaf
(and of every ancestor) keep their caches and are skipped.

In absolute terms, the IDE-scale 3906-node tree recomputes in 568 µs
3.4% of the 16.7 ms budget at 60 FPS. Chromium spends 8–15% of the
frame on layout for SPA pages of comparable complexity.

Flat trees (worst case)

Leaves Full Incremental Speedup
10 3.4 µs 1.8 µs 1.9×
100 14.8 µs 11.1 µs 1.3×
1000 149 µs 116 µs 1.3×

Flat trees produce modest speedup because any leaf change forces the
single root's Flexbox pass to re-evaluate the full child distribution.
This is fundamental to Flexbox, not a Taffy limitation. Real UIs are
not flat — these numbers represent a pathological case that does not
occur in production (VS Code averages depth 12–18, Figma depth 8–15).

Overflow risks documented

  • next_target_index() truncates usize to u32 if len() > u32::MAX
    (≈ 4.3 billion nodes; ~400 GB of tree storage required). Caught in
    debug builds by a debug_assert!; documented on the method.
  • current_generation wraps at u16::MAX; documented on clear().
  • Recursive bench helper has stack depth proportional to tree depth.
    Affects benchmarks only at depths beyond ~8000; not hit by Phase 1
    benchmarks.

Checklist

  • cargo fmt --all passes
  • cargo clippy --workspace --all-targets -- -D warnings passes
  • cargo test --workspace passes (71 unit tests, +11 in this PR)
  • New public items have doc comments
  • If this changes behavior visible to users, CHANGELOG.md has an entry under [Unreleased]
  • If this changes the architecture, an RFC is linked or opened alongside this PR

Notes for reviewers

The mark_dirty_all filter is a single integer comparison per target
(no hashing, no allocation). Targets are processed in slice order and
any kind/generation mismatch short-circuits the iteration body.

recompute_dirty delegates to Taffy's internal cache. Taffy invalidates
upward from a dirty node until it hits a container whose dimensions did
not change; this is why deep trees see large speedups (the invalidation
stops mid-tree) while flat trees see modest ones (the invalidation
always reaches the root).

End-to-end coverage is provided by
signal_mutation_propagates_to_atlas_via_target_id, which exercises the
full Evaluator → Atlas chain in a single test.

CHANGELOG checkbox N/A — pre-alpha; changelog will be introduced at v0.1.0.

Copilot AI review requested due to automatic review settings May 19, 2026 17:36

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR adds selective (incremental) layout recomputation to the Atlas by consuming the Evaluator’s broadcast dirty-target stream (Vec<TargetId>) and filtering it via a new TargetKind discriminant plus an Atlas view-generation counter.

Changes:

  • Introduces frame::TargetKind (#[repr(u16)]) and uses it with TargetId to support subsystem-scoped dirty-target broadcasts.
  • Extends LayoutAtlas with TargetId-based invalidation (mark_dirty_all) and incremental recomputation (recompute_dirty), plus generation/index helpers.
  • Adds Atlas-focused benchmarks and wiring in Cargo.toml.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
crates/byard-core/src/frame.rs Adds TargetKind enum and a round-trip test for TargetId kind packing.
crates/byard-core/src/evaluator/signal.rs Updates module docs to reference ViewArena more directly.
crates/byard-core/src/atlas/mod.rs Expands Atlas docs to describe the new dirty-target flow and lifecycle.
crates/byard-core/src/atlas/layout.rs Implements generation-aware TargetId invalidation (mark_dirty_all) and incremental layout (recompute_dirty), plus helpers and tests.
crates/byard-core/Cargo.toml Registers the new atlas benchmark target.
crates/byard-core/benches/atlas.rs Adds benchmark program comparing full compute vs incremental recompute across tree shapes/sizes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread crates/byard-core/benches/atlas.rs Outdated
Comment thread crates/byard-core/benches/atlas.rs Outdated
Comment thread crates/byard-core/src/atlas/mod.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Comment thread crates/byard-core/benches/atlas.rs Outdated
Comment thread crates/byard-core/benches/atlas.rs Outdated
@Briany4717 Briany4717 merged commit cacaee3 into main May 19, 2026
3 checks passed
@Briany4717 Briany4717 deleted the feat/atlas-recompute-dirty branch May 19, 2026 18:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(atlas): incremental recompute_dirty consuming EvaluatorTick output

2 participants