Skip to content

openapi3: compare multipleOf on exact rationals, not fixed-precision decimals - #1252

Merged
fenollp merged 1 commit into
getkin:masterfrom
snowyukitty:fix/multipleof-precision
Aug 19, 2026
Merged

fenollp merged 1 commit into
getkin:masterfrom
snowyukitty:fix/multipleof-precision

Conversation

@snowyukitty

Copy link
Copy Markdown
Contributor

Fixes #1109.

What breaks

multipleOf formats both operands with "%.10f" before parsing them into
big.Rat. That fixed precision is deliberate — #1068 introduced it to absorb
float64 representation error, and #1194 carried it across when decimal128 was
removed — but the tolerance is absolute, so it is only correct inside a narrow
band of magnitudes.

Above roughly 1e6, ten fractional digits behind a large integer part demand
more significant digits than a float64 carries, so the format prints the binary
error rather than the value. 999999999.99 becomes "999999999.9900000095" and
is rejected, even though the reporter's schema declares that same number as its
maximum:

schema := &Schema{Type: &Types{"number"}, MultipleOf: Ptr(0.01),
    Min: Ptr(-999999999.99), Max: Ptr(999999999.99)}
schema.VisitJSON(999999999.99)  // error: number must be a multiple of 0.01

Below roughly 5e-11, every operand formats to "0.0000000000". A value
then becomes a multiple of anything, and a small multipleOf collapses the
denominator to zero, so big.Rat.Quo panics. Validating a document that merely
declares a small multipleOf takes the process down:

var s Schema
json.Unmarshal([]byte(`{"type":"number","multipleOf":1e-12}`), &s)
s.Validate(context.Background())  // no error: nothing rejects this schema
s.VisitJSON(5.0)                  // panic: division by zero

multipleOf: 0 panics the same way, and a negative multipleOf accepted every
number. Schema.Validate has no check that would stop such a document from
loading, so this is reachable from an ordinary specification.

The change

Compare exact rationals instead of formatted decimals. big.Rat.SetFloat64 is
exact, so there is no formatting step, no parse that can fail silently, and no
denominator that can collapse to zero.

The tolerance becomes relative to the value: accept when the quotient is an
integer, or when the nearest candidate multiple sits within |value| * 2^-51 of
the value — a couple of units in the last place.

Measuring against the value rather than against the quotient matters. A
quotient-relative window grows without bound and would pass one half around
2^49, after which every number validates; 2^50 against multipleOf: 3 is a
concrete case, and there is a regression test for it. The candidate multiples are
derived through big.Int, so no quotient can overflow.

Behaviour changes

input before after
999999999.99 with multipleOf: 0.01 rejected accepted
multipleOf below the old decimal grid, e.g. 1e-12, incl. subnormals panic validated
multipleOf: 0 panic multipleOf must be greater than zero
negative multipleOf accepted everything multipleOf must be greater than zero
1e-300 with multipleOf: 0.01 accepted (treated as zero) rejected
1.00000000004 with multipleOf: 1 accepted rejected

The last row is the one worth flagging: the window is now tied to float64
precision rather than to ten decimal places, so a value that differs from a
multiple by more than a few ULP is rejected where the old grid rounded it in.
Computed floats such as 0.1+0.2 against multipleOf: 0.1 are unaffected.

One limit is inherent rather than chosen. When multipleOf is smaller than the
value's own ULP — 1e16 against multipleOf: 3, where consecutive float64
values are 2 apart — the question is not decidable from the stored number, and
the check accepts. No relative tolerance can both absorb one ULP at 0.3 and
reject a one-unit gap at 1e16.

Two cases are deliberately left alone, with comments saying why: a non-finite
multipleOf is treated as no constraint, and a non-finite value keeps its
current outcome through the exported VisitJSONNumber. SchemaError.Error()
marshals both the value and the schema as JSON, and NaN has no JSON encoding, so
reporting either would panic while formatting the error — a worse outcome than
what is there now. Making SchemaError renderable for non-finite input is a
separate change. Note that VisitJSON already rejects non-finite input before
any keyword runs; only the exported number entry point reaches this code.

Testing

TestIssue817 gains the values from the report. A new
TestSchemaMultipleOfAcrossMagnitudes covers the invariant by scenario: large
magnitudes, preserved tolerance for computed floats, a multipleOf smaller than
the value's precision including a subnormal, a value far below the multipleOf,
zero, non-finite input, multipleOf bounds, a no-panic case for every previously
crashing input, and an assertion that the failure still names multipleOf in
SchemaError.SchemaField.

The computed-float cases go through a //go:noinline helper on purpose. Written
as a literal, 0.1+0.2 is folded exactly at compile time and the case would
assert nothing.

go test ./openapi3/, go vet ./..., and gofmt -l . are clean.
go test ./... is clean except openapi2conv/TestIssue440, which fails
identically on an unpatched checkout of the same base
(invalid character '.' looking for beginning of value) and is unrelated.

…decimals

Both sides of the multipleOf check were formatted with "%.10f" before being
parsed into big.Rat. That fixed precision is deliberate — getkin#1068 introduced it to
absorb float64 representation error and getkin#1194 carried it across when decimal128
was removed — but the tolerance is absolute, so it is only correct inside a
narrow band of magnitudes.

Above roughly 1e6 the format demands more significant digits than a float64
carries, so it prints the binary error instead of the value: 999999999.99 becomes
"999999999.9900000095" and is rejected even though the same schema declares it as
the maximum. Below roughly 5e-11 every operand formats to "0.0000000000", which
makes any value a multiple of anything and turns a small multipleOf into a zero
denominator, so validating a document that merely declares multipleOf: 1e-12
panics with a division by zero. multipleOf: 0 and a negative multipleOf reach the
same code and were never rejected.

Compare exact rationals instead. big.Rat.SetFloat64 is exact, so there is no
formatting step, no parse that can silently fail, and no denominator that can
collapse. The tolerance becomes relative to the value: accept when the quotient
is an integer, or when the nearest candidate multiple sits within |value| * 2^-51
of the value, which is a couple of units in the last place. Measuring against the
value rather than against the quotient matters — a quotient-relative window grows
without bound and would exceed one half around 2^49, accepting everything — and
the candidate multiples are derived through big.Int so no quotient can overflow.

Behaviour changes, all covered by tests: large magnitudes now validate; a
multipleOf below the old decimal grid, including subnormals, now validates
instead of panicking; multipleOf: 0 and negatives are reported instead of
panicking or accepting everything; a value far below the multipleOf, such as
1e-300 against multipleOf: 0.01, is now rejected rather than treated as zero; and
the window is now tied to float64 precision rather than to ten decimal places, so
a value that differs from a multiple by more than a few ULP, such as 1.00000000004
against multipleOf: 1, is now rejected where the old grid rounded it in. Computed
floats such as 0.1+0.2 against multipleOf: 0.1 are unaffected, which is why the
new tests route them through a //go:noinline helper: written as a literal the
compiler folds the expression exactly and the case would test nothing.

Two paths are deliberately left alone because SchemaError renders the schema and
the value as JSON and NaN has no JSON encoding, so reporting them would panic
while formatting the error. A non-finite multipleOf is treated as no constraint,
and a non-finite value keeps its current outcome. Both are noted in comments.

Fixes getkin#1109
@fenollp
fenollp merged commit 94fcebf into getkin:master Aug 19, 2026
5 checks passed
@fenollp

fenollp commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Thank you very much!

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.

MulipleOf Validation is failing for numbers 999999999.99, 999999999.90

2 participants