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}