Exact-rational evaluation¶
A KerML Rational is a rational number, and OpenSysML evaluates it as one: 0.1 + 0.2 == 0.3
is true, 1 / 3 is the Rational 1/3, RationalFunctions::numer(RationalFunctions::rat(1, 3))
is 1. Real stays IEEE 754 binary64. This record derives that behavior from the
specification, states the choices the specification leaves to an implementation and how each was
made, and keeps the probes of the pinned pilot, which evaluates a Rational as a Java double and
so differs from this implementation by design.
An earlier version of this record adjudicated the change and declined it, on the grounds that the
specification is silent on precision and the pilot computes in binary64. That decision is
superseded: the specification is not silent on what a Rational is, and the pilot's binary64 is
an implementation artifact, not a reading of the text. The pilot's answers are kept below as the
documented divergence.
What the specification requires¶
KerML 1.0 (formal/2025-02-01) and the Kernel libraries vendored under internal/workspace/libs:
- A Rational is a rational number. §9.3.2.2.8: "Rational is the type of rational numbers,
extended with values for positive and negative infinity." §9.3.2.2.9: "Real is the type of
mathematical (extended) real numbers. This includes both rational and irrational numbers".
ScalarValues.kermlorders themNatural :> Integer :> Rational :> Real :> Complex. - A decimal literal is a Rational. §8.3.4.8.13
LiteralRational:value : Real, "the value whose rational approximation is the result of evaluating this LiteralRational"; §8.4.4.9.2: "only the rational-number subset of the real numbers can be represented using a finite literal. So the result of a LiteralRational is actually always classified in the KerML DataType Rational." A finite decimal —0.1,1.5e3,25E-3— names a rational exactly (1/10, 1500, 1/40), so the rational approximation of the value it spells is that value: the literal evaluates to it, not to a binary64 near it. - The library declares which operations stay Rational.
RationalFunctions.kerml(§9.4.10):rat(numer: Integer, denum: Integer): Rational,numer/denom(rat: Rational): Integer,abs,'+','-','*','/',max,min,sum,productover Rationals returningRational;'<','>','<=','>=','=='returningBoolean;gcd,floor,round,ToIntegerreturningInteger;ToStringreturningString;ToRational(x: String): Rational.IntegerFunctions.kerml(§9.4.11):'/'(x: Integer, y: Integer): Rational— an Integer quotient is a Rational — while'+','-','*','%','**','^'(Natural exponent) returnInteger. - Classification follows the declared result, not the number held. A function's result is
classified by its declared return type (§8.4.4.9.2's rule for
LiteralRational, applied to the library signatures above). So a whole-valued result of Rational arithmetic is still aRational—6 / 3is the Rational2,6 / 3 istype Integerisfalse— and only an operation declared to returnInteger(floor,round,numer,denom,gcd, Integer+ - * % **) yields one. - Real operations have no Rational result.
sqrt, the trigonometric functions,exp,lnand every functionRealFunctionsalone declares returnReal; their results are in general irrational, so no Rational value is available to return.RationalFunctions::'**'declaresy: Rationalandreturn : Rational, but(2 / 3) ** 0.5is irrational: the declaration can hold only for an Integer exponent, which is what is evaluated exactly; any other exponent is theRealFunctions::'**'the declaration specializes. - Infinity. §9.3.2.2.8 extends Rational with the two infinities. KerML's only notation for an
infinite value is
LiteralInfinity(*, §8.4.4.6, typedPositive); its existing rules apply unchanged to Rationals —*exceeds every finite Rational (1 / 3 < *), equals itself, and any arithmetic over it is a typed error. No finite Rational operation produces an infinity: a zero divisor is a division-by-zero error.
What the specification is silent on, and so is decided here rather than derived: the internal
representation, any bound on a Rational's size, the precision of Real (the text describes the
mathematical reals; it has no conformance clause on precision), how a Rational meets a Real that
an implementation holds approximately, and the text a Rational prints as. UML, fUML and PSSM say
nothing about KerML Rationals and were not consulted.
What is implemented¶
- Representation (
internal/semantic/semantics/rational.go).ValRationalis held exactly, in lowest terms with a positive denominator. A Rational whose terms fitint64(denominator within 32 bits) lives inline inValue; any other in an immutablebig.Rat, so each Rational has one representation andValuestays within its 64-byte bound. Integer staysValInt(IntValue/BigIntValue). - Literals.
0.1,.5,1.5e3,25E-3,1.0e400parse exactly (semantics.ParseRational); an integer literal stays an Integer. A literal declaredReal— the value of an attribute typedReal— is held as the binary64 nearest it, which is that declaration's boundary (below). - Arithmetic (
semantics.RatArith,RatPow,RatNeg,RatAbs).+ - * /over Integer and Rational operands are exact; Integer/is the exact Rational quotient (1 / 3is1/3, which replaces the once-roundedIntQuotient);**and^with an Integer exponent are exact, a negative exponent inverting ((2 / 3) ** -2is2.25);0 ** -1is a domain error. - Comparison and equality (
semantics.CompareRat,CompareReal) are exact between exact values. Between a Rational and a binary64 Real they are at Real precision, the Rational rounded once; see the rule below. - Library functions (
internal/exec/runtime/library_conversions.go,library_functions.go).rat,numer,denom,gcd,abs,floor,round,max,min,sum,product,ToString,ToRational,ToIntegercompute exactly over exact operands;RationalFunctions::sum((0.1, 0.2))is0.3. TheRealFunctionsversions takeRealoperands, soRealFunctions::sum((0.1, 0.2))is binary640.30000000000000004. A finite binary64 Real passed to a Rational function is the rational number that double is — every finite binary64 is one — so forx : Real = 0.1,numer(x)is3602879701896397: the declaration, not the function, rounded. - Size budget. A Rational result whose numerator and denominator together need more bits than
the numeric size budget (
DefaultMaxIntegerBits, shared with Integers, settable per run) issemantics.ErrRationalSizeLimit, never rounded. A power is refused from a lower bound before it is computed. A long accumulation of distinct fractions — a harmonic sum — therefore stops with a typed error instead of growing without bound (TestRuntimeRobustnessExactRational). - Printing (
Value.FormatRational). A Rational whose denominator has no prime factor other than 2 and 5 is a terminating decimal and prints as one, in the layout a Real prints in (0.3,2.0,1.5e-05,1e+400); any other prints asnumer/denom(1/3,25/12). So every value that was already a binary fraction (0.5,1.5,2.0) prints as before, and REPL, JSON and trace output change only where the old output was a rounding. - Quantities and units (
semantics/units.go,quantity_eval.go). A quantity's magnitude is whatever number it holds, exact or binary64. Unit scale factors are ratios, composed exactly (Scale.Times,DividedBy,Pow) while their terms are whole and within 2^53, so0.1 [m] + 1 [mm]is exactly0.101 [m]and1 [km] / 3is1/3 [km]; a scale beyond that, or a conversion through a binary64 magnitude, is binary64 as before. - The Real boundary (
semantics.RealOf). A value written to a feature typedReal, the operands ofRealFunctions, and a Rational meeting a binary64 Real in arithmetic are converted to the nearest binary64 once. A nonzero Rational below the least positive binary64 is an overflow error rather than a silent zero.
Every boundary¶
- gRPC (
api/proto/sysml.proto,internal/frontend/protoconv,internal/frontend/grpc). The service answers a Rational binary64 holds exactly as the existingreal_value/real_magnitude, so old clients see what they saw. Any other crosses as the additiveRationalmessage (numerator and denominator as decimal text) inValue,QuantityandDocumentValue, negotiated by therational_valuescapability exactly asbig_int_valuesnegotiatesbig_int_value: a service that does not advertise it answers such a Rational as an unsupported value naming the capability, refuses a request or document query carrying or answering one withUNIMPLEMENTED, and never sends a nearest double. An answered Rational is canonical (positive denominator, lowest terms, not a binary64); an inbound one need only be in lowest terms over a positive denominator, and an inboundreal_valueis a binary64 Real (see the wire rule). - JSON (
internal/frontend/engine,internal/frontend/core):"rationalValue": {"numerator": "1", "denominator": "3"}and"rationalMagnitude", with the same canonical rule (wire contract). - Clients. Go:
opensysml.Rationalover*big.Rat. Python:fractions.Fraction, a binary64-exact Rational arriving asfloat; generated typed classes declare aRationalfeatureFractionand decode it exactly (as_rational). Node:{kind: "rational", numerator: bigint, denominator: bigint}withrational(),formatRational(),rationalToNumber()andrationalOfDouble(); generated classes declareRationalValue(asRational). Java and Rust carry an exact numerator/denominator pair, JuliaRational{BigInt}, MATLAB a struct of decimal strings. Each sends every exact Rational asrational_valueunderrational_values, rewrites one a double holds toreal_valuefor a service without it and refuses any other, and refuses an answered Rational that is not canonical. - RDF (
internal/translate/export/rdf_expr.go). A literal is written as the exact Rational it denotes: a decimal token asxsd:decimal, an exponent token binary64 holds exactly asxsd:double, any other exponent token as its exactxsd:decimal(1E-1→"0.1"^^xsd:decimal). An importedxsd:double/xsd:floatis the binary value it names. The round trip is exact. - SMT (
internal/exec/solve). The encoding was always exact; now the evaluator is too for Integer and Rational arithmetic, so replay (solve/replay.go) replays it exactly and only arithmetic a binary64 Real takes part in is replayed, and marked rounded (Query.Rounded,RoundingSound), as binary64. A declaredRealvariable is binary64 (Var.Binary64). - Compiled calcs (
internal/translate/codegen/rational.go,sysml -compile). Compiled code holds numbers asint64and binary64, so it compiles exact Rational arithmetic only where one binary64 rounding gives the exact answer: constants are folded exactly, a single operation over values binary64 holds exactly is compiled with guards, an Integer quotient compared with a whole number is compared exactly. Anything else — aRationalparameter,a * 0.1over a variablea,(a * 0.5) ** 2,a / 3 < 0.1, collection operations over exact Rationals — is refused at compile time with a typed error naming the construct, never compiled to a rounding.
Comparing a Rational with a binary64 Real¶
With exact Rationals and binary64 Reals, attribute x : Real = 0.1; x == 0.1 meets the double
nearest 1/10 (held by x, since a Real declaration rounds) with 1/10 itself. KerML has Rational
⊂ Real (§9.3.2.2.8) and so asks for the mathematical comparison of two reals; but the binary64
Real is this implementation's approximation (§9.3.2.2.9 leaves precision open), not the text's,
and the text does not say how an approximate Real meets an exact Rational. The rule is therefore
tool-defined, and chosen so the approximation does not give a wrong answer elsewhere:
- A Rational meeting a binary64 Real is rounded once, to the nearest double, before it is
compared (
semantics.CompareReal):==,!=,<,<=,>,>=between them compareRealOf(rational)with the Real. Sox == 0.1istrue,x : Real = 1 / 3; x == 1 / 3istrue,rat(1, 3) == 1.0 / 3.0istrue(1.0 / 3.0is exact1/3too) andrat(1, 4) == 0.25istrue. A Rational whose magnitude no finite double holds compares as the infinity of its sign; a NaN compares as it does with any number (unordered,!=true). - This is the rule mixed arithmetic already follows (the Rational is rounded once where a
binary64 Real takes part), and the one a binding needs: KerML §7.4.9 asserts that both ends of
a binding connector hold equal values, so a Rational bound to a
Realfeature — rounded by the declaration — must equal the Rational it was bound from. Exact comparison would make that binding's own equalityfalse, a wrong answer rather than a choice (instance_real_binding_meets_rational). - Rational with Rational stays exact (
0.1 + 0.2 == 0.3istrue), and so does Integer with anything: an Integer keeps its kind in aRealfeature, soCompareIntRealcompares a large Integer with a Real exactly as before. - Equality of values (sets,
includes, query==, solver witness replay, compiled==) uses the same rule, so a Rational and a Real that compare equal are one member of a set.
A long Real-typed accumulation of a decimal literal (x := x + 0.01 a thousand times, x :
Real) is binary64 at every step and so never reaches ErrRationalSizeLimit
(action_real_accumulation_stays_binary64, and TestRuntimeRobustnessRationalRealComparison
under a lowered budget); the same accumulation over exact Rationals is exact and stays within the
budget only while its denominators do.
A Rational a double holds, on input¶
The service answers a Rational binary64 holds exactly (1/4) as real_value, so its answers
never spell one number two ways. KerML says nothing about a wire, so how a client tells the
service that a 0.25 it sends is the Rational rather than the Real is tool-defined:
- Clients send every exact Rational as
rational_valueto a service that advertisesrational_values, one a double holds included (Fraction(1, 4),1//4,Rational.of(1, 4)). - The service accepts a binary64-exact
rational_valueon input as that exact Rational (protoconv.LowestTermsRational; still refused unless in lowest terms over a positive denominator), while every answer stays canonical (CanonicalRational):in x : Rational; x + 1 / 3withxsent as therational_value1/4is exact7/12. - An inbound
real_valueis always a Real: the samexsent asreal_value0.25is binary640.5833333333333333. The declaration does not reinterpret it. - Runtime semantics do not depend on the encoding beyond that: once read, an exact Rational is the same value whether it arrived on the wire or was written in the model.
- To a service without
rational_values, which would read the arm as unknown, a client sends a Rational a double holds exactly as thatreal_value— the only form such a service reads — and refuses any other Rational before the call.
Both directions are covered by the gRPC conformance cases (execute_action_rational_input_meets_
rational, execute_action_real_input_meets_rational, evaluate_calc_rational_wire,
evaluate_calc_rational_wire_canonical_answer), internal/frontend/grpc/convert_rational_test.go
and each client's tests (client/python/tests/test_rational.py against a live service).
The pilot differs by design¶
The pinned pilot (jupyter-sysml-kernel, provisioned by scripts/download-pilot-validator.sh +
scripts/download-pilot-evaluator.sh) holds a LiteralRational as a Java double
(LiteralRationalImpl.value: protected double value). Its answers, verbatim, beside ours:
| Case | Pilot answer | OpenSysML |
|---|---|---|
0.1 + 0.2 |
LiteralRational 0.30000000000000004 |
0.3 |
0.1 + 0.2 == 0.3 |
LiteralBoolean false |
true |
0.1 + 0.2 <= 0.3 |
LiteralBoolean false |
true |
0.3 < 0.1 + 0.2 |
LiteralBoolean true |
false |
0.1 + 0.2 - 0.3 |
LiteralRational 5.551115123125783E-17 |
0.0 |
(1.0 / 49.0) * 49.0 == 1.0 |
LiteralBoolean false |
true |
(1.0 / 3.0) * 3.0 == 1.0 |
LiteralBoolean true (double rounding happens to land on 1.0) |
true |
0.1 ten-fold sum == 1.0 |
LiteralBoolean false |
true |
1.0 / 3.0, 1 / 3 |
LiteralRational 0.3333333333333333 |
1/3 |
Every pilot row is the binary64 answer, 5.551115123125783E-17 being exactly the double 0.1 +
0.2 - 0.3; every OpenSysML row is the rational one §9.3.2.2.8 and §8.4.4.9.2 define. (The pilot's
integer literals are narrower still: 9007199254740993 answers ERROR:For input string:
"9007199254740993", a 32-bit parseInt.) The probes are committed as
tools/referee/exec/testdata/cases/exact_rationals.cases, marked by-design with those clauses:
the five whose answers differ land in the execution referee's
differs-by-design bucket, the clause recorded beside both raw outputs, and the other five agree
(the referee compares reals to two decimal places). None is hidden or normalized away.
RationalFunctions::rat, numer and denom¶
rat(numer: Integer, denum: Integer): Rational, numer(rat: Rational): Integer and
denom(rat: Rational): Integer presuppose an exact ratio, and now have one:
rat(n, d)is the Rationaln/din lowest terms, the valuen / dcomputes:rat(1, 3)is1/3,rat(6, 4)is1.5,rat(1, 0)is the typed division-by-zero error1 / 0reports, never an infinity. The library'sRationalFunctions::sum/productbodies start fromrat(0, 1)/rat(1, 1).numer(x)anddenom(x)are the numerator and positive denominator ofxin lowest terms; an Integer is itself over1.numer(0.1)is1anddenom(0.1)10,numer(rat(1, 3))is1anddenom(1.0 / 3.0)3,denom(0.0001)is10000, andrat(numer(x), denom(x)) == xholds for every finitex. A binary64 Real argument is the Rational it is exactly (big.Rat.SetFloat64); an infinity or NaN has no ratio and issemantics.ErrArithmeticDomain.
The pilot implements none of the three. Each call, qualified at the prompt and as an attribute's value, answers the unevaluated invocation (UUIDs elided):
| Case | Pilot answer |
|---|---|
RationalFunctions::rat(1, 3) |
InvocationExpression rat |
RationalFunctions::rat(6, 4) |
InvocationExpression rat |
RationalFunctions::rat(1, 0) |
InvocationExpression rat |
RationalFunctions::numer(0.75), denom(0.75) |
InvocationExpression numer, InvocationExpression denom |
RationalFunctions::numer(RationalFunctions::rat(6, 4)) |
InvocationExpression numer |
RationalFunctions::denom(1.0 / 3.0) |
InvocationExpression denom |
RationalFunctions::numer(2), denom(2) |
InvocationExpression numer, InvocationExpression denom |
RationalFunctions::numer(0.1), denom(0.1) |
InvocationExpression numer, InvocationExpression denom |
RationalFunctions::rat(1, 3) == 1.0 / 3.0 |
LiteralBoolean false |
RationalFunctions::rat(1, 3) == RationalFunctions::rat(1, 3) |
LiteralBoolean false |
RationalFunctions::rat(1, 3) != RationalFunctions::rat(1, 3) |
LiteralBoolean true |
6 / 4 (operator syntax) |
LiteralRational 1.5 |
1 / 0 |
no output |
The ==/!= rows are not verdicts: the pilot compares two unevaluated invocation nodes
and finds them unequal, so rat(1, 3) is "not equal" even to itself. That is the same
unevaluated-operand artifact w6d:complex-is-zero-qualified records in the
execution referee. RationalFunctions::abs, floor and
'/' called by name are unevaluated too; only operator syntax folds. So the pilot cannot
referee any of the three, and the semantics are self-assessed. The
probes are committed as tools/referee/exec/testdata/cases/rational_terms.cases, where
every call lands in pilot-unevaluated and the operator quotient agrees.
Cost¶
Small Rationals are held inline — an int64 numerator over a uint32 denominator in the
Value itself — and only terms beyond that use a normalized math/big.Rat, so the Value stays
32 bytes and ordinary arithmetic allocates nothing it did not allocate as binary64. Literals, a
double converted exactly, and Integer arithmetic beyond int64 take the same allocation-free paths.
Interleaved runs on one machine (8 cores, six samples each side, benchstat), against the
binary64 implementation:
| Benchmark | Time | Bytes/op | Allocs/op |
|---|---|---|---|
internal/exec/runtime, all seven existing benchmarks |
geomean +2.1%, no significant change | geomean +3.5% | unchanged |
IntegerArithmeticLoopBeyondInt64 |
no significant change | +27% (227.6 KiB → 290.1 KiB) | unchanged |
tests/perf (REPLEvalExpr, ExecuteAction, BatchConstraints, SameConstraintManyInstances, BatchSatisfy, Instantiate, GRPCEvaluate) |
no significant change on any | at most +2.8% (GRPCEvaluate) |
unchanged |
RationalDecimalLoop (1,000 steps of exact decimal arithmetic) |
no significant change | unchanged | unchanged |
RealDecimalLoop (the same loop over a feature declared Real) |
no significant change | unchanged | unchanged |
RationalHarmonicLoop (200 exact terms of 1/i) |
+47% to +55% | ×4.3 | +69% |
go test ./internal/exec/runtime -run '^$' -bench . -benchmem -count=6
go test ./tests/perf -run '^$' -bench 'Instantiate|BatchConstraints|BatchSatisfy|ExecuteAction$|GRPCEvaluate|REPLEvalExpr|SameConstraint' -benchtime=20x -benchmem -count=6
An Integer beyond int64 is the numerator of a big.Rat over one, which is the byte increase in
IntegerArithmeticLoopBeyondInt64. The harmonic sum is what exactness costs where it is real:
its denominator outgrows int64 within a few dozen terms and reaches hundreds of bits, where
binary64 kept 53. An accumulation whose terms keep growing is stopped by
ErrRationalSizeLimit at the configured size budget rather than slowing without bound.
The rounded-query census¶
The solver reasons over SMT-LIB's exact Real sort, so a query is marked rounded
(Query.Rounded) where the evaluator rounds: an exact-real unsat about it is reported
undecided, and %configure all and %optimize decline the completeness claim (see
spec-compliance).
With exact Rationals only arithmetic a binary64 Real takes part in still rounds.
TestRoundedCensus (internal/exec/solve/rounded_census_test.go) translates every constraint,
requirement, satisfaction assertion and analysis case in the repository's solver-facing corpora
through the path %check/%solve/%configure all/%optimize use, and counts the marked ones:
OPENSYSML_SMT=/usr/bin/z3 go test -count=1 -run TestRoundedCensus -v ./internal/exec/solve
OPENSYSML_SMT=/usr/local/bin/cvc5 go test -count=1 -run TestRoundedCensus -v ./internal/exec/solve
| Corpus | Files | Translated queries | Rounded, binary64 Rationals | Rounded, exact Rationals |
|---|---|---|---|---|
| Training corpus | 100 | 18 | 3 | 0 |
| Pilot corpora | 213 | 95 | 9 | 2 |
| Conformance fixtures | 1,361 | 17 | 0 | 0 |
| Examples + manual | 53 | 177 | 17 | 3 |
| Solver fixtures | 10 | 65 | 7 | 1 |
| Standard library | 107 | 203 | 0 | 0 |
| Total | 1,844 | 575 | 36 | 6 |
Both solvers give the same counts. Thirty of the 36 queries a binary64 Rational marked were
rounded only by a decimal literal or an Integer quotient, which is now exact, so their unsat
verdicts are reported as verdicts. The six left are genuinely binary64 — arithmetic over a
feature declared Real (powerMargin in examples/verdicts-demo/rover.sysml, pwr and acc in
the pilot's HSUVDynamics.sysml) or a quotient of one (MassPerCrate in the solver fixtures) —
and none of them is provably exact.