@@ -92,12 +92,22 @@ struct Inputs {
9292
9393/// The inputs of a head's last compile *attempt* (success or failure).
9494///
95- /// `None` = never attempted. [`sync`] compares this against the current
96- /// inputs to decide when to (re)compile - there is no dirty flag to set or
97- /// forget.
95+ /// `None` = never attempted. [`sync`] compares this against the current inputs
96+ /// (the head's committed graph CA + config) to decide when to (re)compile -
97+ /// there is no dirty flag to set or forget.
9898#[ derive( Component , Default ) ]
9999pub struct CompiledInputs ( Option < Inputs > ) ;
100100
101+ /// When `true`, [`validate_committed`] hashes every open head's working graph
102+ /// each frame and warns if it differs from the head's committed graph CA - i.e.
103+ /// a system mutated the working graph without committing it, violating the
104+ /// [`WorkingGraph`](head::WorkingGraph) commit-before-return invariant.
105+ ///
106+ /// Defaults to `false` (no extra hashing); enable at runtime to debug a
107+ /// suspected missing commit.
108+ #[ derive( Default , Resource ) ]
109+ pub struct ValidateCommitted ( pub bool ) ;
110+
101111/// Event to trigger evaluation of an entrypoint.
102112#[ derive( Event ) ]
103113pub struct EvalEntryEvent {
@@ -195,27 +205,67 @@ pub fn on_eval_entry(
195205// Systems
196206// ---------------------------------------------------------------------------
197207
208+ /// Commit a head's working graph to the registry when it has diverged from the
209+ /// head's current commit, updating the head and emitting a
210+ /// [`head::CommittedEvent`]. Returns `true` if a new commit was made.
211+ ///
212+ /// **Call this from any system that mutates a head's
213+ /// [`WorkingGraph`](head::WorkingGraph), before the system returns** - it is how
214+ /// the commit-before-return invariant (see `WorkingGraph`) is upheld, which in
215+ /// turn lets [`sync`] recompile from the committed address without re-hashing.
216+ /// This is the single place a working graph is content-addressed.
217+ pub fn commit_working_graph < N > (
218+ registry : & mut Registry < N > ,
219+ cmds : & mut Commands ,
220+ entity : Entity ,
221+ head : & mut ca:: Head ,
222+ graph : & Graph < N > ,
223+ ) -> bool
224+ where
225+ N : Clone + ca:: CaHash ,
226+ {
227+ let graph_ca = ca:: graph_addr ( graph) ;
228+ let Some ( head_commit) = registry. head_commit ( head) else {
229+ return false ;
230+ } ;
231+ if head_commit. graph == graph_ca {
232+ return false ;
233+ }
234+ let old_head = head. clone ( ) ;
235+ let new_commit_ca = registry. commit_graph_to_head (
236+ crate :: reg:: timestamp ( ) ,
237+ graph_ca,
238+ || crate :: clone_graph ( graph) ,
239+ head,
240+ ) ;
241+ log:: debug!( "Graph changed -> {}" , new_commit_ca. display_short( ) ) ;
242+ cmds. trigger ( head:: CommittedEvent {
243+ entity,
244+ old_head,
245+ new_head : head. clone ( ) ,
246+ } ) ;
247+ true
248+ }
249+
198250/// Keep every open head's VM in sync with the inputs to compilation.
199251///
200- /// The inputs are the working graph's content address and the
201- /// [`CompileConfig`]; each head's [`CompiledInputs`] memoizes the inputs of
202- /// its last compile attempt, and the VM is rebuilt whenever they differ -
203- /// there is no dirty flag to set or forget. This single rule covers head
204- /// open (and startup spawn), head replace/branch-move, graph edits, and
205- /// config changes.
252+ /// The inputs are the head's *committed* graph content address and the
253+ /// [`CompileConfig`]; each head's [`CompiledInputs`] memoizes the inputs of its
254+ /// last compile attempt, and the VM is rebuilt whenever they differ. The
255+ /// committed CA is read straight from the registry - **no per-frame hashing**
256+ /// (#159): the [`WorkingGraph`](head::WorkingGraph) commit-before-return
257+ /// invariant guarantees the working graph already matches it, and every change
258+ /// is reflected either by a new commit (edits, via [`commit_working_graph`]) or
259+ /// by a reset `CompiledInputs` (head open/replace/branch-move/resync), so
260+ /// comparing committed CA + config is sufficient to drive recompiles.
206261///
207- /// Whether the rebuild is a fresh `init` or an in-place `compile` is decided
208- /// by VM presence in [`head::HeadVms`]: absent means a fresh init (head
262+ /// Whether the rebuild is a fresh `init` or an in-place `compile` is decided by
263+ /// VM presence in [`head::HeadVms`]: absent means a fresh init (head
209264/// replace/branch-move remove the VM to discard the old graph's node state);
210265/// present means an in-place compile, preserving node state (graph edits and
211266/// config changes).
212- ///
213- /// When the working graph's address has diverged from the head's commit, the
214- /// graph is committed to the registry first and a [`head::CommittedEvent`]
215- /// is emitted for UI state updates (handled by `GantzEguiPlugin` if present).
216267pub fn sync < N > (
217- mut cmds : Commands ,
218- mut registry : ResMut < Registry < N > > ,
268+ registry : Res < Registry < N > > ,
219269 builtins : Res < BuiltinNodes < N > > ,
220270 ep_fns : Res < EntrypointFns < N > > ,
221271 config : Res < CompileConfig > ,
@@ -225,45 +275,25 @@ pub fn sync<N>(
225275 N : ' static + Node + Clone + ca:: CaHash + Send + Sync ,
226276{
227277 for mut data in heads_query. iter_mut ( ) {
228- let graph: & Graph < N > = & * data. working_graph ;
278+ // The committed graph CA - the working graph already matches it (the
279+ // `WorkingGraph` invariant), so there is nothing to hash here.
280+ let Some ( graph_ca) = registry. head_commit ( & data. head_ref . 0 ) . map ( |c| c. graph ) else {
281+ continue ;
282+ } ;
229283 let inputs = Inputs {
230- graph : ca :: graph_addr ( graph ) ,
284+ graph : graph_ca ,
231285 config : config. 0 ,
232286 } ;
233287 if data. compiled_inputs . 0 == Some ( inputs) {
234288 continue ;
235289 }
236290
237- // Commit when the working copy has diverged from the head's commit.
238- let head: & mut ca:: Head = & mut * data. head_ref ;
239- if let Some ( head_commit) = registry. head_commit ( head) {
240- if head_commit. graph != inputs. graph {
241- let old_head = head. clone ( ) ;
242- let old_commit_ca = registry. head_commit_ca ( head) . copied ( ) . unwrap ( ) ;
243- let new_commit_ca = registry. commit_graph_to_head (
244- crate :: reg:: timestamp ( ) ,
245- inputs. graph ,
246- || crate :: clone_graph ( graph) ,
247- head,
248- ) ;
249- log:: debug!(
250- "Graph changed: {} -> {}" ,
251- old_commit_ca. display_short( ) ,
252- new_commit_ca. display_short( )
253- ) ;
254- cmds. trigger ( head:: CommittedEvent {
255- entity : data. entity ,
256- old_head,
257- new_head : head. clone ( ) ,
258- } ) ;
259- }
260- }
261-
262291 // Rebuild the VM. On an in-place compile error the VM is kept (its
263292 // previous module remains evaluable) and the error surfaces via the
264293 // module/diagnostics components; a failed init leaves no VM, so eval
265294 // systems (e.g. `drive_frame_bangs`, `on_eval_entry`) skip the head
266295 // rather than driving a stale graph.
296+ let graph: & Graph < N > = & * data. working_graph ;
267297 let get_node = |ca : & ca:: ContentAddr | lookup_node ( & registry, & * * builtins, ca) ;
268298 let entrypoints = collect_entrypoints ( & ep_fns, & get_node, graph) ;
269299 let result = match vms. get_mut ( & data. entity ) {
@@ -282,3 +312,36 @@ pub fn sync<N>(
282312 data. compiled_inputs . 0 = Some ( inputs) ;
283313 }
284314}
315+
316+ /// Debug check for the [`WorkingGraph`](head::WorkingGraph) commit-before-return
317+ /// invariant.
318+ ///
319+ /// When [`ValidateCommitted`] is enabled, hash every open head's working
320+ /// graph and warn if it differs from the head's committed graph CA - i.e. a
321+ /// system mutated the working graph without committing it. A no-op (no hashing)
322+ /// when disabled, which is the default.
323+ pub fn validate_committed < N > (
324+ validate : Res < ValidateCommitted > ,
325+ registry : Res < Registry < N > > ,
326+ heads : Query < head:: OpenHeadDataReadOnly < N > , With < head:: OpenHead > > ,
327+ ) where
328+ N : ' static + ca:: CaHash + Send + Sync ,
329+ {
330+ if !validate. 0 {
331+ return ;
332+ }
333+ for data in heads. iter ( ) {
334+ let working = ca:: graph_addr ( & data. working_graph . 0 ) ;
335+ let committed = registry. head_commit ( & data. head_ref . 0 ) . map ( |c| c. graph ) ;
336+ if committed != Some ( working) {
337+ log:: warn!(
338+ "WorkingGraph invariant violated: head {:?} working graph ({}) does \
339+ not match its committed graph ({:?}) - a system mutated it without \
340+ committing (see `commit_working_graph`)",
341+ data. entity,
342+ working. display_short( ) ,
343+ committed. map( |c| c. display_short( ) . to_string( ) ) ,
344+ ) ;
345+ }
346+ }
347+ }
0 commit comments