Skip to main content

bevy_brink/
ground_truth.rs

1//! Host-side ground-truth check (issue #938, tracked from #897) — the
2//! `brink_runtime::effect_trace` pattern applied to the bevy boundary.
3//!
4//! `brink_runtime::effect_trace` closes the compiler's #870 gap: a purely
5//! structural row check can't catch a static effects row that under-reports
6//! what the bytecode *actually* does, because both the caller's and the
7//! callee's rows can silently agree on the wrong (too-small) answer. The
8//! independent fix is to run the real bytecode and record what it actually
9//! touches, then assert the static row covers it.
10//!
11//! The bevy host boundary has the exact same shape, one layer up: BH-1
12//! (`crate::capability`) computes a *declared* per-story [`Access`] by
13//! joining the compiler's static effect rows against a **host-authored**
14//! capability manifest (`crate::capability::CapabilityManifest`). That join
15//! is only as good as the manifest — if a `bind_brink_query` binding's real
16//! Bevy system touches a component its manifest entry never lists in
17//! `effects.reads`/`effects.writes`, BH-1's `Access` silently under-reports,
18//! and BH-3's parallel Step phase's disjointness argument
19//! (`crate::batch::parallel`, decision-log 2026-07-16) is built on exactly
20//! that `Access`. A purely-manifest-side check can't catch this (both the
21//! manifest and the join can silently agree on the wrong answer); this
22//! module is the independent, run-the-real-binding-and-look check that
23//! closes that gap, mirroring `brink_runtime::effect_trace`'s "ground truth"
24//! role for the compiler side.
25//!
26//! Feature-gated exactly like `effect-trace` on `brink-runtime`
27//! (`crates/brink-runtime/src/effect_trace.rs`): this module and every call
28//! site are compiled out entirely unless `bevy-brink`'s own `effect-trace`
29//! feature is enabled (off by default — not a released consumer's concern),
30//! so an ordinary build pays exactly zero cost.
31//!
32//! ## What "actual component access" means here
33//!
34//! Bevy's own query/system access is *static*: a `Query<&Transform>`
35//! declares exactly the same [`Access`] whether or not it ever iterates a
36//! matching entity. So the ground truth doesn't need to be captured mid-run
37//! by instrumenting opcodes (unlike the compiler's `effect_trace`, which
38//! really does vary by which branch bytecode takes) — it can be captured
39//! once, precisely, the moment [`crate::BrinkBindingsAppExt::bind_brink_query`]
40//! registers the binding's system (`bindings.rs`'s `bind_brink_query`, via
41//! [`System::initialize`](bevy_ecs::system::System::initialize)), which is
42//! bevy's own ground truth for what that system can touch. "Instrumenting
43//! the Step phase" then means: every time a query binding is **actually
44//! dispatched** (`bindings.rs`'s `dispatch_one_external`, the one safe
45//! access layer a real `bind_brink_query` invocation flows through — both
46//! for the serial API and for a batch turn's parked-external resolution),
47//! [`record`] logs that dispatch's (flow, binding, story, captured access)
48//! tuple; [`check`] then asserts every logged access is a subset of BH-1's
49//! declared row-join for that story.
50//!
51//! No `unsafe` — this wraps the existing safe `world.run_system_with` call
52//! site; the sanctioned-unsafe module (`crate::batch::parallel`) is
53//! untouched and does not grow.
54
55use std::marker::PhantomData;
56
57use bevy_asset::AssetId;
58use bevy_ecs::component::Components;
59use bevy_ecs::entity::Entity;
60use bevy_ecs::query::{Access, ComponentAccessKind};
61use bevy_ecs::resource::Resource;
62use bevy_ecs::world::World;
63
64use crate::asset::{BrinkProgram, ProgramAsset};
65use crate::batch::aggregate_access;
66use crate::capability::CapabilityTable;
67
68/// One real `bind_brink_query` dispatch, observed at the exact point bevy
69/// actually ran it. The runtime counterpart of
70/// `brink_runtime::effect_trace::ObservedRow` — this module never
71/// constructs an opaque/approximate access; every entry is the concrete
72/// [`Access`] bevy's own `System::initialize` reported for the bound
73/// system.
74#[derive(Debug, Clone)]
75pub struct ObservedAccess {
76    /// The flow entity whose external call dispatched this binding.
77    pub flow: Entity,
78    /// The flow's story — [`CapabilityTable::access_for`] looks up its BH-1
79    /// declared access under this key.
80    pub story: AssetId<ProgramAsset>,
81    /// The `bind_brink_query` binding name that was dispatched.
82    pub binding: String,
83    /// The binding's real, bevy-declared [`Access`] (captured once at
84    /// registration time — see the module docs' "what actual component
85    /// access means here").
86    pub access: Access,
87}
88
89/// Log of every query-binding dispatch observed so far under marker `M`.
90/// Populated by [`record`] (called from `bindings.rs`'s
91/// `dispatch_one_external`); drained/inspected by [`check`]. A `Resource`
92/// like `CapabilityTable<M>`, so a host/test/scenario-harness driving
93/// several batch turns accumulates one log across all of them — call
94/// [`GroundTruthLog::reset`] between comparisons that should be independent
95/// (exactly like `brink_runtime::effect_trace::reset`).
96#[derive(Resource)]
97pub struct GroundTruthLog<M: Send + Sync + 'static = ()> {
98    entries: Vec<ObservedAccess>,
99    _marker: PhantomData<fn() -> M>,
100}
101
102impl<M: Send + Sync + 'static> Default for GroundTruthLog<M> {
103    fn default() -> Self {
104        Self {
105            entries: Vec::new(),
106            _marker: PhantomData,
107        }
108    }
109}
110
111impl<M: Send + Sync + 'static> GroundTruthLog<M> {
112    /// Every dispatch recorded since the last [`reset`](Self::reset).
113    #[must_use]
114    pub fn entries(&self) -> &[ObservedAccess] {
115        &self.entries
116    }
117
118    /// Clear the log. Call before a run whose observed accesses should be
119    /// compared independently of any prior run in the same `World`.
120    pub fn reset(&mut self) {
121        self.entries.clear();
122    }
123}
124
125/// Record one real query-binding dispatch. Called from
126/// `bindings.rs`'s `dispatch_one_external` right after
127/// `world.run_system_with` succeeds, with the binding's registration-time
128/// [`Access`] (captured by `bind_brink_query`). A no-op if the flow entity
129/// no longer carries a [`BrinkProgram<M>`] (it despawned between dispatch
130/// decision and this call) — there is no story to attribute the access to,
131/// and this instrumentation must never turn a benign race into a hard
132/// error, mirroring `brink_runtime`'s own "a silent miss skips recording"
133/// rule for its `note_effect_*` helpers.
134pub(crate) fn record<M: Send + Sync + 'static>(
135    world: &mut World,
136    flow: Entity,
137    binding: &str,
138    access: Access,
139) {
140    let Some(story) = world.get::<BrinkProgram<M>>(flow).map(|p| p.handle.id()) else {
141        return;
142    };
143    world
144        .get_resource_or_insert_with(GroundTruthLog::<M>::default)
145        .entries
146        .push(ObservedAccess {
147            flow,
148            story,
149            binding: binding.to_string(),
150            access,
151        });
152}
153
154/// Whether a violating access was a read or a write — named in
155/// [`Violation`] so a report can say exactly which.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum AccessKind {
158    Read,
159    Write,
160}
161
162impl std::fmt::Display for AccessKind {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        f.write_str(match self {
165            Self::Read => "read",
166            Self::Write => "write",
167        })
168    }
169}
170
171/// One under-report: a real `bind_brink_query` dispatch touched a component
172/// its story's capability manifest never declares — the exact class this
173/// issue guards (names the flow, the component, and the binding).
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct Violation {
176    pub flow: Entity,
177    pub story: AssetId<ProgramAsset>,
178    pub binding: String,
179    /// Human-readable component name (`Components::get_name`), or a
180    /// debug-formatted `ComponentId` if bevy has no name for it. `"<all
181    /// components>"` for the rare case of an unbounded observed access
182    /// (e.g. a binding taking `&World`/`EntityRef`), which can't be named
183    /// component-by-component.
184    pub component: String,
185    pub kind: AccessKind,
186}
187
188impl std::fmt::Display for Violation {
189    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190        write!(
191            f,
192            "flow {:?} binding `{}` {}s component `{}`, which story {:?}'s capability manifest never declares",
193            self.flow, self.binding, self.kind, self.component, self.story
194        )
195    }
196}
197
198/// The ground-truth check itself: for every dispatch [`record`]ed into
199/// `log`, assert its real bevy [`Access`] is a subset of the story's BH-1
200/// row-join access (`CapabilityTable::access_for`, aggregated across
201/// containers via [`aggregate_access`] — the same aggregate BH-2/BH-3
202/// already consume for their own bookkeeping, since v1 has no per-container
203/// narrowing on the host side either, `docs/effects-spec.md` §7). A story
204/// with no capability table loaded at all (no manifest/registry wired)
205/// joins to an empty `Access` — any real component touch is then correctly
206/// a violation, since nothing was declared.
207///
208/// Never panics — returns every violation found, named by
209/// flow/component/binding, for the caller (a test or scenario harness) to
210/// assert against (e.g. `assert!(violations.is_empty())`).
211#[must_use]
212pub fn check<M: Send + Sync + 'static>(
213    log: &GroundTruthLog<M>,
214    cap_table: &CapabilityTable<M>,
215    components: &Components,
216) -> Vec<Violation> {
217    let mut violations = Vec::new();
218    for entry in &log.entries {
219        let declared = cap_table
220            .access_for(entry.story)
221            .map(aggregate_access)
222            .unwrap_or_default();
223        if entry.access.is_subset(&declared) {
224            continue;
225        }
226        let Ok(iter) = entry.access.try_iter_access() else {
227            violations.push(Violation {
228                flow: entry.flow,
229                story: entry.story,
230                binding: entry.binding.clone(),
231                component: "<all components>".to_string(),
232                kind: AccessKind::Read,
233            });
234            continue;
235        };
236        for kind in iter {
237            let (id, access_kind) = match kind {
238                ComponentAccessKind::Exclusive(id) => (id, AccessKind::Write),
239                ComponentAccessKind::Shared(id) => (id, AccessKind::Read),
240                // A `Has<T>`-style archetypal check never touches the
241                // component's value, so it can never be the ECS-value
242                // under-report this check guards against.
243                ComponentAccessKind::Archetypal(_) => continue,
244            };
245            let covered = match access_kind {
246                AccessKind::Write => declared.has_write(id),
247                AccessKind::Read => declared.has_read(id),
248            };
249            if covered {
250                continue;
251            }
252            let component = components
253                .get_name(id)
254                .map_or_else(|| format!("{id:?}"), |n| n.to_string());
255            violations.push(Violation {
256                flow: entry.flow,
257                story: entry.story,
258                binding: entry.binding.clone(),
259                component,
260                kind: access_kind,
261            });
262        }
263    }
264    violations
265}
266
267#[cfg(test)]
268mod tests {
269    use bevy_ecs::component::Component;
270    use bevy_ecs::world::World;
271
272    use super::*;
273
274    #[derive(Component)]
275    struct Transform;
276
277    #[derive(Component)]
278    struct AudioSink;
279
280    fn story_id() -> AssetId<ProgramAsset> {
281        AssetId::<ProgramAsset>::invalid()
282    }
283
284    #[test]
285    fn subset_access_produces_no_violations() {
286        let mut world = World::new();
287        let transform_id = world.register_component::<Transform>();
288        let flow = world.spawn_empty().id();
289
290        let mut log = GroundTruthLog::<()>::default();
291        let mut observed = Access::default();
292        observed.add_read(transform_id);
293        log.entries.push(ObservedAccess {
294            flow,
295            story: story_id(),
296            binding: "get_position".to_string(),
297            access: observed,
298        });
299
300        let mut declared = Access::default();
301        declared.add_read(transform_id);
302        let mut table = crate::capability::ContainerAccessTable::default();
303        table.insert(
304            brink_format::DefinitionId::new(brink_format::DefinitionTag::Address, 0),
305            crate::capability::ContainerAccess {
306                access: declared,
307                ..Default::default()
308            },
309        );
310        let mut cap_table = CapabilityTable::<()>::default();
311        cap_table.insert_for_test(story_id(), Ok(table));
312
313        let violations = check(&log, &cap_table, world.components());
314        assert!(violations.is_empty(), "{violations:?}");
315    }
316
317    #[test]
318    fn write_beyond_declared_read_is_a_named_violation() {
319        let mut world = World::new();
320        let transform_id = world.register_component::<Transform>();
321        let audio_id = world.register_component::<AudioSink>();
322        let flow = world.spawn_empty().id();
323
324        let mut log = GroundTruthLog::<()>::default();
325        let mut observed = Access::default();
326        observed.add_read(transform_id);
327        observed.add_write(audio_id);
328        log.entries.push(ObservedAccess {
329            flow,
330            story: story_id(),
331            binding: "play_and_reposition".to_string(),
332            access: observed,
333        });
334
335        // Manifest declares only the Transform read — AudioSink write is an
336        // under-report.
337        let mut declared = Access::default();
338        declared.add_read(transform_id);
339        let mut table = crate::capability::ContainerAccessTable::default();
340        table.insert(
341            brink_format::DefinitionId::new(brink_format::DefinitionTag::Address, 0),
342            crate::capability::ContainerAccess {
343                access: declared,
344                ..Default::default()
345            },
346        );
347        let mut cap_table = CapabilityTable::<()>::default();
348        cap_table.insert_for_test(story_id(), Ok(table));
349
350        let violations = check(&log, &cap_table, world.components());
351        assert_eq!(violations.len(), 1, "{violations:?}");
352        let v = &violations[0];
353        assert_eq!(v.flow, flow);
354        assert_eq!(v.binding, "play_and_reposition");
355        assert_eq!(v.kind, AccessKind::Write);
356        assert!(
357            v.component.contains("AudioSink"),
358            "violation should name the offending component: {v:?}"
359        );
360    }
361
362    #[test]
363    fn no_capability_table_at_all_flags_any_real_access() {
364        let mut world = World::new();
365        let transform_id = world.register_component::<Transform>();
366        let flow = world.spawn_empty().id();
367
368        let mut log = GroundTruthLog::<()>::default();
369        let mut observed = Access::default();
370        observed.add_read(transform_id);
371        log.entries.push(ObservedAccess {
372            flow,
373            story: story_id(),
374            binding: "get_position".to_string(),
375            access: observed,
376        });
377
378        let cap_table = CapabilityTable::<()>::default();
379        let violations = check(&log, &cap_table, world.components());
380        assert_eq!(
381            violations.len(),
382            1,
383            "no manifest wired at all means nothing is declared — any real access is a violation: {violations:?}"
384        );
385    }
386}
387
388/// End-to-end scenario coverage (this issue's own gate): drives real
389/// `bind_brink_query` bindings through the actual dispatch call site
390/// (`bindings.rs`'s `dispatch_one_external`, reached here via the plugin's
391/// `resolve_pending_externals` servicing a batch turn's parked query — the
392/// same path a batch-mode host's flows go through today), across several
393/// flow counts, and asserts [`check`] behaves correctly against both a
394/// correctly-declared and a deliberately under-declared manifest. This is
395/// the "wire into the scenario harness so randomized workloads exercise the
396/// assertion" deliverable: a small, real (not mocked) workload axis
397/// (flow count) exercising the whole registration → dispatch → check
398/// pipeline, rather than a hand-constructed `ObservedAccess`/`Access` like
399/// the unit tests above. Full integration into `benches/scenario/model.rs`'s
400/// BH-B axes matrix (a dedicated access-disjointness axis) is future BH-B
401/// work per the epic's own scope note (#897) — flagged, not attempted here.
402#[cfg(test)]
403mod scenario {
404    use bevy_app::Update;
405    use bevy_asset::Assets;
406    use bevy_ecs::component::Component;
407    use bevy_ecs::entity::Entity;
408    use bevy_ecs::system::{In, Query};
409    use brink_format::Value;
410    use std::collections::BTreeMap;
411
412    use super::*;
413    use crate::asset::{BrinkStoryAsset, LineTablesAsset};
414    use crate::capability::{CapabilityEffects, CapabilityManifest, CapabilityManifestExternal};
415    use crate::{
416        BrinkBindingsAppExt, BrinkCapabilityAppExt, BrinkFlowRequest, BrinkQueryInput,
417        advance_batch,
418    };
419
420    #[derive(Component)]
421    struct Enemy;
422
423    fn enemy_count(In((_entity, _args)): In<BrinkQueryInput>, q: Query<&Enemy>) -> Value {
424        #[expect(
425            clippy::cast_possible_truncation,
426            clippy::cast_possible_wrap,
427            reason = "test story, tiny enemy count"
428        )]
429        Value::Int(q.iter().count() as i32)
430    }
431
432    const STORY_SOURCE: &str =
433        "EXTERNAL enemy_count()\n-> start\n=== start ===\nEnemies near: {enemy_count()}.\n-> END\n";
434
435    /// Compile `STORY_SOURCE` for real (through the full `.inkb` round trip,
436    /// not the `add_story_assets` test helper, which zeroes `effect_rows` —
437    /// BH-1's join needs real ones) and spawn `flow_count` flows against one
438    /// shared story, driving `advance_batch` until every flow reaches its
439    /// terminal `Done` line. Returns the app so the caller can inspect its
440    /// `GroundTruthLog`/`CapabilityTable`.
441    fn drive_scenario(flow_count: usize, manifest: CapabilityManifest) -> bevy_app::App {
442        let mut app = crate::test_support::make_test_app();
443        app.bind_brink_query::<(), _, _>("enemy_count", enemy_count);
444        app.register_capability::<(), Enemy>("Enemy");
445        app.insert_resource(manifest);
446        app.add_systems(Update, advance_batch::<()>);
447        app.world_mut().spawn(Enemy);
448
449        let out = brink_compiler::compile("t.ink", move |p| {
450            if p == "t.ink" {
451                Ok(STORY_SOURCE.to_string())
452            } else {
453                Err(std::io::Error::new(std::io::ErrorKind::NotFound, "x"))
454            }
455        })
456        .expect("scenario story should compile");
457        let mut inkb = Vec::new();
458        brink_format::write_inkb(&out.data, &mut inkb);
459        let loaded = brink_format::read_inkb(&inkb).expect("read_inkb");
460        let (program, tables) = brink_runtime::link(&loaded).expect("link");
461        let (_, initial_context) = brink_runtime::FlowInstance::new_at_root(&program);
462
463        let world = app.world_mut();
464        let program_handle = world
465            .resource_mut::<Assets<ProgramAsset>>()
466            .add(ProgramAsset {
467                program,
468                initial_context,
469                effect_rows: loaded.effect_rows,
470            });
471        let tables_handle = world
472            .resource_mut::<Assets<LineTablesAsset>>()
473            .add(LineTablesAsset { tables });
474        let story_handle = world
475            .resource_mut::<Assets<BrinkStoryAsset>>()
476            .add(BrinkStoryAsset {
477                program: program_handle,
478                line_tables: tables_handle,
479            });
480
481        let flows: Vec<Entity> = (0..flow_count)
482            .map(|_| {
483                app.world_mut()
484                    .spawn(
485                        BrinkFlowRequest::<()>::builder()
486                            .story(story_handle.clone())
487                            .build(),
488                    )
489                    .id()
490            })
491            .collect();
492
493        // Generous, fixed tick budget: fulfillment + the capability join's
494        // one-tick-late asset-event flush + several batch turns' worth of
495        // Collect/park/resolve/continue round trips. Every flow's story is
496        // two turns deep (one call, one terminal line), so this comfortably
497        // converges regardless of how bevy orders `advance_batch` relative
498        // to `resolve_pending_externals` within a tick.
499        for _ in 0..12 {
500            app.update();
501        }
502
503        for flow in flows {
504            // "Reached its terminal line" ⟺ no longer parked on a pending
505            // external — the story is exactly two turns deep (one call, one
506            // `-> END`), so once unparked it has nothing left to await.
507            let unparked = app
508                .world()
509                .get::<crate::BrinkFlow<()>>(flow)
510                .is_some_and(|f| !f.inner.has_pending_external());
511            assert!(
512                unparked,
513                "flow {flow:?} should have resolved its pending external within the tick budget"
514            );
515        }
516
517        app
518    }
519
520    fn declaring_manifest() -> CapabilityManifest {
521        CapabilityManifest {
522            externals: vec![CapabilityManifestExternal {
523                name: "enemy_count".to_string(),
524                effects: CapabilityEffects {
525                    reads: vec!["Enemy".to_string()],
526                    writes: vec![],
527                    detect: BTreeMap::new(),
528                },
529            }],
530        }
531    }
532
533    fn under_declaring_manifest() -> CapabilityManifest {
534        // An entry for `enemy_count` exists (so the row join doesn't treat it
535        // as "no manifest entry at all, contributes nothing" for an unrelated
536        // reason) but its `effects` are empty — the exact under-report class
537        // this issue guards: the binding really reads `Enemy`, but nothing
538        // declares it.
539        CapabilityManifest {
540            externals: vec![CapabilityManifestExternal {
541                name: "enemy_count".to_string(),
542                effects: CapabilityEffects::default(),
543            }],
544        }
545    }
546
547    /// Randomized-workload axis (flow count, 1/3/7): with a manifest that
548    /// correctly declares `enemy_count`'s `Enemy` read, every real dispatch
549    /// recorded across every flow count checks clean.
550    #[test]
551    fn correctly_declared_manifest_checks_clean_across_flow_counts() {
552        for flow_count in [1usize, 3, 7] {
553            let app = drive_scenario(flow_count, declaring_manifest());
554            let log = app.world().resource::<GroundTruthLog<()>>();
555            assert_eq!(
556                log.entries().len(),
557                flow_count,
558                "expected one recorded dispatch per flow at flow_count={flow_count}"
559            );
560            let cap_table = app.world().resource::<CapabilityTable<()>>();
561            let violations = check(log, cap_table, app.world().components());
562            assert!(
563                violations.is_empty(),
564                "flow_count={flow_count}: {violations:?}"
565            );
566        }
567    }
568
569    /// The under-report case: the manifest never declares `enemy_count`'s
570    /// real `Enemy` read, so every recorded dispatch is a violation, each
571    /// naming its flow, the `Enemy` component, and the `enemy_count` binding.
572    #[test]
573    fn under_declared_manifest_flags_every_dispatch_by_flow_component_and_binding() {
574        let flow_count = 3;
575        let app = drive_scenario(flow_count, under_declaring_manifest());
576        let log = app.world().resource::<GroundTruthLog<()>>();
577        let cap_table = app.world().resource::<CapabilityTable<()>>();
578        let violations = check(log, cap_table, app.world().components());
579        assert_eq!(violations.len(), flow_count, "{violations:?}");
580        for v in &violations {
581            assert_eq!(v.binding, "enemy_count");
582            assert_eq!(v.kind, AccessKind::Read);
583            assert!(
584                v.component.contains("Enemy"),
585                "violation should name the Enemy component: {v:?}"
586            );
587        }
588        // Every one of the flows spawned above is named by some violation —
589        // "a violation must name the flow" (this issue's own wording).
590        let mut named_flows: Vec<Entity> = violations.iter().map(|v| v.flow).collect();
591        named_flows.sort_unstable();
592        named_flows.dedup();
593        assert_eq!(named_flows.len(), flow_count);
594    }
595}