Skip to content

execution_graph: stop a node's own writes from re-triggering it#90

Merged
no-materials merged 3 commits into
forest-rs:mainfrom
no-materials:graph/self-write-fixpoint
Jun 1, 2026
Merged

execution_graph: stop a node's own writes from re-triggering it#90
no-materials merged 3 commits into
forest-rs:mainfrom
no-materials:graph/self-write-fixpoint

Conversation

@no-materials
Copy link
Copy Markdown
Contributor

What

Fixes a non-termination bug introduced by #85 (host writes invalidate prior readers).

A node whose tape both reads and writes the same host-state key never reached a
fixpoint: the read made the node's output depend on the key, while the write
marked that same key dirty during the run — after the planning drain — so the
key stayed dirty and re-scheduled the node on the next pass. run_all() with no
external change re-ran it forever (run_count climbed 1, 2, 3, …).

How

After each run, a node's dependency set is now reads ∖ writes: we collect the
keys the node wrote (a new write_ids scratch buffer, symmetric with the
existing read_ids) and exclude them from its own dependencies. The write still
calls mark_dirty, so other nodes that read the key are invalidated exactly
as before — only the writer's self-trigger is removed. A read-modify-write node
now converges after one run.

While here, host writes to graph-owned Input keys are ignored: an Input
write interns to the same id as a node's input-binding dependency, so honoring
it would let the self-write filter strip that binding and leave the node stale
after a later invalidate_input. Inputs are graph-owned (set via
set_input_value / invalidate_input); hosts should only write the
HostState / OpaqueHost namespaces.

Behavior note

A key a node both reads and writes is treated as an output it owns, not an
input — so an external invalidation of such a key won't re-run that node
either. This is documented in the ## Semantics rustdoc. It's the only
non-looping resolution: keeping the dependency to honor external invalidation
would re-introduce the infinite self-trigger.

Tests

  • node_that_reads_and_writes_same_key_reaches_fixpoint — run_count stays 1
    across repeated run_all().
  • host_write_to_input_key_does_not_drop_graph_input_dependency — a host
    Input-key write no longer strips the node's "x" binding; invalidate_input
    still reruns it.
  • Existing host_write_invalidates_prior_readers_of_same_key still passes
    (cross-node invalidation intact).

Follow-up (not in this PR)

The Input-write guard is enforced locally in the graph's access sink. The
deeper, compile-time fix is to give writes their own key type in
execution_tape (e.g. a WritableKeyRef with only HostState / OpaqueHost)
so writing an Input key is unrepresentable in record_write /
AccessSink::write. That's a cross-crate public-API change, better suited to
the pre-release API hardening tracked separately.

  forest-rs#85 made host writes mark the written key dirty so prior readers are
  invalidated. But a node that both reads and writes the same key then
  invalidated *itself*: the read made its output depend on the key, the
  write re-dirtied it, and the node re-ran on every run_all() forever
  (observed run_count 1, 2, 3, ... with no external change).

  Track the keys a node writes during a run and exclude them from its own
  dependency set (reads ∖ writes). The write still marks the key dirty, so
  other nodes that read it are still invalidated; only the writer's
  self-trigger is removed, so a read-modify-write node now reaches a
  fixpoint.

  Also ignore host writes to graph-owned Input keys: they intern to the
  same id as a node's input binding, so honoring them would let the
  self-write filter strip a real graph-input dependency and leave the node
  stale after invalidate_input. Hosts only own the HostState/OpaqueHost
  namespaces.

  Adds regression tests for both cases.
Comment thread execution_graph/src/tape_access.rs Outdated
@@ -98,14 +101,14 @@ impl AccessSink for CollectingAccessSink<'_> {

fn write(&mut self, key: ResourceKeyRef<'_>) {
self.counter.set(self.counter.get().saturating_add(1));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

An ignored ResourceKeyRef::Input writes still increment the strict-deps access counter before mark_tape_key_dirty(...) returns None.

This is also true of line 265 below.

That means a host call that only does ctx.record_write(ResourceKeyRef::Input("x")) is treated as having reported an access in strict-deps mode, even though the graph records no dependency, no invalidation, and no access-log entry for it. This weakens strict-deps: an unusable/ignored key can satisfy “this host call recorded an access.”

Fix direction: only increment the strict-deps counter for accepted writes, or make ignored input writes explicitly visible as a strict-deps violation. If non-strict mode should silently ignore these writes, strict mode should still reject “only ignored writes.”

I think just moving the increment inside the if statement below is fine and it preserves strict-reps semantics: ignored Input writes don't count as real access events.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good call - fixed by 539c152

  Host writes to graph-owned Input keys are ignored (mark_tape_key_dirty
  returns None), but the access sinks still bumped the strict-deps counter
  before that rejection. As a result a host call whose only action was
  ctx.record_write(ResourceKeyRef::Input(..)) advanced the counter and
  passed strict-deps, even though the graph recorded no dependency, no
  invalidation, and no access-log entry — weakening the mode's guarantee
  that every host call reports something usable.

  Move the counter increment inside the accepted-write branch in both
  CollectingAccessSink::write and DepsOnlyAccessSink::write, so only
  accepted (HostState/OpaqueHost) writes count as access events. Reads are
  unchanged; non-strict mode still silently ignores Input writes (it never
  reads the counter), while strict mode now rejects a call whose only event
  is an ignored write.

  Adds a regression test asserting StrictDepsViolation for a host call that
  only writes an Input key.
@no-materials no-materials merged commit b95423c into forest-rs:main Jun 1, 2026
15 checks passed
@no-materials no-materials deleted the graph/self-write-fixpoint branch June 1, 2026 12:43
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.

2 participants