Skip to content

refactor: add graph traversal core and move resolution onto it - #108

Open
ehennestad wants to merge 2 commits into
fix-controlled-term-deserializationfrom
add-graph-traversal-visitor
Open

refactor: add graph traversal core and move resolution onto it#108
ehennestad wants to merge 2 commits into
fix-controlled-term-deserializationfrom
add-graph-traversal-visitor

Conversation

@ehennestad

@ehennestad ehennestad commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

First architecture PR of the stack. Introduces the shared traversal machinery from #69 and moves link resolution onto it.

What was wrong

Schema.resolve walked the graph by hand. It had no cycle detection, and it discarded the value returned by resolve:

linkedInstances{j}.resolve(nvPairs{:});   % return value dropped

A 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 — MixedTypeReference here, and the referenceNode = [] branch of KGResolver in 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, in Schema.resolve and in loadInstances/resolveLinks — each with its own mixed type unwrapping, and only the serializer with cycle detection.

TraversalCore

openminds.internal.graph.TraversalCore holds 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.BaseVisitor is the side-effecting protocol. It differs from the sketch in #69 in one respect: edges, not nodes, are the unit of work.

children = doForLinkedEdge(obj, parentNode, propertyName, children)

The sketch has onVisitNode(node) call resolveNode(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, and canResolve returning false inside onVisitNode would silently skip nodes another resolver could handle.

So the two concerns are separated:

  • AbstractLinkResolver is an interface for fetching the data behind one kind of identifier. It no longer walks anything.
  • ResolvingVisitor owns the traversal, the link depth accounting and the cycle detection, and selects a resolver per reference from the registry.

Schema.resolve reduces 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 onCleanup so a failed resolution cannot leak budget.

Breaking changes

  • AbstractLinkResolver.resolve (static) becomes resolveNode (instance method). The rename separates it from Schema.resolve, and instance methods let a resolver hold state such as an HTTP client or a cache.
  • canResolve becomes an instance method.
  • NumLinksToResolve is gone from the resolver signature; the visitor owns depth.
  • LinkResolverRegistry.LinkResolvers is a cell array. The resolvers no longer share matlab.mixin.Heterogeneous, which existed only to allow the object array.

openminds-kg-sync must update KGResolver accordingly. Its existing distinction between passing a ReferenceNode to populate and building a new instance for a MixedTypeReference carries over unchanged, and is now exactly what the protocol expects rather than something the caller silently discards.

MixedTypeReference and the return type of resolve

MixedTypeReference overrode resolve to 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.resolve assigned results back into the input array:

obj(i) = visitor.visit(obj(i));

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:

  • a resolver that replaces rather than populates has its result stored on the parent property, which is the defect described above;
  • a circular graph terminates. Two ContentType instances referencing each other are resolved with a link depth of 500, so termination can only come from cycle detection rather than from exhausted depth;
  • a MixedTypeReference resolves 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. MockLinkResolver moves to resolveNode, and a ReplacingMockLinkResolver is added for the replacement case.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Test Results (R2022a)

748 tests  +3   744 ✅ +3   2m 30s ⏱️ +11s
 19 suites ±0     3 💤 ±0 
  1 files   ±0     1 ❌ ±0 

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
ehennestad force-pushed the add-graph-traversal-visitor branch from b61f509 to a1caf8a Compare August 28, 2026 00:04
@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.82828% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.17%. Comparing base (80eb833) to head (68c97f7).

Files with missing lines Patch % Lines
...ternal/+openminds/+internal/+graph/TraversalCore.m 77.77% 8 Missing ⚠️
.../+openminds/+internal/+resolver/InstanceResolver.m 20.00% 4 Missing ⚠️
.../+openminds/+internal/+resolver/ResolvingVisitor.m 86.36% 3 Missing ⚠️
code/internal/+openminds/+abstract/Schema.m 81.81% 2 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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
ehennestad force-pushed the add-graph-traversal-visitor branch 5 times, most recently from b71a7d6 to 68c97f7 Compare August 28, 2026 12:59
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