Skip to content

math: reject unrepresentable Rationals at the codec boundary, not inside Rational - #152

Merged
Yaraslaut merged 1 commit into
masterfrom
rational-predecode-validation
Aug 23, 2026
Merged

math: reject unrepresentable Rationals at the codec boundary, not inside Rational#152
Yaraslaut merged 1 commit into
masterfrom
rational-predecode-validation

Conversation

@Yaraslaut

@Yaraslaut Yaraslaut commented Aug 22, 2026

Copy link
Copy Markdown
Member

Closes #131. Rebased onto master (single commit, no merge commits) after #153 landed.

Rational::setWire clamps what it cannot represent rather than rejecting, so a decode never fails. {"num":5,"den":0,"dp":2} becomes a perfectly plausible 5/1, and every dispatch path decodes before any model-level validate() runs — so nothing downstream can tell the value was altered.

The design, after review

The first version put a process-wide policy and a throwing setWire inside the value type. That was the wrong layer: rejecting malformed wire input is not Rational's job. Removed: WirePolicy, WireValidationError, setWirePolicy/wirePolicy, ScopedWirePolicy, and the throw. setWire is plain noexcept clamping again.

Where the decision went. morph::wire never sees it — an execute envelope's body is an opaque std::string, and wire.hpp says so itself: "payload smuggled inside body is invisible to any structural/depth check". The typed decode happens in ActionTraits<A>::fromJson, the first and only place a Rational inside that body is decoded. That boundary now rejects a clamped payload with ParseError — the error type it already throws for a malformed body. Both fromJson macro expansions carry it.

What Rational keeps is only what it alone knows: Wire::validate() (the representability predicate) and WireClampScope (a scoped clamp count). The type reports the fact; the decoder decides what it means — the same clamp is a protocol violation off a socket and a harmless normalisation from a local caller.

Wire::validate() uses std::in_range<std::int64_t>(detail::absU64(num)) rather than != INT64_MIN: the real requirement is that a component can be negated, i.e. its magnitude fits, which in_range states directly.

Interaction with #153, now that it has landed

They compose cleanly rather than overlapping. #153 made canonicalise() total by clamping an INT64_MIN component; this PR means a wire value carrying one fails Wire::validate() and is rejected before that clamp is reachable from the wire at all. The clamp remains as the backstop for values constructed in code.

Round-trip, per the review

Tested directly: six representable values (including ±INT64_MAX, 1/3 at dp=18, a negative numerator) each written and read back — equality, component-wise identity, and byte-identical re-encoding, so the round trip is a fixed point. A non-canonical-but-representable value (4/81/2) reduces without reporting a clamp, because reduction is not clamping.

Verification

  • Mutation-verified: disabling the boundary check fails 4 assertions.
  • Full suite 20,307 assertions / 1,107 cases against the rebased master.
  • Docs gate (--target doc), gcc -Wall -Wextra -Werror, and clang -Weverything all clean locally — I now run these before pushing rather than relying on CI to find them.

Spec updated in both places the change touches: docs/spec/util/rational.md (shortened per review — problem first, then what the library provides) and docs/spec/core/registry.md (the fromJson boundary rule).

@codecov

codecov Bot commented Aug 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@Yaraslaut Yaraslaut left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ultimately i think this is a wrong place for such addition, this is a responsability of the Wire itself to handle such errors, no the Rational type to provide facilities to work around misuse of the serialization format, make sure that the valid Rational roundripts properly itself using provided formatter, but find out how this messages even arrived through the wire, and still responsability of the wire to handle such issues

Comment thread include/morph/util/rational.hpp
Comment thread docs/spec/util/rational.md Outdated
@Yaraslaut Yaraslaut changed the title math: add a decode policy so Rational can reject clamping wire input (draft) math: reject unrepresentable Rationals at the codec boundary, not inside Rational Aug 22, 2026
…ide Rational

Review feedback: rejecting malformed wire input is not Rational's job. The
type should round-trip what it can represent; handling a payload that
arrived malformed belongs to the layer that decoded it.

Removed from Rational: WirePolicy, WireValidationError, setWirePolicy/
wirePolicy, ScopedWirePolicy, and the throw from setWire. setWire is plain
noexcept clamping again.

What Rational keeps is only what it alone knows:
- Wire::validate(), the representability predicate, written with
  std::in_range over the component magnitudes rather than `!= INT64_MIN`.
  The real requirement is that a component can be negated, which is to say
  its magnitude fits, and in_range says that directly.
- WireClampScope, a scoped count of clamps during a decode.

Tracing where such a payload actually arrives answered where the decision
belongs. morph::wire carries an execute envelope's `body` as an opaque
string and never parses it -- wire.hpp says as much ("payload smuggled
*inside* `body` is invisible to any structural/depth check") -- so
ActionTraits<A>::fromJson is the first and only place a Rational inside
that body is decoded. That is the codec boundary, and it now rejects a
clamped payload with ParseError, the error type it already throws for a
malformed body. Both fromJson macro expansions carry it.

The round-trip property is tested directly: six representable values, each
written and read back, checked for equality, component-wise identity, and
byte-identical re-encoding.

Rebased onto master after #153 landed. The two interact cleanly: #153 made
canonicalise() total by clamping an INT64_MIN component, and a wire value
carrying one now also fails Wire::validate(), so the decode is rejected
before that clamp is reachable from the wire at all.

Mutation-verified: disabling the boundary check fails 4 assertions. Full
suite 20,307 assertions / 1,107 cases against the rebased master; docs gate
and gcc/-Weverything clean locally.

Closes #131

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Yaraslaut
Yaraslaut force-pushed the rational-predecode-validation branch from 9bd07a9 to 2cc26c3 Compare August 23, 2026 06:35
@Yaraslaut
Yaraslaut marked this pull request as ready for review August 23, 2026 06:35
@Yaraslaut
Yaraslaut merged commit 16dfc1b into master Aug 23, 2026
34 checks passed
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.

No pre-decode validation seam for Rational -- setWire clamps hostile wire input instead of rejecting

1 participant