Skip to content

feat(pkg/go): report the relations in a cycle the graph cannot resolve - #668

Draft
SoulPancake wants to merge 1 commit into
feat/graph-validation-apifrom
experiment/graph-refusal-identities
Draft

feat(pkg/go): report the relations in a cycle the graph cannot resolve#668
SoulPancake wants to merge 1 commit into
feat/graph-validation-apifrom
experiment/graph-refusal-identities

Conversation

@SoulPancake

Copy link
Copy Markdown
Member

Build refuses a model whose relations sit in a cycle that cannot be resolved and returns no graph alongside the refusal, so there is nothing left to read for a position. Two of those shapes leave every relation in them satisfiable, which means the rewrite-tree walk finds an entry point for all of them and reports nothing: a cycle where no step reads a tuple, so going round it never gets closer to an answer, and a cycle that does read a tuple but runs through an operand of an intersection or an exclusion, which the resolver cannot subtract while it is still computing it. Of the 38 shared corpus models Build refuses, 18 come back with a message naming no relation, and those 18 are these two shapes.

cycle_shape.go finds both over the model and raises one finding per relation, so a caller gets a line rather than a sentence about the whole model. The graph stays the authority on whether a model resolves, and TestCycleShapesAgreeWithTheBuilder holds this to it by allowing findings only on models Build refuses: it reports on 18 of the 38 refused and on 0 of the 51 accepted. Enumerating simple cycles is exponential in the worst case, so a relation's search is capped, and one that hits the cap goes unreported rather than reported from a lower bound that would give one relation of a cycle a finding and the next one nothing. The worst corpus relation walks 7 steps of the 200,000 allowed.

Cycle findings run only where the walk found nothing, because the relations the walk names are the same relations a cycle runs through and one finding per relation is enough. Where neither says anything the refusal is reported as itself, positionless, which no corpus case reaches.

That last wording comes from the sentinel the refusal carries rather than from the builder's message. AssignWeights chooses which problem to report by ranging over a map, so a model with more than one gets a message naming whichever came out first, and a different one on the next run. One corpus case does this, alternating between page#member and ticket#member over repeated builds. Validation reports both of those as missing-definition either way, so nothing is lost by wording the finding from the sentinel, and the finding is then the same every run, which is what lets a consumer cache or diff it. The builder's error is chained under ErrModelNotBuildable so a caller that wants the original text can still reach it, and TestEveryGraphSentinelHasAWording keeps the table exhaustive.

Only accepted graphs are read anywhere in this path. Fingerprinting all 51 accepted corpus graphs over 40 builds each, covering weights, node types, tuple-cycle flags, recursive labels and wildcards, gives one distinct graph per model and no model that flips between accepted and refused, so a built graph is stable to read. A refused one is not available to read at all.

pkg/go/graph is untouched: it is byte-identical to develop in this tree.

CyclicRelation joins the emitted error types with its own sentinel, ErrRelationInUnresolvableCycle. Its documentation page and graph-model-unbuildable's are rewritten against the messages the code now produces, both having documented examples that report something else. The five new shared corpus cases put an unsatisfiable relation among satisfiable ones, where walk order can change the answer; every pre-existing case breaks every relation in its model, so order cannot show up in any of them. Their expected values were taken from the Go validator. pkg/js and pkg/java have not been run against them.

Description

What problem is being solved?

How is it being solved?

What changes are made to solve it?

References

Review Checklist

  • I have clicked on "allow edits by maintainers".
  • I have added documentation for new/changed functionality in this PR or in a PR to openfga.dev [Provide a link to any relevant PRs in the references section above]
  • The correct base branch is being used, if not main
  • I have added tests to validate that the change in functionality is working as expected

@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e4dab258-2415-462e-a1e4-330be7924e88

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@SoulPancake
SoulPancake force-pushed the feat/graph-validation-api branch from 6f98202 to 1e1f520 Compare August 21, 2026 13:31
@SoulPancake
SoulPancake force-pushed the experiment/graph-refusal-identities branch from d42d5b8 to d65f0ff Compare August 21, 2026 13:31
Build refuses a model whose relations sit in a cycle that cannot be resolved and
returns no graph alongside the refusal, so there is nothing left to read for a
position. Two of those shapes leave every relation in them satisfiable, which
means the rewrite-tree walk finds an entry point for all of them and reports
nothing: a cycle where no step reads a tuple, so going round it never gets closer
to an answer, and a cycle that does read a tuple but runs through an operand of
an intersection or an exclusion, which the resolver cannot subtract while it is
still computing it. Of the 38 shared corpus models Build refuses, 18 come back
with a message naming no relation, and those 18 are these two shapes.

cycle_shape.go finds both over the model and raises one finding per relation, so
a caller gets a line rather than a sentence about the whole model. The graph
stays the authority on whether a model resolves, and
TestCycleShapesAgreeWithTheBuilder holds this to it by allowing findings only on
models Build refuses: it reports on 18 of the 38 refused and on 0 of the 51
accepted. Enumerating simple cycles is exponential in the worst case, so a
relation's search is capped, and one that hits the cap goes unreported rather
than reported from a lower bound that would give one relation of a cycle a
finding and the next one nothing. The worst corpus relation walks 7 steps of the
200,000 allowed.

Cycle findings run only where the walk found nothing, because the relations the
walk names are the same relations a cycle runs through and one finding per
relation is enough. Where neither says anything the refusal is reported as
itself, positionless, which no corpus case reaches.

That last wording comes from the sentinel the refusal carries rather than from
the builder's message. AssignWeights chooses which problem to report by ranging
over a map, so a model with more than one gets a message naming whichever came
out first, and a different one on the next run. One corpus case does this,
alternating between page#member and ticket#member over repeated builds.
Validation reports both of those as missing-definition either way, so nothing is
lost by wording the finding from the sentinel, and the finding is then the same
every run, which is what lets a consumer cache or diff it. The builder's error is
chained under ErrModelNotBuildable so a caller that wants the original text can
still reach it, and TestEveryGraphSentinelHasAWording keeps the table exhaustive.

Only accepted graphs are read anywhere in this path. Fingerprinting all 51
accepted corpus graphs over 40 builds each, covering weights, node types,
tuple-cycle flags, recursive labels and wildcards, gives one distinct graph per
model and no model that flips between accepted and refused, so a built graph is
stable to read. A refused one is not available to read at all.

pkg/go/graph is untouched: it is byte-identical to develop in this tree.

CyclicRelation joins the emitted error types with its own sentinel,
ErrRelationInUnresolvableCycle. Its documentation page and
graph-model-unbuildable's are rewritten against the messages the code now
produces, both having documented examples that report something else. The five
new shared corpus cases put an unsatisfiable relation among satisfiable ones,
where walk order can change the answer; every pre-existing case breaks every
relation in its model, so order cannot show up in any of them. Their expected
values were taken from the Go validator, and pkg/js reports the same.

One of the five expects two relations of one type, which no case did before, and
that is order-sensitive in Java. ModelValidator raises entrypoint findings while
iterating the relations HashMap's keySet, so their order is by hash bucket:
brokenA and brokenB differ by one in hashCode and fall either side of a bucket
boundary. It now iterates the names sorted, which is what pkg/go does in
cycle_detection.go, so all three languages report them in the same order. No
test outside the shared corpus asserts an entrypoint message in Java.
@SoulPancake
SoulPancake force-pushed the experiment/graph-refusal-identities branch from d65f0ff to ccee2e7 Compare August 21, 2026 13: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.

1 participant