Skip to content

fix: stop an unresolved refinement being reported as a cycle [minor] - #196

Merged
matt-edmondson merged 1 commit into
mainfrom
claude/nice-davinci-vyokmw
Sep 15, 2026
Merged

matt-edmondson merged 1 commit into
mainfrom
claude/nice-davinci-vyokmw

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #171

What was wrong

ValidateRefinement inferred "cycle" from Refines() never reaching a non-semantic type. But Refines() has two exits, not one — its loop condition is

while (current is Semantic semantic && semantic.Declaration is SchemaSemanticType declaration)

so it stops both at a type it has already seen (a real cycle) and at a link whose Declaration is null (a name the schema does not declare). Both chains look identical from the call site.

So A refines B, B refines Missing, and schema.Validate() answered:

[Error] A: Semantic type 'A' refines itself, directly or through a cycle, so it is represented as nothing.
[Error] B: Semantic type refines 'Missing', which this schema does not declare.

There is no cycle anywhere. The first message sends the author looking for one, and it names A — which is fine — rather than B, which is the declaration with the typo in it.

The fix

SchemaSemanticType.RefinesItself() asks the question the message actually makes: did the chain come back to a type it had already seen?

public bool RefinesItself()
{
	SchemaSemanticType deepest = Refines().LastOrDefault() ?? this;
	return deepest.UnderlyingType is Semantic semantic && semantic.Declaration is not null;
}

The chain ended on a cycle exactly when the deepest type it reached still refines something that resolves, since that is the link Refines() refused to follow a second time. An unresolved tail leaves a Semantic with a null Declaration there instead, and is left to the declaration that names it — which keeps it one message rather than two, the same rule CLAUDE.md states for an unresolved semantic type generally.

It is written in terms of Refines() rather than walking again, and reuses the LastOrDefault() ?? this idiom already in Representation() so the direct A refines A case (which yields nothing) is covered by the same line.

Tests

  • AnUnresolvedRefinementDeeperInAChainIsNotACycle — the two-link Weight → ForceMagnitude → Missing chain the issue describes, asserting exactly one issue, that it is the "does not declare" one, and that it is reported against ForceMagnitude. The existing AnUnresolvedRefinementIsRejected is depth 1 and never reaches this branch.
  • ARefinementChainThatRevisitsATypeStillReportsItself — A → B → C → B, where the cycle does not include the type being asked, so the fix cannot narrow the check to self-reference.

Verified the first test fails without the change (Sequence contains more than one element — the spurious cycle error) and passes with it.

Schema.Test is 478/478 green and Schema.Cpp.Test is 102/102 green on net10.0. The net8.0/net9.0 legs could not be launched in this container (only the .NET 10 runtime is installed), so CI covers those.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CCVJEzptkDVM2wWtGTY7Av


Generated by Claude Code

Refines() stops walking for two reasons - a type it has already seen, and a
link naming something the schema does not declare - and ValidateRefinement
could tell them apart only by whether the chain reached a non-semantic type,
which both answer the same way. So `A` refines `B` refines `Missing` reported
a cycle that does not exist, on `A`, while `B` - the declaration with the typo
in it - was named only by the second message.

RefinesItself() asks the question the message makes: the chain ended on a
cycle exactly when the deepest type it reached still refines something that
resolves, since that is the link Refines() refused to follow twice. An
unresolved tail is left to the declaration that names it, which is where the
fix is, keeping it one message rather than two.

Fixes #171

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCVJEzptkDVM2wWtGTY7Av
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 3553c2a into main Sep 15, 2026
13 checks passed
@matt-edmondson
matt-edmondson deleted the claude/nice-davinci-vyokmw branch September 15, 2026 02:28
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.

A refinement chain ending at an unresolved name is reported as a cycle that does not exist

2 participants