refactor: add graph traversal core and move resolution onto it - #108
Open
ehennestad wants to merge 2 commits into
Open
refactor: add graph traversal core and move resolution onto it#108ehennestad wants to merge 2 commits into
ehennestad wants to merge 2 commits into
Conversation
Contributor
Test Results (R2022a)748 tests +3 744 ✅ +3 2m 30s ⏱️ +11s For more details on these failures, see this check. Results for commit 68c97f7. ± Comparison against base commit 80eb833. ♻️ This comment has been updated with latest results. |
ehennestad
force-pushed
the
add-graph-traversal-visitor
branch
from
August 28, 2026 00:04
b61f509 to
a1caf8a
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## fix-controlled-term-deserialization #108 +/- ##
=======================================================================
+ Coverage 79.02% 79.17% +0.15%
=======================================================================
Files 417 419 +2
Lines 4042 4077 +35
=======================================================================
+ Hits 3194 3228 +34
- Misses 848 849 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Resolution walked the instance graph by hand inside Schema.resolve, with no cycle detection and no way to store an instance that a resolver returned. This introduces the shared traversal machinery described in issue #69 and moves resolution onto it. TraversalCore holds the primitives every traversal of an instance graph needs: enumerating linked and embedded edges, unwrapping mixed type values, writing children back to a property, and tracking which nodes have been seen. The same traversal is currently written three times, in the serializer, in Schema.resolve and in loadInstances, each with its own mixed type handling and only one of them with cycle detection. BaseVisitor is the side-effecting protocol on top of it. Edges rather than nodes are the unit of work, which is a departure from the sketch in issue #69. A node hook cannot store a replacement, because replacing a child needs the parent, the property and the position within it. Since a reference whose type is unknown until it is probed can only be resolved by replacement, a node hook cannot express resolution at all. Returning the child list also keeps the remaining children in position. Resolution splits in two. AbstractLinkResolver is now an interface for fetching the data behind one kind of identifier, and no longer walks anything. ResolvingVisitor owns the traversal and selects a resolver for each reference it meets. Keeping the resolver as the visitor, as issue #69 proposed, cannot handle a graph holding references from more than one source, because the traversal would be bound to a single resolver. Schema.resolve reduces to constructing a visitor and calling it. The recursion, depth accounting and resolver lookup leave the class. The static resolve and canResolve methods become instance methods, and resolve is renamed resolveNode to separate it from Schema.resolve. Resolvers may now hold state such as clients and caches. The registry holds resolvers in a cell array rather than relying on matlab.mixin.Heterogeneous, which the resolvers no longer share. Breaking change for openminds-kg-sync: KGResolver must rename its static resolve to an instance method resolveNode, make canResolve an instance method, and drop the NumLinksToResolve argument, which the visitor now owns. Its existing distinction between populating a reference node and building a new instance carries over unchanged and is now what the protocol expects. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ehennestad
force-pushed
the
add-graph-traversal-visitor
branch
5 times, most recently
from
August 28, 2026 12:59
b71a7d6 to
68c97f7
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First architecture PR of the stack. Introduces the shared traversal machinery from #69 and moves link resolution onto it.
What was wrong
Schema.resolvewalked the graph by hand. It had no cycle detection, and it discarded the value returned byresolve:linkedInstances{j}.resolve(nvPairs{:}); % return value droppedA resolver that populates a reference in place therefore worked, but one that has to build a new instance and return it had no effect at all. That is the normal case for a reference whose type is not known until it is probed —
MixedTypeReferencehere, and thereferenceNode = []branch ofKGResolverin openminds-kg-sync. Verified before the change with a resolver that replaces rather than populates: the resolver ran, produced the instance, and the property still held the original stub.The same traversal exists three times in the codebase — in
BaseSerializer, inSchema.resolveand inloadInstances/resolveLinks— each with its own mixed type unwrapping, and only the serializer with cycle detection.TraversalCore
openminds.internal.graph.TraversalCoreholds the primitives: enumerating linked and embedded edges, unwrapping mixed type values, writing children back to a property, and tracking which nodes have been seen. What "seen" means is left to the protocol built on top, because the two protocols need different cycle semantics.BaseVisitor, and a departure from the issue
openminds.abstract.BaseVisitoris the side-effecting protocol. It differs from the sketch in #69 in one respect: edges, not nodes, are the unit of work.The sketch has
onVisitNode(node)callresolveNode(node), which reproduces the bug above — the hook has no parent, no property and no index, so it structurally cannot store a replacement, and it drops the returned instance exactly as the old code did. Since a reference whose type is unknown can only be resolved by replacement, a node hook cannot express resolution at all.Returning the child list also means the whole property is assigned once, which keeps the remaining children in their original positions. That is the shape needed for the multi-valued position loss addressed later in this stack.
Resolution split in two
The issue proposes
AbstractLinkResolver extends BaseVisitor. That cannot handle a graph holding references from more than one source, because the traversal would be bound to whichever resolver started it, andcanResolvereturning false insideonVisitNodewould silently skip nodes another resolver could handle.So the two concerns are separated:
AbstractLinkResolveris an interface for fetching the data behind one kind of identifier. It no longer walks anything.ResolvingVisitorowns the traversal, the link depth accounting and the cycle detection, and selects a resolver per reference from the registry.Schema.resolvereduces to constructing a visitor and calling it.Link depth is spent on following a link and not on descending into an embedded instance, since an embedded instance is part of its parent rather than a separate node. The depth is restored via
onCleanupso a failed resolution cannot leak budget.Breaking changes
AbstractLinkResolver.resolve(static) becomesresolveNode(instance method). The rename separates it fromSchema.resolve, and instance methods let a resolver hold state such as an HTTP client or a cache.canResolvebecomes an instance method.NumLinksToResolveis gone from the resolver signature; the visitor owns depth.LinkResolverRegistry.LinkResolversis a cell array. The resolvers no longer sharematlab.mixin.Heterogeneous, which existed only to allow the object array.openminds-kg-sync must update
KGResolveraccordingly. Its existing distinction between passing aReferenceNodeto populate and building a new instance for aMixedTypeReferencecarries over unchanged, and is now exactly what the protocol expects rather than something the caller silently discards.MixedTypeReference and the return type of resolve
MixedTypeReferenceoverroderesolveto call the resolver directly, because the base implementation discarded replacements and a reference of unknown type can only be resolved by replacement. With the visitor storing what the resolver returns, that override is redundant and is removed.Removing it exposed a second problem.
Schema.resolveassigned results back into the input array:which cannot work when the resolved instance is a different class from the reference it replaces — precisely the case the override existed for. Results are now collected separately and returned. An array that resolves to a single class still comes back as an object array; one that resolves to several comes back as a cell array, since instances of different types cannot form one.
Tests
Two cases added to
ResolverTest:ContentTypeinstances referencing each other are resolved with a link depth of 500, so termination can only come from cycle detection rather than from exhausted depth;MixedTypeReferenceresolves to an instance of the discovered type, covering the removed override.Existing resolver tests are updated for the cell array registry and the instance-method contract.
MockLinkResolvermoves toresolveNode, and aReplacingMockLinkResolveris added for the replacement case.🤖 Generated with Claude Code