Skip to content

Commit 080efac

Browse files
celtolean: compare float fields at CEL's double precision
A proto `float` is Lean Float32, but CEL converts it to a double before any numeric operation. Elaborating the literal at Float32 instead was not merely imprecise: `this == 1.1` on a `float` field is satisfiable in Float32 (by the Float32 nearest 1.1) and unsatisfiable in CEL, so the kernel could prove a proposition whose CEL evaluation is false. A typed `float` value now widens to Float wherever it meets a literal, another numeric field or arithmetic. Float32 → Float is exact, so widening only removes the rounding. `float`/`double` comparisons unify for the same reason. The Float32 literal-magnitude check stays as a backstop for any unwidened path, but is no longer the guarantee — `this < 1e40` is now emitted exactly rather than rejected. Standard `float.*` rules stay narrow deliberately: their bounds come from the descriptor and are already exactly Float32 values, so the comparison cannot round either way.
1 parent bc8e825 commit 080efac

7 files changed

Lines changed: 119 additions & 49 deletions

File tree

‎README.md‎

Lines changed: 37 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -282,10 +282,11 @@ proposition whose CEL evaluation would have errored:
282282
under `Cel.mulOk x.toInt64 100` — so the guard is exactly CEL's condition
283283
and an intermediate outside the 32-bit range is not an overflow, matching
284284
CEL. Division and modulo widen too (fixing `int32Min / -1`, which wraps in
285-
`Int32` but not in CEL). Without that typing (values bound by a
286-
comprehension variable, elements reached through indexing) the guard falls
287-
back to the operand's own Lean type, so `Int32`/`UInt32` operands demand the
288-
32-bit range — conservatively stricter than CEL, sound but not exact.
285+
`Int32` but not in CEL). Comprehension binders and indexed elements are typed
286+
too (see below), so this reaches inside `all`/`exists` bodies; only values
287+
with no proto type at all (free identifiers) fall back to the operand's own
288+
Lean type, where `Int32`/`UInt32` operands demand the 32-bit range —
289+
conservatively stricter than CEL, sound but not exact.
289290
`Nat` size arithmetic guards truncating subtraction. Constant arithmetic
290291
folds and range-checks at generation time (an always-erroring rule is
291292
rejected); concatenation and timestamp/duration arithmetic are total and
@@ -298,15 +299,18 @@ proposition whose CEL evaluation would have errored:
298299
scalar field rule, `this`-rooted select paths in message rules (including
299300
map-selection leaves and enum ints, which CEL compares as `int32`), and
300301
custom CEL on `repeated.items`/`map.keys`/`map.values` elements; it also
301-
rejects negative literals against unsigned fields and `double` literals
302-
outside `Float32`'s finite range on a `float` field. Literals meeting a
302+
rejects negative literals against unsigned fields. Literals meeting a
303303
*widened* arithmetic result are checked at 64-bit width instead, so
304304
`this * 10000 <= 5000000000` on an `int32` field is accepted — the product
305305
is `Int64`. The same typing makes mixed-width comparisons elaborate
306306
(`this.small < this.big` across `int32`/`int64` widens the narrower side).
307-
Values with no resolvable proto type — comprehension binders
308-
(`this.all(v, v > 3000000000)`), indexed elements, free identifiers — are
309-
*not* checked and can still wrap.
307+
- **Container elements are typed**: a path may descend into a container, so
308+
the value a comprehension binds (`this.all(v, …)` — a repeated field's
309+
elements, a map's keys) and the value indexing yields (`this.items[0]` — an
310+
element, a map's value) carry the same descriptor typing a named field does.
311+
Paths compose through binders, so `this.all(o, o.lots.all(n, n > 3e9))` is
312+
range-checked at the innermost element's type. This closes the last
313+
silent-wrap corner: only free identifiers now go untyped.
310314
- **Indexing**: CEL errors on an out-of-range index or missing map key, so
311315
`l[i]` / `m['k']` translate to `(l[i]?).get h` under a pending
312316
`(l[i]?).isSome` guard rather than a panicking `l[i]!`. Guards discharge at
@@ -320,20 +324,30 @@ proposition whose CEL evaluation would have errored:
320324
- **Enums work as ints**: the plugin resolves every `this` path against the
321325
descriptors, so enum-typed values (field rules, message-rule leaves,
322326
repeated/map element rules) render through the generated enum's `.toInt32`
323-
view, matching CEL's enum-as-int semantics. Standard enum rules keep their
324-
constructor-test form. (Enum values reached through *indexing* — an
325-
enum-valued `m['k']` compared to an int — still fail at Lean elaboration.)
327+
view, matching CEL's enum-as-int semantics — including enum values a
328+
comprehension binds or indexing reaches (`this.tier_history[0] >= 1`), under
329+
the index guard. Standard enum rules keep their constructor-test form.
326330
- **Map selection sugar works**: `this.labels.priority` becomes guarded key
327331
indexing (missing key ⇒ false, CEL's error), and `has(this.labels.priority)`
328332
becomes key presence — at any path depth.
329333
- Warnings (stderr / JSON) flag translations with caveats: `has()` presence
330334
semantics beyond `Option` fields, evaluation-time-dependent `now`,
331335
placeholder timestamp/duration types, free identifiers kept verbatim,
332336
non-literal regex patterns.
333-
- `string(e)` uses `toString`, whose formatting may diverge from CEL's for
334-
floats. `float` fields compare at `Float32` precision where CEL compares at
335-
double precision, so a `double` literal that is not exactly representable in
336-
`Float32` rounds (its *magnitude* is range-checked, its precision is not).
337+
- **`float` fields are compared at double precision**, as CEL does. A proto
338+
`float` is Lean `Float32`, but CEL converts it to a double before any numeric
339+
operation, so a typed `float` value is emitted as `x.toFloat` wherever it
340+
meets a literal, another numeric field, or arithmetic (`this <= 1.1` on a
341+
`float` field becomes `x.toFloat ≤ 1.1`). Widening Float32 → Float is exact,
342+
so this only removes a rounding: without it the literal would elaborate at
343+
`Float32` and `this == 1.1` would be *satisfiable* in Lean (by the `Float32`
344+
nearest 1.1) while being unsatisfiable in CEL — the kernel proving a
345+
proposition whose CEL evaluation is false. Standard `float.*` rules are left
346+
narrow deliberately: their bounds come from the descriptor and are already
347+
exactly `Float32` values, so the comparison cannot round either way. A
348+
`float`/`double` field comparison now elaborates too (the `float` side
349+
widens). What remains: `string(e)` uses `toString`, whose formatting may
350+
diverge from CEL's for floats.
337351

338352
## Codegen semantics and limitations
339353

@@ -542,17 +556,15 @@ non-generated, option-only proto dependencies such as
542556
now-dependent rules (`timestamp.lt_now`/`within`), which need a
543557
validation-time story for `Cel.now`.
544558
3. Residual conservative spots (sound, but stricter than CEL or late-failing).
545-
Descriptor-aware numerics closed the literal-wrapping hole and made
546-
arithmetic guards CEL-exact wherever a rule's `this`-rooted path resolves
547-
to a proto field; what is left is where no such path exists:
548-
- values bound by a comprehension variable (`this.all(v, v * 2 > 0)`) or
549-
reached through indexing (`this.items[0].qty`) carry no descriptor type,
550-
so their literals are unchecked and their 32-bit guards stay
551-
conservative — and enum values reached through indexing still lack the
552-
`.toInt32` view;
559+
Descriptor-aware numerics — now reaching comprehension binders and indexed
560+
elements — closed the literal-wrapping hole and made arithmetic guards
561+
CEL-exact wherever a rule's value resolves to a proto type. What is left:
562+
- free identifiers carry no descriptor type, so their literals are
563+
unchecked and their 32-bit guards stay conservative;
553564
- error absorption under negation (`!(err && false)`) translates to
554565
failure;
555-
- `float` fields compare at `Float32` precision, CEL at double precision.
566+
- index proofs inside `exists_one`/`map`/`filter` bodies and negated
567+
quantifier bodies have no sound discharge point and are rejected.
556568
4. No formal relation between `Protovalidate.Cel.Regex` and RE2 beyond the
557569
per-pattern differential batteries (see
558570
[regex assurance](#regex-assurance-what-the-guards-do-and-do-not-say)), nor

‎Test/cel_corpus.tsv‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ dom_uint32_wide UInt32 dom=uint32 this * 2u < 8589934592u
115115
dom_uint64_add UInt64 dom=uint64 this + 1u > 0u
116116
dom_int64_arith Int64 dom=int64 this * 2 + 1 <= 100
117117
dom_float_range Float32 dom=float this >= -3.4e38 && this <= 3.4e38
118+
dom_float_precision Float32 dom=float this <= 1.1 && this > -1.1
119+
dom_float_beyond_f32 Float32 dom=float this < 1e40
118120
dom_double_range Float dom=double this * 2.0 <= 1e300
119121
dom_enum_int CelTest.Tier dom=enum this >= 1 && this != 3
120122
dom_enum_arith CelTest.Tier dom=enum this + 1 == 2

‎celtolean/domain.go‎

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -143,17 +143,21 @@ func (d *numDom) outOfRange(lit string) error {
143143
"type, where it would wrap", lit, d.proto, d.lean, d.minInt(), d.maxInt())
144144
}
145145

146-
// widen lifts a 32-bit proto integer to the width CEL actually computes at, so
146+
// widen lifts a 32-bit proto scalar to the width CEL actually computes at, so
147147
// arithmetic on it (and its overflow guard) is CEL-exact instead of
148-
// conservatively 32-bit. Values already at CEL width, floats and untyped
149-
// fragments pass through.
148+
// conservatively 32-bit, and so a `float` field is compared at the double
149+
// precision CEL uses. Values already at CEL width and untyped fragments pass
150+
// through.
150151
func (t *translator) widen(p piece) piece {
151152
d := p.dom
152-
if d == nil || d.float || d.bits != 32 {
153+
if d == nil || d.bits != 32 {
153154
return p
154155
}
155156
conv, to := ".toInt64", domI64
156-
if d.unsigned {
157+
switch {
158+
case d.float:
159+
conv, to = ".toFloat", domF64
160+
case d.unsigned:
157161
conv, to = ".toUInt64", domU64
158162
}
159163
return piece{
@@ -166,20 +170,33 @@ func (t *translator) widen(p piece) piece {
166170
}
167171
}
168172

169-
// unifyWidth makes two comparable proto integers elaborate at a common Lean
170-
// type by widening the narrower one (CEL compares int32 and int64 fields
171-
// directly; Lean's fixed-width types would not unify). Mixed signedness is a
172-
// Lean type error either way and is left alone.
173+
// widenFloat lifts a `float` value to `Float` before it meets anything
174+
// numeric. Unlike the integer widening, which unifyWidth applies only when two
175+
// typed operands must meet, this is unconditional: CEL evaluates every numeric
176+
// operation on a `float` field at double precision, and a double literal is
177+
// generally *not* representable in Float32. `this == 1.1` on a `float` field
178+
// is satisfiable in Float32 (by the Float32 nearest 1.1) and unsatisfiable in
179+
// CEL — so without widening the kernel could prove a proposition whose CEL
180+
// evaluation is false. Float32 → Float is exact, so widening only removes the
181+
// rounding, never introduces one.
182+
func (t *translator) widenFloat(p piece) piece {
183+
if p.dom != domF32 {
184+
return p
185+
}
186+
return t.widen(p)
187+
}
188+
189+
// unifyWidth makes two comparable proto scalars elaborate at a common Lean
190+
// type by widening the narrower one (CEL compares int32 and int64, or float
191+
// and double, fields directly; Lean's fixed-width types would not unify).
192+
// Mixed signedness is a Lean type error either way and is left alone.
173193
func (t *translator) unifyWidth(l, r piece) (piece, piece) {
174194
if l.dom == nil || r.dom == nil || l.dom == r.dom {
175195
return l, r
176196
}
177197
if l.dom.float != r.dom.float || l.dom.unsigned != r.dom.unsigned {
178198
return l, r
179199
}
180-
if l.dom.float {
181-
return l, r
182-
}
183200
if l.dom.bits < r.dom.bits {
184201
return t.widen(l), r
185202
}
@@ -207,6 +224,11 @@ func arithDom(l, r piece) *numDom {
207224
// checkLiterals rejects numeric literals that fall outside the domain of the
208225
// typed operand they meet. Both directions are checked (`this < 3e9` and
209226
// `3e9 < this`), as are list/map literals on the right of `in`.
227+
//
228+
// The Float32 branch of accepts is a backstop: every site that lets a literal
229+
// meet a `float` value widens it to `Float` first (widenFloat), so the value's
230+
// domain is `double` by the time the check runs. It fires only if some path
231+
// forgot to widen — better a generation error than a silently rounded literal.
210232
func (t *translator) checkLiterals(l, r piece) error {
211233
for _, pair := range [2][2]piece{{l, r}, {r, l}} {
212234
v, lit := pair[0], pair[1]

‎celtolean/translate.go‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -816,8 +816,10 @@ func (t *translator) binaryArgs(op binOp, args []ast.Expr) (piece, error) {
816816
return piece{}, err
817817
}
818818
// Descriptor-aware checks: a literal outside the typed operand's domain
819-
// would silently wrap at elaboration, and two proto integers of different
820-
// widths only unify once the narrower one is lifted to CEL's width.
819+
// would silently wrap at elaboration, two proto integers of different
820+
// widths only unify once the narrower one is lifted to CEL's width, and a
821+
// `float` field is compared at the double precision CEL uses.
822+
l, r = t.widenFloat(l), t.widenFloat(r)
821823
if err := t.checkLiterals(l, r); err != nil {
822824
return piece{}, err
823825
}
@@ -975,6 +977,7 @@ func (t *translator) equality(fn string, args []ast.Expr) (piece, error) {
975977
if rerr != nil {
976978
return piece{}, rerr
977979
}
980+
l, r = t.widenFloat(l), t.widenFloat(r)
978981
if err := t.checkLiterals(l, r); err != nil {
979982
return piece{}, err
980983
}

‎celtolean/translate_test.go‎

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -378,11 +378,19 @@ func TestNumericDomainWidening(t *testing.T) {
378378
// No arithmetic: no widening, so the emitted proposition stays the one
379379
// a Lean author would write over the field's own type.
380380
{"int32_plain_compare", map[string]PathAttr{"": PathInt32}, `this > 3`, `x > 3`},
381-
// Floats stay at the field's own type: CEL double arithmetic cannot
382-
// error, so there is no guard to tighten (the ArithOk instance is
383-
// trivially true) and widening would only obscure the proposition.
384-
{"float_no_widen", map[string]PathAttr{"": PathFloat}, `this * 2.0 < 5.0`,
385-
`(if h : Cel.mulOk x 2.0 then x * 2.0 < 5.0 else False)`},
381+
// `float` fields widen to Float: CEL evaluates every numeric operation
382+
// on them at double precision, and a double literal generally has no
383+
// exact Float32 counterpart.
384+
{"float_widen_arith", map[string]PathAttr{"": PathFloat}, `this * 2.0 < 5.0`,
385+
`(if h : Cel.mulOk x.toFloat 2.0 then x.toFloat * 2.0 < 5.0 else False)`},
386+
{"float_widen_compare", map[string]PathAttr{"": PathFloat}, `this <= 1.1`,
387+
`x.toFloat ≤ 1.1`},
388+
// The literal is beyond Float32's finite range but an ordinary double:
389+
// widened, the comparison is exactly CEL's.
390+
{"float_widen_magnitude", map[string]PathAttr{"": PathFloat}, `this < 1e40`,
391+
`x.toFloat < 1e40`},
392+
// double fields are already at CEL's width.
393+
{"double_plain", map[string]PathAttr{"": PathDouble}, `this <= 1.1`, `x ≤ 1.1`},
386394
}
387395
for _, tt := range tests {
388396
t.Run(tt.name, func(t *testing.T) {
@@ -396,13 +404,21 @@ func TestNumericDomainWidening(t *testing.T) {
396404
})
397405
}
398406

399-
// Comparing proto integers of different widths: CEL compares them
407+
// Comparing proto scalars of different widths: CEL compares them
400408
// directly, so the narrower one is lifted to the wider Lean type.
401-
fields := map[string]ThisField{"small": {Text: "small"}, "big": {Text: "big"}, "n": {Text: "n"}}
402-
attrs := map[string]PathAttr{"small": PathInt32, "big": PathInt64, "n": PathUInt32}
409+
fields := map[string]ThisField{
410+
"small": {Text: "small"}, "big": {Text: "big"}, "n": {Text: "n"},
411+
"f": {Text: "f"}, "d": {Text: "d"},
412+
}
413+
attrs := map[string]PathAttr{
414+
"small": PathInt32, "big": PathInt64, "n": PathUInt32,
415+
"f": PathFloat, "d": PathDouble,
416+
}
403417
mixed := []struct{ cel, want string }{
404418
{`this.small < this.big`, `small.toInt64 < big`},
405419
{`this.big == this.small`, `big = small.toInt64`},
420+
{`this.f < this.d`, `f.toFloat < d`},
421+
{`this.d == this.f`, `d = f.toFloat`},
406422
{`this.small + 1 > this.big`,
407423
`(if h : Cel.addOk small.toInt64 1 then small.toInt64 + 1 > big else False)`},
408424
{`this.n * 2u > 100u`,
@@ -447,8 +463,6 @@ func TestNumericDomainRejects(t *testing.T) {
447463
"outside the domain of the int64 value"},
448464
{"in_list", map[string]PathAttr{"": PathInt32}, nil, `this in [1, 2, 5000000000]`,
449465
"outside the domain of the int32 value"},
450-
{"float_overflow", map[string]PathAttr{"": PathFloat}, nil, `this < 1e40`,
451-
"outside the finite range of the `float` field's Lean type Float32"},
452466
{"message_rule_leaf", map[string]PathAttr{"stock": PathInt32}, fields,
453467
`this.stock < 3000000000`, "outside the domain of the int32 value"},
454468
{"message_rule_uint", map[string]PathAttr{"batch": PathUInt32}, fields,

‎examples/shipping/InventoryValidTest.lean‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,14 @@ def main : IO Unit := do
9393
"product.first_tier" "indexed enum element below bound"
9494
expectId (Valid.Product.validate { goodProduct with tier_history := #[] }) "" "empty array: index guard unforced"
9595

96+
-- A `float` field is compared at CEL's double precision. The Float32
97+
-- nearest 1.1 is 1.10000002384…, which is *greater* than the double 1.1, so
98+
-- CEL rejects it — and so must the refinement. Comparing in Float32 (where
99+
-- the literal would round to that same value) would have accepted it.
100+
expectId (Valid.Product.validate { goodProduct with weight_kg := 1.0 }) "" "float under cap"
101+
expectId (Valid.Product.validate { goodProduct with weight_kg := 1.1 }) "weight.cap" "float double-precision cap"
102+
expectId (Valid.Product.validate { goodProduct with weight_kg := -1.0 }) "float.gte" "float gte"
103+
96104
-- encode → decodeValid roundtrip
97105
let base := v.toBase
98106
match base.encode with

‎examples/shipping/inventory.proto‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,15 @@ message Product {
6464
id: "lots.capacity", message: "each lot must stay under the reserved cap",
6565
expression: "this.all(n, n * 10000 <= 5000000000)"
6666
}];
67+
float weight_kg = 18 [(buf.validate.field).float.gte = 0.0,
68+
(buf.validate.field).cel = {
69+
// A `float` field compared against a *double* literal: CEL converts the
70+
// field to double, so the emitted proposition does too. Elaborating 1.1
71+
// at Float32 instead would round it up to 1.10000002384…, and a stored
72+
// 1.1f would then pass a rule CEL rejects.
73+
id: "weight.cap", message: "weight must not exceed the cap",
74+
expression: "this <= 1.1"
75+
}];
6776
repeated Tier tier_history = 17 [(buf.validate.field).cel = {
6877
// Enum elements compare as ints, through the generated .toInt32 view.
6978
id: "tiers.known", message: "every recorded tier must be a known tier",

0 commit comments

Comments
 (0)