Skip to content

Walk the graph breadth first so depth is the shortest path - #85

Merged
tgharold merged 7 commits into
masterfrom
tgh-20261002-breadth-first-walk
Oct 2, 2026
Merged

tgharold merged 7 commits into
masterfrom
tgh-20261002-breadth-first-walk

Conversation

@tgharold

@tgharold tgharold commented Oct 2, 2026

Copy link
Copy Markdown
Owner

Motivation

The maximum depth added in #83 counted the length of the route the depth-first walk happened to take. A graph with links back to its parents, such as the navigation properties of an ORM model, has a long route even when every object is a few levels from the root. A valid store of 25 orders failed with a false "nested more than 128 levels deep" error, and so did a valid ring of 200 objects. Each of those objects was in fact validated by another route, so the message was untrue and the validation failed for a valid model.

The walk is now breadth first, so the first path that reaches an object is its shortest one and a depth error is always true. It also uses a queue and no recursion, so the depth of a graph no longer uses the stack.

Summary

  • Replace the recursive walk with a queue. An object is marked when it is queued, so the depth is the shortest path. A collection that a property holds is a queue step of its own, so every step is one level. Paths are built as strings only for objects that have a result. The limit of 128 stays, as a stop for a computed property that builds a new object on each read.
  • Results come shallowest first, and an object that two paths reach is reported with its shorter path. Both are listed as BREAKING in the changelog, with the old and new member names for the ORM case.
  • A collection that a property holds is now enumerated when its step is reached, not when the property is read. This is also listed as BREAKING. An item that adds to its own list no longer throws "Collection was modified".
  • The check for an object that Equals an ancestor now runs when the object is queued, and an object that stops is not marked. Without this, a longer path that passes no equal ancestor never walked the object, which was a new false pass compared with v2.3.3. One case is still not covered: an object that every path reaches through an equal ancestor. It is documented as a known limit and recorded as a skipped spec (EfRegionCopy).
  • README gets an "Order of results" section and a corrected description of the depth and the equal-ancestor rule. Stale comments about stack overflows in the tests are fixed.

The commits follow the usual order for this repo: specs, then the fix, then docs, twice (the rewrite, then the fixes from review).

Test plan

  • Full suite on net8.0 and net10.0: 574 passed, 7 skipped, 0 failed
  • net481 test project builds with 0 warnings and 0 errors (-p:IncludeNetFramework=true)
  • New specs in BreadthFirstWalkTests fail on the previous walker (12 of the first 15, without crashing the test host)
  • Contract-compat run against v2.2.0 and v2.3.3 source: the only differences from this change are the intentional ones (order, shortest path, depth rule). This ran before the equal-ancestor fix and was not repeated after it.
  • CI on Windows runs the net481 tests

tgharold and others added 7 commits October 2, 2026 09:00
A depth-first walk measures the depth of the route it took, so a graph
with links back to its parents fails with "nested more than 128 levels
deep" even when every object is a few levels from the root. A valid
store of 25 orders and a valid ring of 200 objects both fail.

The specs cover the rules of a breadth-first walk: the depth is the
shortest path, an object that two routes reach is reported with the
shorter route, a depth error is reported once and is never false, and
the results come shallowest first. Twelve of the fifteen tests fail on
the depth-first walk, without a stack overflow.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
The depth-first walk measured the depth of the route it took. A graph
with links back to its parents, such as the navigation properties of an
ORM model, has a long route even when every object is a few levels from
the root, and a valid store of 25 orders failed with a false "nested
more than 128 levels deep" error.

The walk now keeps a queue and calls nothing recursively. An object is
marked when it is queued, so the first path that reaches it is the
shortest one, and a depth error is always true. A collection that a
property holds is a queue step of its own, so every step is one level.
Paths are chains of steps and become strings only for an object that
has a result. The limit of 128 stays, as a stop for a computed property
that builds a new object on each read.

The results come shallowest first, so Items[0].Name now follows Last.Name
in a model with First, Items and Last. An object that two routes reach
is reported with its shorter path. Ties keep the first property.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…paths

Add an "Order of results" section to the README, say that the depth is
the shortest path, and drop the changelog note about stack use for deep
collections, which no longer holds. Add a BREAKING changelog entry for
the result order, the shortest path in a member name and the depth rule.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
The Equals-ancestor stop marks an object on its shortest path, so a longer
path that passes no equal ancestor never walks it, and an invalid object
below it passes. v2.3.3 found it for LongFirst. The two specs for it
fail on the breadth-first walk. A third spec, EfRegionCopy, records a
known limit and is skipped.

Also pin what already holds: an object that two paths stop at is
validated once, an equal ancestor at level 129 gets no depth error, a
level is finished for all parents before the next one starts, and a
collection that a property holds is enumerated when its step is reached,
not when the property is read. Move the result-order test from
UpgradeCompatibilityTests, which checks the 2.2 contract, to
BreadthFirstWalkTests, because the order changed in 3.0.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
The Equals-ancestor check ran when an object came out of the queue, after
the object was marked on its shortest path. If that path passed an equal
ancestor, the object was validated but not walked, and every longer path
to it was dropped, so an invalid object below it passed. v2.3.3 found it
when the first path in property order passed no equal ancestor.

Check for an equal ancestor when the object is queued, and do not mark
an object that stops. It is validated once, however many paths stop at
it. A later path that passes no equal ancestor queues it and walks it,
and skips the validation. That also finds the object when the shortest
path is the first in property order, which v2.3.3 missed.

An object that every path reaches through an equal ancestor is still not
walked. That is a known limit (EfRegionCopy, skipped). Also fix three
comments, and say that the depth limit stops one new object per read.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…ming

Update the README for the equal-ancestor rule, the order of items that
are collections, and the limit of the depth check for a property that
builds two or more new objects per read. Add two BREAKING changelog
bullets: the equal-ancestor rule with its known limit, and the later
enumeration of a collection that a property holds. Make two examples
clearer and remove a duplicate definition in a test summary.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Since the maximum depth, a guard that breaks makes the walk fail with a
depth error and does not overflow the stack. Say so, and keep the note
about what happened before 3.0. The comments about a record's generated
Equals are unchanged, because that recursion is still real.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
@tgharold
tgharold merged commit 29e5fc2 into master Oct 2, 2026
3 checks passed
@tgharold
tgharold deleted the tgh-20261002-breadth-first-walk branch October 2, 2026 20:34
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.

1 participant