Skip to content

fix(compilers/openapi): bound or remove quadratic key scans - #774

Merged
OmarAlJarrah merged 22 commits into
mainfrom
fix/openapi-external-hop-cost
Oct 8, 2026
Merged

OmarAlJarrah merged 22 commits into
mainfrom
fix/openapi-external-hop-cost

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Summary

Resolves #773, resolves #775, resolves #776, resolves #777, resolves #778, resolves #779.

They share one mechanism. The library finds a key in raw YAML by comparing the keys of the mapping that holds it, in order: jsonpointer.GetTarget on a yaml.Node, and the read of a model object that none of its fields answers, which the library makes in the mapping the object was built from. A read therefore costs the width of every mapping it passes. Wherever morphic reads, or makes the library read, once per reference, n references into one wide mapping cost n squared. This PR removes that scan from morphic's own reads, and bounds the library reads morphic adds where it cannot avoid them.

Reading a pointer as the library does: navigation

A new package, compilers/openapi/internal/navigation, reads a pointer through the parsed model a token at a time, as GetTarget's walk does, and says where that walk leaves the model:

  • Step (moved from resolve, so defs can import it) reads one token as the library's walk reads on. A model the model holds by value comes back as a pointer to a copy (openapi: Scope.At misses a reference under an operation's default response #779).
  • ReadingOf follows the library's dispatch for one token (getStructTarget and navigateModel, v1.25.2). It reads through stand-ins (GetNavigableNode), asks each map a model embeds through the map's own NavigateWithKey, and reads a model's core by key tag. It says which of three ways the read goes on: a field or an entry answers the token; the read leaves the model, for the mapping the library would scan, which it returns unscanned; or the library retries an index token. Leaves asks whether it leaves.
  • Walk steps until a token leaves the model, and returns that mapping with the tokens left, which the library reads together there. So a << merge holding a decoy is read as the library reads it. An index token on a struct that is no model is left to the library whole, since the library tries it as an index only once the rest of the pointer fails below it as a key.
  • Tokens splits a pointer as the library's navigation stack holds it.

ModelAt, Scope.At, defs.Reader, loops and modelCost all read through it now.

Reading raw YAML through an index: treeReads

treeReads (treereads.go) mirrors GetTarget's YAML walk: the first of two equal keys, a key read through an alias, and the << fallback with its backtracking. It answers each mapping from an index built once. Its << fallback keeps the reads it is inside on a stack rather than the call stack, each pushed at a step, so the read's limit bounds them. It has no depth bound of its own, as the library has none: the parse budgets bound a chain's depth, and a caller may lift them. It walks two ways:

  • cost counts the library's steps, to price the reads the library makes.
  • read takes the same walk but counts its own steps: one for each node it visits, key it looks up and << key it tries. A mapping's width then costs it once, when indexed. loops answers from read.

A generated differential holds both walks to GetTarget, and cost's counts to a transcription of the library's walk. A fuzz target drives that generator's seed.

An earlier version of this branch capped the << recursion at 1,024 merged mappings along a pointer, and priced a deeper read past any limit. With references on, loops then left a $ref the library resolves unresolved: 1,100 nested inline merges, or 45,000 through aliases at the default alias budget, exited 1 where main exits 0. A read entering a mapping it is already reading for the same part would never end, in the library as here. No parsed tree holds that cycle, since the pre-parse refusals reject an anchor naming its own ancestor, and a hand-built one is priced past the limit where it closes.

modelCost prices the library's read of a pointer in the source's model exactly:

  • a step for each token the model answers or fails;
  • where Walk says the read leaves the model, a step into the raw YAML;
  • cost's price of the tokens left there.

#773: resuming a chain in another document

With allow-external-refs=true, settle resumes a schema chain that stalled on another document's cached bytes (#761). Each resumed hop was read twice, once by ends to rule out a cycle and then by the resumed resolution, and nothing charged either read.

settle now charges every resumption, before it reads anything, to maxResumeWork (2²⁸ steps per resolution pass, the size maxMappingWork already uses). The charge is:

  • a step for the resumption;
  • for each hop the resumed resolution reads in the document's tree, a step plus the keys the library's read compares there, counted once per read: twice for a schema (ends, then the library), and once for other kinds, which ends does not check;
  • a step for a stand-in resumption.

Past the budget, the resolution is left where it stalled. Its $ref reports resolving it further takes more than the 268435456 steps budgeted for chains through referenced documents, in place of the bytes artifact (invalid path -- expected index, got key). The crossing is reported once, at the document, as openapi/budget-exceeded.

#777: the mapping-target charge followed the wrong reading

keysHolding charged a read of raw YAML along nodeview's reading, which keeps the last of two equal keys. The library keeps the first. So a wide mapping under the first of two equal keys was charged as the empty second, and 40,000 targets took 15 s without ever reaching maxMappingWork. mappings.priced replaces keysHolding. It prices each resolved target, and each hop chainEnds reads, which was charged one step whatever its width, with modelCost and cost.

#775: loops

With external references on, loops reads every schema chain of a held source, an internal $ref's included. It is looking for a chain that closes through the source's file name, before the resolver follows it (#768). It read each hop with GetTarget, twice for a tree hop. The first version of this PR then charged maxLoopWork (2²⁸ steps) the keys those reads compared, where main counted hops against maxLoopReads.

That made the bound reachable by valid documents. Past it, a held source's unread chains are left unresolved, since one could close through the held file. Each of these came back with more than 10,000 unresolved-ref errors where main compiles it clean:

loops now reads each hop with navigation.Walk. It reads the raw YAML the model leaves for, or the tree a file-name hop names, with treeReads.read. maxLoopWork is charged that work:

  • a step for each hop and each token;
  • its own steps in raw YAML;
  • each mapping's keys once, when first indexed.

So a hop costs its depth, and a mapping its width once. A read is given the budget left and stops within it, though a mapping it indexes is charged whole.

30,000 ten-schema chains (about 270,000 hops in model positions) stopped main's hop-counting guard with its warning. Here they are read in full, with nothing reported, in 18.0 s against main's 17.6 s. maxHeldRefs derived from maxLoopReads, so it is now stated on its own, at the value it had (2¹⁶).

#776: checkUniqueKeys

checkUniqueKeys compared every pair of a mapping's keys. One pass now finds the same pair: the earliest key written again, and where it is written next.

#778: ModelAt, Scope.At and defs.Reader

resolve.Scope.ModelAt read a pointer whole. For a pointer into an extension, that scanned each raw mapping below, for a node it then discarded. Stepping a token at a time stopped the scans below. But the step that left the model was itself the library's read of the token in the object's own mapping. So a pointer naming one of n top-level x- keys still cost a scan of the root, in ModelAt and in Scope.At alike.

Both now walk through navigation, and stop where a token leaves the model, before any scan. Where a walk leaves at a reference, the reference is passed only if its target holds the token:

  • The walk asked the library, which scans the mapping the target was built from. A $ref's pointer costs the resolver that scan anyway, but the resolver never reads a discriminator mapping value. So n mapping values into one reference's mapping of n keys cost the lowering's typePosition n scans, as main's ModelAt did.
  • Scope.Holds now answers it from the loader's treeReads index. MappingTargets hands the lowering that index, the one the mapping targets were read through, and the mappings pass's own scope asks the same one. A scope without Holds asks the library, as before.

Past an index the library retries, Locate takes what the pointer names from the library's whole read of the rest, as Walk does, which the issue requires of ModelAt for every pointer. No v1.25.2 type is read by index, so no parsed document reaches it.

  • A hoisted $ref read its pointer twice from the root, for ModelAt and for Scope.At. Scope.Locate walks it once for both, and for DeclaredAt. The hoisting and typePosition's first hop use it. lowering.Ctx.At had no caller left; InScope takes the scope Locate answers.

  • defs.Reader read each #/$defs/... pointer whole in the document before any ancestor. An OpenAPI document's $defs is raw YAML, so each local $defs reference cost a scan of the root mapping, for an answer that can be no schema. Its reads now stop where a token leaves the model:

    • at the document;
    • at a position in raw YAML;
    • at a $defs written as raw YAML.

    None of these holds a schema, so no answer changes.

  • ModelAt read / as the entry keyed by the empty string, where the library reads the root.

#779: a step past a struct the model holds by value

The library reads the last token of a field the model holds by value as the value. It reads an earlier one from the field's address, where the type's own methods navigate it. Step read every token as the last. An operation holds its responses by value and answers default only through those methods, so every walk built from single reads stopped there:

  • Scope.At missed a reference under a default response, and read content another document holds as the source's own. This was already in main.
  • defs.Reader, which reads each position from its parent, found no $defs ancestor under a default response. A $ref to a local definition there reported definition not found and lowered to any, and so did a discriminator mapping value naming one. The library resolves both, and a status code's response compiled. This was already in main.
  • ModelAt answered nil there, where the whole read finds the schema. This PR introduced it, and a differential over every position in the corpus found it in the golden petstore spec.
  • modelCost priced the read as ending there.

Step now hands a model it lands on by value on as a pointer to a copy, which every caller walks on from. As in the library, any other struct stays a value. defs.Reader reads each position with Step.

The Scope.At miss showed at load. Take a mapping target under a default response that points into another document. Its content was walked as the source's, so a $ref in it was resolved against the source, and a finding about the source's own schema was reported there. Under a status code, nothing was.

Design notes

  • A mirror of the library's dispatch, rather than its read. The library's own read cannot drift from the resolver's, and every reader here used to make it. But the scan is inside it, at the step that leaves the model, so a reader asking it once per reference pays the square of a wide mapping, wherever it stops after.

    • ReadingOf mirrors only the dispatch for one token, and asks each map through the library's own method. Where it cannot place a token, Walk lets the library read it.
    • Two alternatives lose. Swapping a model's root node out for the read is not reentrant. Keeping the library's read keeps the cost.
    • The mirror is held to the library three ways: a differential on a document built to break each rule; a corpus-wide differential at every position, through every internal $ref's target; and a mutant per rule.
  • loops answers from treeReads.read, not GetTarget. The guard must answer as the resolver does, since a cycle it misses overflows the stack. Three ways out of the refusals lose:

    treeReads already had to end where GetTarget ends for its prices to hold. It is now held there by the fuzz target as well.

  • ends keeps its own GetTarget read (charged), rather than reading through treeReads. ends guards a resumption fix(compilers/openapi): resolve mappings and cross-document refs #763 added, whose work is the library's own resumed reads. The budget bounds those. Crossing it declines the resumption, which leaves the chain failing as it did before fix(compilers/openapi): resolve mappings and cross-document refs #763.

    • ends' read is a constant share of that work. Reading it through treeReads would move the bound, not remove it, and give up the library's own read in a guard. Reading through the model roughly doubled the chains openapi: chains into one wide mapping of another document take quadratic time #773's budget admits, with no change in wall time (6.57 s against 6.30 s at 40,000).
    • loops is the other way round. Its own reads were the only quadratic work its bound refused, and the resolver resolves those chains by itself.
  • Holds is a function the scope takes, not an index it holds. Moving treeReads into navigation would give that package the compiler's YAML helpers, where its rule is that it reaches only the library. The loader already hands the lowering what it resolved as functions (Mapped, Built), and Ends reaches the scope the same way. A scope without Holds answers exactly as before, and only its cost differs.

  • A bound that ignores the path (pointer length × the widest mapping) would need no model. But it charges a read through narrow mappings as if it crossed the widest, so a component file with one large mapping would hit the budget on chains that never touch it.

  • What stays uncharged: the library's own first hop into a document, and a hop that leaves the document. Those are a reference's own read, which nothing charged before fix(compilers/openapi): resolve mappings and cross-document refs #763. A resumed hop the library answers from its object cache is still charged, which overcharges it. A hop the library has resolved once never stalls, so it is never resumed.

Known limits

Test plan

  • navigation:
    • TestWalk_AnswersAsTheWholeReadDoes holds Walk, plus the library's read of what it leaves, to the whole read. Its document puts << decoys at the root and in a schema, and holds value-held fields, maps embedded by pointer and by value, a resolved reference and a key written twice.
    • TestWalk_AnswersAsTheWholeReadDoesAcrossTheCorpus does the same at every corpus position.
    • TestWalk_ScansNoMappingWhereItLeavesTheModel gives each mapping a nil first key, which a scan faults on.
    • TestLeaves_* covers each dispatch path, including a fake model whose by-value map lacks the library's map methods, and a model's core read by tag alone.
    • Also TestWalk_LeavesAnIndexTheLibraryRetriesToIt, TestWalk_ReturnsTheTokensLeftWhereAStepFails, TestTokens_DecodesAsTheLibraryReads and TestStep_*.
  • Corpus differentials: openapitest.Positions gives every walk location and every tree path. For each internal $ref, it adds every path below its target, read through the $ref. Each position is also asked with stray tokens appended.
    • ModelAt and Walk are held to the library's whole read there.
    • Locate's scope is held to At's, with every resolved reference read as one into another document.
  • defs: TestTarget_ReadsPastResponsesTheOperationHoldsByValue, which fails on main, and TestTarget_ReadsNoMappingTheModelLeavesFor.
  • resolve: TestScope_ReadsNoRawMappingAPointerPasses, TestScope_ModelAt_ReadsThePointerAsTheLibraryDoes, TestScope_Locate_ReadsTheScopeAsAtDoes, TestScopeAt_PassesAReferenceTheWalkLeavesTheModelFrom, and the maxRefChain bound for Locate.
    • TestScopeAt_PassesAReferenceTheWalkLeavesTheModelFrom also runs with a Holds that reads the target's mapping as the library does, and with fakes answering the opposite way, which the walk follows.
    • TestScope_Locate_ReadsAnIndexTheLibraryRetriesWhole holds ModelAt to the whole read past a retried index, on a synthetic document.
    • TestScope_Locate_HoldsAnswersAsTheLibraryAtEveryReferenceKind holds a walk asking the loader's Holds to one asking the library, at a reference of each of the nine kinds ReferenceEnd names besides a schema. It asks for tokens the target holds itself, through a merge, escaped or written twice, and for tokens it does not hold. At 6a9f53d, no corpus position left the model at a reference into a token its target holds, so this spec is built for that read. An inverted Holds fails it, and so does one that leaves its token unescaped.
  • Holds wiring: TestMappingTargets_HoldsReadsThroughTheTargetsIndex, TestPositionScope_ReadsThroughTheGivenIndex and TestRefScope_CarriesTheMappingTargetsItWasGiven.
  • loops:
    • TestLoops_AReadCostsItsDepthNotItsWidth reads n $refs into one mapping three ways: by file name, past an extension, and at top-level keys. Doubling n at most doubles the work.
    • TestLoops_ReadAtChargesItsOwnSteps.
    • TestLoops_AReadStopsAtTheBoundItHasLeft.
  • treeReads:
    • TestTreeReads_CountsWhatTheLibraryReadTakes covers 3,200 generated trees × 25 pointers: duplicate keys, alias keys and values, << naming an alias, an inline mapping, a sequence or a scalar, several merges in one mapping, document nodes, odd pairs, escaped, empty and leading-zero tokens, and invalid pointers. cost and read both end where GetTarget ends, and cost counts what a transcription of the library's walk counts.
    • FuzzTreeReads_ReadEndsWhereGetTargetEnds draws the generator's seed. A minute reads about two million trees without a difference.
    • TestTreeReads_ReadsAMergeChainAsDeepAsTheLibrary reads a chain 2^17 merged mappings deep where GetTarget reads it, counted as the transcription counts it, in steps linear in the depth.
    • _PricesAMergeCyclePastTheLimit refuses a hand-built cycle where it closes, and with no limit counts the most an int holds rather than wrapping negative. _ReadsAMergeCycleTheLibraryEnds reads a cyclic tree whose read the library does end.
    • TestTreeReads_HoldsAsTheLibraryReadsOneToken holds holds to GetTarget's read of the one-token pointer on 1,600 generated trees.
    • Before the stack replaced the recursion, a differential held the two together over 1.95 million generated reads, at limits from 0 to unbounded, counting both ways: equal steps and targets, or past the limit on both.
    • Also TestTreeReads_ReadCostsItsDepthNotItsWidth, TestTreeReads_Cost, _StopsPastTheLimit and _IndexesAMappingOnce.
    • modelCost: a refused pointer is free, a decoy merge and an enum member are charged, and nothing is indexed past the limit.
    • TestJoinTokens_KeepsTheBytesTheLibraryReads and TestPartsOf.
  • Merge depth at the CLI, references off and on: 1,100, 3,000 and 9,000 nested inline << merges, and five aliased chains of 9,000 (45,000 merges). Diagnostics and exit code are identical to main's on each, and so is the IR at 9,000 and 45,000. ceb1e00 exits 1 on each with references on. With the alias budget off, 108,000 and 450,000 merges compile in-process with diagnostics identical to main's. d0f84f8 adds a cycle-scan-failed warning to each, and with references on an unresolved-ref.
  • Merge backtracking on a real document: chains whose hop is found only past 60 << keys, each naming one 1,000-key mapping. At 4,000 chains main takes 2.25 s; here the budget stops it at about 1,110 chains, in 1.03 s. At 1,000 chains both resolve everything.
  • openapi: chains into one wide mapping of another document take quadratic time #773: TestSettle_TheResumedWorkIsBounded tries every limit short of the work two resumed chains take. Past it, no chain is resumed, each one left reports the budget at its $ref, and one openapi/budget-exceeded lands at the document. Also TestSettle_AResumedReadIsChargedTheKeysItScans, TestResumeWork_Charge and TestResumable_TheBudgetStopsWhatItWouldResume.
  • openapi: a key written twice in raw YAML steers the mapping-target charge off the scanned mapping #777:
    • TestMappings_AKeyWrittenTwiceIsPricedAsTheLibraryReadsIt and TestMappings_PricedCountsTheLibrarysRead (model, tree and both).
    • TestMappings_TheKeysTheResolverScansAreCharged requires each target charged twice, once by chainEnds and once to resolve it.
    • At the CLI, the duplicated-key layout leaves exactly the targets the plain one does past the bound: 3,632 of 20,000, and 23,633 of 40,000.
  • openapi: one wide extension mapping makes the compile quadratic #776: TestCheckUniqueKeys_NamesTheEarliestKeyWrittenAgain, including a later key repeated first and one repeated last.
  • Mutation sweep of the code since 0f65658, each mutant built before it counted:
    • 19 in navigation, 4 in defs, 9 in resolve and its callers, and 12 in load.
    • All turn a test red.
    • Two tests were added because a mutant first survived: the model read's indexing charge, and a read given the budget left.
    • Since d0f84f8: 15 in the << stack, all red. 12 in the retried read and the Holds wiring: 10 red and 2 equivalent. One equivalent is the guard that keeps the whole read's answer once a step-by-step walk completes, which then ends where the whole read does. The other is retriedRead's error check: the library returns no target with an error. One of the 10 first survived, and its test now builds MappingTargets through the mappings pass.
  • morphic compile on the 154 specs under testdata/, references off and on (308 runs): IR, diagnostics and exit code identical to main, at 6a9f53d.
  • morphic-harness testdata (no panic, invariants, round-trip, determinism, order invariance): output byte-identical to main's.
  • Fuzzing and race checks, all clean:
    • FuzzTreeReads_ReadEndsWhereGetTargetEnds, FuzzCompile and FuzzLowerSchema for 60 s each, FuzzCycleDetector for 45 s, and FuzzInternalPointer_ScanAndLoweringReadFragmentsAlike for 30 s, at 6a9f53d;
    • go test -race on navigation, defs, resolve, load, lowering, schema, openapitest and compilers/openapi.
  • BenchmarkCompile_Petstore, 12 interleaved runs each: a median of 1.57 ms here against 1.63 ms on main, with allocations within 0.3%.
  • go test ./... and make fmt vet lint nolint-grammar nolint coverage-count coverage pass, and the coverage gate finds every statement covered. The full gate is left to CI.

Timings: /usr/bin/time morphic validate root.yaml on macOS, with each generator in its issue. Every column was run at the same time, on builds whose revision go version -m reports, unmodified. 2191ed0 is main before #763, and 0f65658 is this branch before navigation. This branch is 6a9f53d; the two commits after it add a test and change doc comments only.

shape 2191ed0 main (25bdace) 0f65658 this branch
#773: 20,000 chains into one mapping, refs on 2.42 s 6.94 s 3.33 s, bound crossed 3.60 s, bound crossed
#773: 40,000 chains into one mapping, refs on 5.34 s 24.33 s 6.59 s, bound crossed 6.79 s, bound crossed
#773: 40,000 chains, mappings of 100, refs on 3.07 s 4.97 s 5.09 s 5.10 s
#775: 10,000 file-name chains, refs on 4.94 s 3.01 s 2.27 s
#775: 20,000 file-name chains, refs on 13.77 s 3.77 s, 10,811 errors 5.54 s
#776: one extension of 20,000 keys 0.59 s 0.14 s 0.14 s
#776: one extension of 40,000 keys 2.50 s 0.28 s 0.27 s
#777: 40,000 targets, wide mapping under the first of two equal keys 15.23 s, bound not crossed 3.23 s, bound crossed 2.96 s, bound crossed
#778: 40,000 internal $refs into one extension 10.76 s 5.99 s 5.99 s
#778's shape, refs on 14.57 s 4.56 s, 16,841 errors 6.09 s
40,000 $refs to as many top-level keys 15.50 s 15.56 s 7.77 s
the same, refs on 18.77 s 10.57 s, 16,839 errors 9.18 s
20,000 local $defs refs beside 20,000 root keys 8.35 s 8.56 s 4.70 s
40,000 $refs to a default response's properties, compile, instructions 175.4 G 181.0 G 174.6 G
a $ref past 9,000 nested inline << merges, refs on 0.02 s 0.02 s, 1 error 0.02 s
10,000 mapping values into a referenced parameter's extensions 2.41 s 2.72 s 1.59 s

The #773 shapes exit 1 on every build, because the lowering does not lower a $ref into another document (#74). "Bound crossed" there means load left chains unresolved past maxResumeWork, as the known limit counts them. A $defs reference under a default response, with a discriminator mapping value to it: main exits 1 with two unresolved-ref; this branch exits 0, as under "200".

The last two rows have no issue of their own. The first nests 9,000 {<<: …} mappings under x-m around {D: {type: object}}, with one schema $refing #/x-m/D. The second gives a parameter Q 10,000 x-k{i} keys, makes P a $ref to Q, and maps a discriminator's values to #/components/parameters/P/x-k{i}. It exits 1 on every build, with one pass/discriminator-missing-variant per value, since no target is a variant of the base.

With external references allowed, settle resumes a schema chain that
stalled on another document's cached bytes (GitHub #761). The library
finds each key of a pointer by comparing the keys of the mapping that
holds it, in order, so a resumed hop costs the width of every mapping it
passes, and settle read each such hop twice: once in ends, to rule out a
cycle, then in the resumed resolution. Nothing charged that work, so n
chains into one mapping of n keys took time quadratic in n: 40,000 took
23 s where the build before the resumption existed took 5 s.

settle now charges every resumption to a budget of 2^28 steps per
resolution pass, before it reads anything: a step for the resumption,
and for each hop the resumed resolution reads in the document's tree, a
step plus the keys the library's read compares there, once for each time
the hop is read. Past the budget a resolution is left where it stalled,
its $ref reports that the budget stopped it, and the crossing is
reported once, at the document, as openapi/budget-exceeded.

The keys are counted by treeReads, a model of jsonpointer.GetTarget's
walk of a YAML tree that answers each mapping from an index built once,
so pricing a read costs its depth rather than the widths it scans. It
follows the library's reading, not nodeview's: the first of two equal
keys, a key read through an alias, and the `<<` fallback with its
backtracking, since a charge along any other path could be steered past.
GetTarget still makes every read; the model only prices them, and a
test holds it to GetTarget on generated trees.
The library finds a key in raw YAML by comparing a mapping's keys in
order, so a read costs the width of every mapping it passes. Four more
places paid that, or a quadratic of their own, per reference:

- annotation.checkUniqueKeys compared every pair of a mapping's keys, so
  one extension of n keys took n squared steps to compile (GitHub #776).
  One pass now finds the same pair: the earliest key written again.
- mappings charged a read of raw YAML along nodeview's reading, which
  keeps the last of two equal keys where the library keeps the first. A
  wide mapping under the first of two equal keys was charged as the
  empty second, and never reached maxMappingWork (GitHub #777). Targets
  and chain hops are now priced by treeReads, along the library's path.
- loops read each hop in the source's tree twice and charged it as one
  hop, whatever the width (GitHub #775). It now reads once, and charges
  the keys the read compares against maxLoopWork, 2^28 steps. Past it,
  chains are left as they were past the hop bound: unresolved where the
  source is held, with the cycle-scan-failed warning.
- resolve.Scope.ModelAt read a pointer whole, scanning each raw mapping
  below an extension for a node it then discarded (GitHub #778). It now
  walks a token at a time and stops where the model leaves for raw YAML.

maxHeldRefs no longer derives from the loop bound, so it is stated on
its own, at the value it had.
BudgetExceeded's doc listed the budgets openapi.Limits sets, and said
each refuses the document. Load's work budgets report it too, for the
mapping targets and now for resuming chains through other documents,
which no Limits field raises and which leave what they did not reach
unresolved; validation over what references reach stops rather than
refusing. CycleScanFailed's doc named the pre-parse scan and reach, not
load's reading of the chains that name the source by its file name.

maxScanMerges said a read past it refused a resumption, which was true
only while treeReads priced nothing else.
resolve.Step read every token as the second of two, under a map keyed by
the empty string, because the library reads the one-token pointer "/" as
the root. Only the empty token needs that. Any other is now read alone:
the same call, without a map and a second token to parse. ModelAt now
steps every pointer it reads, so on a six-token pointer into the model
this takes it from 16.3 to 14.9 microseconds and from 96 allocations to
68, against 13.5 and 33 for one read of the whole pointer.
The library's pointer walk reads the last token of a field the model
holds by value as the value, and any earlier one from the field's
address, where the type's methods navigate it. resolve.Step reads every
token as the last, so a walk built from steps stopped at an operation's
responses wherever only their methods answer, as for default:

- Scope.passed missed a reference under a default response, so
  Scope.At read content another document holds as the source's own
  (GitHub #779).
- ModelAt, now a walk of steps, answered nil there where reading the
  whole pointer finds the schema. A differential over every position in
  the corpus found it in the golden petstore spec.
- modelCost priced a read below a default response as ending there.

Step now hands such a struct on as a pointer to a copy, which every
caller walks on from. The corpus differential is kept as a test.
A mapping target under an operation's default response, a reference into
another document, made load walk that document's content as the
source's, so a $ref in it was resolved against the source and a finding
about the source's own schema was reported there (GitHub #779). Step's
fix keeps it the other document's; this pins that at load, against the
same document with the response under a status code.
The library reads on from a field's address only where the field holds
a model, which navigates by its core's keys; any other struct value it
reads on from as itself. Step addressed every struct value, so a walk of
steps could navigate one by methods the library would not reach. Only
models are held by value in the model today, so no read changed; Step
now applies the library's own rule.

Also: maxResumeWork's doc names the indexing it charges, and a comment
in checkUniqueKeys says which repeat it keeps.
The ModelAt corpus differential read less than it said. Two of its four
globs matched no file, so a spec at another depth or with a .yml
extension was never read. It re-parsed each file rather than reading
the tree the model was built from, so a stream whose first document is
empty gave it no tree paths. Its depth and count caps were bare
literals that dropped what lay past them without a word. And its oracle,
the whole read ModelAt is held to, was copied into a second package, so
editing one copy would compare the two tests against different readings
with both green.

openapitest now holds the walk once: SpecFiles lists every spec under a
root with the filter internal/harness sweeps with, and Positions reads
the model's own tree, failing past MaxTreeDepth rather than truncating.
The corpus test reads all of testdata through them, and both ModelAt
tests compare against one wholeModelAt.
Step moves from resolve into a package of its own, navigation, which
defs can import as well: defs reads the model a token at a time with a
lone GetTarget, the read Step exists to replace, and it sits below
resolve.

The package also says where the library's read of a pointer leaves the
model. The library reads a token in the mapping an object was built
from when none of the object's fields or embedded maps answers it, and
it finds the key there by comparing the mapping's keys in order. Asked
once per reference, that scan costs the square of a wide mapping, and a
reader stepping through the model paid it even where it then stopped.

- Leaves follows the library's dispatch for one token (getStructTarget,
  navigateModel in v1.25.2): stand-ins are read through, a model's
  embedded maps are asked through their own NavigateWithKey, and its
  core is read by key tag. Where nothing answers, it returns the
  mapping the library would scan, without scanning it.
- Walk steps until a token leaves the model, and hands back that
  mapping with the tokens left, which the library reads together
  there, so a `<<` merge holding a decoy reads as the library reads it.
- Tokens splits a pointer as the library's navigation stack holds it.

Nothing calls them yet. They are held to the library's whole read by a
differential over a document built to break each rule (merge decoys at
the root and in a schema, value-held fields, maps embedded by pointer
and by value, a resolved reference, a key written twice) and over every
position in the corpus, and each rule has a mutant a test reddens.
defs.Reader read the model a token at a time with a lone GetTarget, the
read Step replaced in #779. For the last token of a field the model
holds by value, the library hands back the value, and an operation's
responses answer default only through their pointer's methods. So the
reader found no ancestor at all for a schema under a default response:
a $ref to a local definition there reported "definition not found" and
lowered to any, and a discriminator mapping value to one failed the
same way, where the library resolves both and a status code's response
compiled. Each position is now read with navigation.Step.

The reader also read every pointer whole in the document first. An
OpenAPI document's "$defs" is raw YAML, which the library scans for the
key, so each local $defs reference cost a scan of the document's root
mapping, for an answer that can be no schema. The reads now stop where
navigation.Leaves says a token leaves the model: at the document, at a
position in raw YAML and at a $defs written as raw YAML, none of which
holds a schema, so no answer changes. n local $defs references beside n
root keys, `morphic validate`: 2.93 s to 2.27 s at 10,000, and 7.78 s
to 4.51 s at 20,000.
The library reads an index token on a struct that is no model as a key
first, with the rest of the pointer below it, and tries the index only
once that read fails, so what answers the token turns on the tokens
after it. A walk of single steps reads the key alone and goes on from
whatever it finds, so it could miss what the whole read reaches through
the index. Walk now leaves such a read to the library whole.

No model type in v1.25.2 is read by index, so no read of a parsed
document changes; this keeps Walk equal to the whole read for any type
the library dispatches on, as a test with synthetic types holds it.
The corpus differentials asked about each place the model's walk
reaches and each path of the tree, but never a position past a $ref:
a reference's site holds only the $ref, so no tree path continues
below it, and no stray token appended there named a key its target
holds. A read through a reference, which the library makes by reading
what the reference resolved to, was compared nowhere in the corpus,
though that is where an operation's default response broke the stepped
walk.

Positions now also reads, for each internal $ref the tree writes, every
path below its target from the $ref's own site. The ModelAt and
navigation differentials pass over the added positions unchanged.
ModelAt and Scope.At stop where a pointer leaves the model, but the step
that left it was itself the library's read of the token in the mapping
the object was built from, which scans that mapping. A pointer naming
one of n top-level x- keys cost a scan of the root, so n such $refs cost
n squared in ModelAt and At alone, for a node both then discarded. Both
now walk through navigation and stop where Leaves says a token leaves
the model, before any scan. Where the walk leaves at a reference, it
still asks the library whether the target holds the token, which
decides whether the reference is passed and which the resolver's own
read of the pointer makes too.

A hoisted $ref read its pointer twice from the root, once for ModelAt
and once for At. Scope.Locate walks it once for ModelAt, DeclaredAt and
At together, and the hoisting and typePosition's first hop use it.
lowering.Ctx.At has no caller left, so it is gone; InScope takes the
scope Locate answers.

ModelAt also read "/" as the entry keyed by the empty string, where the
library reads the root, which a schema document's model can answer.

n $refs to n top-level keys, `morphic validate`: 4.71 s on main, 4.65 s
before, 3.04 s now at 20,000; 15.04, 15.20 and 7.64 s at 40,000, the
rest being the library's own read of each $ref. 40,000 $refs to the
properties of a default response's schema, `morphic compile`: 181.1G
instructions before, 175.8G now, 175.4G on main, with identical IR.
With external references on, loops reads every schema $ref's chain, an
internal $ref's included, to find a cycle through the source's file
name before the resolver follows one. It read each hop with GetTarget,
which finds a key in raw YAML by comparing a mapping's keys in order,
and charged maxLoopWork the keys compared. n $refs into one mapping of
n keys therefore crossed the bound near 23,000, and since a chain the
guard cannot read could close through the held source, every $ref past
it was left unresolved: 40,000 $refs into one extension failed with
16,841 unresolved-ref where main compiles them, as did 20,000 file-name
chains (#775's own generator) and 40,000 $refs to top-level keys. The
cost was the guard's reads, not the resolver's.

loops now reads each hop with navigation.Walk, and the raw YAML the
model leaves for, or the tree a file-name hop names, with treeReads.read:
the walk the price followed, answering from its index, so a hop costs
its depth and a mapping its width once. maxLoopWork charges that own
work. A read is given the budget left and stops within it, though a
mapping it indexes is charged whole.

modelCost, which still prices the library's reads of mapping targets,
now prices them exactly: Walk says where the read leaves the model, so a
field holding raw YAML is not charged a scan of its object's mapping, a
decoy `<<` cannot steer the price off the mapping the library scans,
and a pointer the library refuses is free. joinTokens keeps a byte that
is no UTF-8, which jsontext spelled as U+FFFD. Walk now returns the
tokens left at a failed step, so a price can count the steps taken.

`morphic validate --opt allow-external-refs=true`, 40,000 $refs into
one extension: exit 0 in 8.50 s, against 14.86 s on main and 16,841
errors before. #775's generator at 20,000: 5.16 s, against 13.07 s and
10,811 errors.
loops now answers a hop from treeReads' read rather than only pricing
the library's, so a read that ended elsewhere than GetTarget's would be
a chain the guard follows apart from the resolver, a cycle it misses.
The generated differential held both walks to GetTarget on eight seeds;
the fuzz target draws the generator's seed, so the search covers trees
those eight never built. A minute of it reads about two million trees,
25 pointers each, without a difference.
CycleScanFailed's doc named load's chain read among the checks that
report it, as reading the chains that name the source by its file name,
and said the code is never a refusal. The read takes every schema chain
of a held source, to find one that closes through the file name, and
past its bound a $ref it did not read is left unresolved, since its
chain could close through the held file. The doc now says both.
treeReads counted the library's read of raw YAML through a recursive
walk of its `<<` fallback, capped at 1,024 merged mappings along one
pointer, and priced a read past the cap past any limit. With references
on, loops then cut the chain, and a $ref the library resolves was
reported unresolved: a spec with 1,100 nested inline merges, or 45,000
through aliases at the default alias budget, exited 1 where main exits
0. The library has no such cap. The depth it reads is bounded only by
the budgets the source is parsed within, which a caller sets and may
turn off, so no fixed cap could be right.

The fallback now keeps the reads it is inside on a stack instead of the
call stack. Each is pushed at a step, so the read's limit bounds them
as it bounds its steps. A read entering a mapping it is already reading
for the same part would never end, in the library as here. No parsed
tree holds such a cycle, since the pre-parse refusals reject an anchor
naming its own ancestor; a hand-built one is priced past the limit
where it closes.

Below the old cap the counts are unchanged: 1.95 million generated
reads match the recursive walk's steps and targets, or pass the limit
on both. A chain 2^17 mappings deep is read and counted as a
transcription of the library's walk reads and counts it.
The library reads an index token on a struct that is no model as a key
first, with the rest of the pointer below it, and as an index only once
that read fails, so what answers the token turns on the tokens after
it. Walk already leaves such a read to the library. Scope.Locate, which
ModelAt, DeclaredAt and At read through, walked it a step at a time and
read the key alone, so ModelAt could answer nil where the whole read it
replaced finds a schema through the index. ModelAt is meant to answer
as that whole read did for every pointer (#778).

navigation now exports the reading of one token (ReadingOf), and Locate
takes what the pointer names from the library's whole read of the rest
once it meets a retried index. It still notes references a step at a
time, as At's own walk always has.

No model type in v1.25.2 is read by index, so no read of a parsed
document changes. A test with a synthetic document holds ModelAt to the
whole read there.
Where Scope.Locate's walk leaves the model at a reference, the
reference is passed only if its target holds the next token, and the
walk asked the library, whose read compares the keys of the mapping the
target was built from in order. For a $ref the resolver's own read of
the pointer makes the same scan. A discriminator mapping value is never
read by the resolver, so there that scan was the only one: n values
into one reference's mapping of n keys cost n squared steps in the
lowering's typePosition, as in main's ModelAt. A CPU profile of a
10,000-value compile put 0.19 s of the compiler's 0.33 s there.

Scope now takes the question as a function, Holds, the way it already
takes Ends, Mapped and Built. MappingTargets answers it from the
treeReads index the mapping targets were read through, so each mapping
is indexed once, and the mappings pass's own scope asks the same index.
A scope without Holds asks the library as before.

holds is held to GetTarget's read of the one-token pointer across
generated trees, and Holds fakes that answer the opposite way show the
walk passes what Holds says rather than asking the library.
Where a walk leaves the model at a reference, Scope.Holds answers
whether the target's mapping holds the next token, in place of the
library. The corpus differentials never exercise that answer: at
6a9f53d no corpus position left the model at a reference into a token
its target holds, so a Holds that always said no would have passed
them, and one unit test, on a response, was all that said otherwise.

The new test builds a reference of each kind ReferenceEnd names besides
a schema, each to a target holding extensions, merged keys, a key
written twice and keys that need escaping. It holds the walk asking the
loader's Holds to the walk asking the library at every one of them. An
inverted Holds fails it at 101 assertions, and a Holds that leaves its
token unescaped at 18. It shares the corpus test's reading of every
reference as ending in another document.
maxNavigableHops still named Leaves as the reader it bounds, though the
loop it bounds is ReadingOf's now, and treeReads' overview listed its
uses without the one resolve.Scope.Holds makes of it.
@OmarAlJarrah
OmarAlJarrah merged commit c1b58cb into main Oct 8, 2026
1 check passed
@OmarAlJarrah
OmarAlJarrah deleted the fix/openapi-external-hop-cost branch October 8, 2026 01:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment