Skip to content

A field keeps what its type said - #969

Merged
onatozmenn merged 1 commit into
mainfrom
a-field-keeps-what-its-type-said
Aug 9, 2026
Merged

A field keeps what its type said#969
onatozmenn merged 1 commit into
mainfrom
a-field-keeps-what-its-type-said

Conversation

@onatozmenn

Copy link
Copy Markdown
Collaborator

A parameter of type Positive was already known to be positive. A field
declared Positive was not, and the two say the same thing.

record Held { by: Positive }

fn needs(n: Positive) -> Int where n > 0, { n }

fn through(h: Held) -> Int { needs(h.by) }   // was Guarded, is Proven

The value has no name at that call for anything to have narrowed, and no
contract answered for it, so the only thing that knew anything was the type the
record was declared with — and nothing asked it. facts.rs said so itself:

Int.max is the number […] Every other field read is a value nothing here
knows.

Measured first

Where a refinement stops travelling, found by walking it rather than guessed:

written as tier
needs(b) where b: Positive proven
needs(h.by) where by: Positive guarded ← this
needs(b) where b: NonZero guarded ← something else

Two separate gaps, and only the first is closed here.

The second is value != 0, which admits everything but one number: two
intervals, not one, and Facts keeps ranges. That is a limit of what a fact
is, not of fields, and it is one of the two things still stopping std/ratio
from proving simplified's bottom != 0 at its call sites — the other being
the product of two numbers, which no interval bounds. Both are stated in the
changelog rather than left to be rediscovered.

The change

facts::Env gains one question: what does this expression's declared type
admit. The checker answers from its type table, because the fact machinery has
none, and answers Range::ANY when the type says nothing. Reading a
refinement's own predicate stays blind about fields, or a predicate mentioning
one would come straight back for the type being read.

Honest about what it buys

No obligation in the corpus moves. The records there are declared with
plain Int, so nothing there was losing anything. What moved is that the
compiler stopped throwing away something it already had, and the corpus numbers
(167 / 11 / 23) are unchanged on purpose rather than by accident.

Break-verified

CAUGHT     a field read stops asking what its type admits
SURVIVED   the type table answers for a type that is not refined

The survivor was not a missing test. A type that is not refined already answers
Range::ANY, so the guard asking whether it was refined bought nothing — dead
code, and it and the Option around it are gone.

The first run of that script reported both cases survived, and was wrong
both times: the edit never applied, and a check that cannot see its own
mutation is worse than no check. It verifies the text changed and that the run
produced a result before reading either.

Checks

cargo fmt, clippy -D warnings, nextest --profile ci (2469 passed, 4
skipped), cargo test --doc --workspace.

A parameter of type `Positive` was already known to be positive. A field
declared `Positive` was not, and the two say the same thing.

    record Held { by: Positive }

    fn needs(n: Positive) -> Int where n > 0, { n }

    fn through(h: Held) -> Int { needs(h.by) }   // was Guarded, is Proven

The value has no name at that call for anything to have narrowed, and no
contract answered for it, so the only thing that knew anything was the type
the record was declared with. Nothing asked it. The comment in `facts.rs` said
so in as many words: "every other field read is a value nothing here knows".

`Env` can ask now. The checker answers out of its type table, because the fact
machinery has none, and `Range::ANY` when the type says nothing.

Measured before writing any of it, three ways, to find where a refinement
stops travelling rather than guessing:

    needs(b)        where b: Positive     proven
    needs(h.by)     where by: Positive    guarded   <- this
    needs(b)        where b: NonZero      guarded   <- something else

The third is a different limit and is left alone: `value != 0` admits
everything but one number, which is two intervals and not one, so `Facts`
cannot hold it. That is what still stops `std/ratio` from proving
`simplified`'s `bottom != 0` at its call sites, along with the product of two
numbers, which no interval bounds.

No obligation in the corpus moves, and the changelog says so: the records there
are declared with plain `Int`. What moved is that the compiler stopped losing
something it already had.

Break-verified. The first attempt reported both cases survived and was wrong
both times -- the script never applied the edit -- so it checks that the text
changed and that the run produced a result before reading it. Then: taking the
question away is caught by name. The second case really did survive, and it was
a redundant guard rather than a missing test: a type that is not refined
already answers `Range::ANY`, so asking twice bought nothing. The guard and the
`Option` around it are gone.
@onatozmenn
onatozmenn merged commit 2930f13 into main Aug 9, 2026
10 checks passed
@onatozmenn
onatozmenn deleted the a-field-keeps-what-its-type-said branch August 9, 2026 19:50
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.

1 participant