← All reference docs

Refinement types — design note (#69)

Status: design ACCEPTED (2026-07-28), not implemented. The identity decision below is settled; the encoding and checker are not started.

The decision in one line, in Michael's framing: identity is structural and reproducible; semantic relationships are layered above it. Semantic dedup would mean the hash no longer identifies the object's representation — it would identify "the current toolchain's opinion about the object", contingent on normalization policy, solver version, and timeouts.

What this is

A refinement type attaches a proposition to a type: {x: Int | x > 0} is the Ints that are positive. The proposition is discharged by the same SMT machinery that already discharges properties (§7), so this needs no new solver, no new translation fragment, and no new guarantee vocabulary.

It is the natural next axis for Oath, and the reason is structural rather than fashionable. The thesis is properties in identity: machine-checked promises are part of what a definition IS. Today those promises live at the property layer — claims about a definition, attached to it. Refinements move the same idea into the type layer, where they become claims the type system enforces compositionally at every use site, not just facts recorded about one definition.

Concretely, what it buys:

THE IDENTITY DECISION

This is the fork. Everything else is engineering; this is not.

A refinement is a proposition, and propositions have many syntactically different forms with the same meaning: {x | x > 0}, {x | 0 < x}, {x | x >= 1}. Since refinements are part of the type, and types are part of the canonical O1 encoding (§1), the question is unavoidable:

Do logically-equivalent refinements produce the same hash?

Decision: NO. Refinement identity is SYNTACTIC, exactly like every other part of the O1 encoding. {x | x > 0} and {x | 0 < x} are different types with different hashes.

Why

The project has already answered this question twice, in the same direction, and written the answer down as an invariant:

Semantic equivalence in Oath is a relation drawn over the hash graph, never into it. Adopting semantic refinement identity would reverse that for types specifically, and the consequences are not cosmetic:

  1. Identity would become undecidable. Proposition equivalence over the theories Oath admits (unbounded Int, ℚ, IEEE floats, ADTs, quantifiers) is not decidable in general. A hash function that must decide it is not a hash function. Every put would depend on a solver call that may not terminate.
  2. Identity would become solver-dependent. If two refinements hash the same because Z3 4.16.0 proved them equivalent, then the identity of a definition depends on a solver version. docs/SPEC.md §1 already treats encoding changes as forking reality; making them depend on a prover makes reality fork on a dependency upgrade. The whole point of the deterministic rlimit budget (§7.2) is that verdicts are machine-independent — but verdicts are metadata. Identity has a stricter standard, not a looser one.
  3. The N-version experiment would collapse. oathrs reproduces hashes byte-for-byte from the spec. It could not do so if hashing required agreeing with another solver's equivalence judgements. The strongest evidence this project has that its spec is real is that a blind kernel reproduces its hashes; semantic identity would trade that away.
  4. It buys less than it appears to. The thing users actually want is not "these are the same object" but "I can pass this value where that type is expected." That is subtyping, and subtyping is where the SMT belongs (below). Syntactic identity plus semantic subtyping gets the ergonomics without putting a solver in the hash.

What this costs, honestly

Two definitions whose refinements differ only in spelling are different objects, even though they are interchangeable in use. That is a real duplication cost in the commons: the store may hold {x | x > 0} and {x | 0 < x} versions of the same idea.

This is the same cost the project already accepts for bodies — (+ a b) and (+ b a) are different objects — and the answer is the same: the discovery layer relates them. oath find already has the machinery (spec-query by property hash, proof-implication, e-graph canonicalization), and refinements should plug into it rather than into hashing. A find --equiv over refinements is future work, not a blocker.

"Syntactic" means the CANONICAL ENCODED AST, not source text

Accepted with one sharpening that matters: syntactic here refers to the O1 canonical encoding, not to what the author typed. If that is left vague, "syntactic identity" drifts into "implementation-dependent identity", which fails for the same reason semantic identity does — just more quietly.

Oath already folds several distinctions away before hashing, and refinements inherit every one of them:

What is therefore NOT yet settled, and MUST be pinned in SPEC §1 before any implementation, is which of these apply inside a refinement proposition:

  1. Implicit/elaborated types. If the refinement's base type is inferrable, is the elaborated type encoded, or the written one? It must be the elaborated one, or the same refinement hashes differently depending on how much the author annotated.
  2. Operator desugaring. If > desugars to < with swapped operands, then {x | x > 0} and {x | 0 < x} collapse by desugaring — a syntactic, decidable, solver-free normalization. That is legitimate and is NOT the semantic identity rejected above, but it must be stated explicitly rather than emerge from parser accident. Whatever the choice, it has to be the same in a blind kernel reading only the spec.
  3. Numeric literal forms (1 vs 0x1, 2/4 vs 1/2 for ℚ) — already a settled question for terms; confirm it extends unchanged.
  4. Source-level aliases for types or predicates, which must expand before encoding.

The rule of thumb: a transformation may run before hashing iff it is TOTAL, DECIDABLE, and SOLVER-FREE. Desugaring qualifies. Proving P ⟺ Q does not. The line is not "how much normalization" but "does the normalizer need to reason".

Fixture requirements (non-negotiable)

The encoding change ships with fixtures demonstrating BOTH directions, or it does not ship:

  1. Every existing corpus hash is unchanged. Unrefined types must encode byte-identically to today, so the feature cannot fork reality for definitions that do not use it.
  2. Logically equivalent, differently spelled refinements produce DIFFERENT hashes. This is the positive witness for the decision itself. Without it the invariant is prose, and this project has now been bitten three times by rules pinned only by prose (§7's no-script rule, #67's union rule, #72's abort semantics) — two of which were silently wrong in one kernel or the other.

Both belong in fixtures/, checked by the blind kernel, not only in unit tests.

Consequence for the spec

If refinements are added to Ty, §1's encoding gains a case and every existing hash is unaffected only if the encoding is extended in a way that leaves unrefined types byte-identical. That must be a hard requirement: a refinement feature that re-hashes the existing corpus would fork reality for no reason. The natural shape is a new Ty.K value ("refine") wrapping a base type plus a proposition, so unrefined types encode exactly as they do today.

Subtyping: where the SMT actually goes

{x: T | P} <: {x: T | Q} iff P ⟹ Q. That implication is an SMT query, and it is where every interesting refinement obligation is discharged:

Decidability follows the base theory, which is a known quantity here: Int/Rat/Bool are good, Float is decidable via FPA, Str is the weak spot (see #68), and quantified or recursive refinements will sometimes not discharge.

Obligations get the guarantee ladder, not a binary. This is the design's sharpest fit with the rest of Oath. An undischargeable obligation must NOT be a hard type error — that would make the type system's usefulness hostage to solver completeness. Instead an obligation should carry the same vocabulary properties already have:

That makes refinements strictly additive to the guarantee story rather than a second, stricter gate that can reject working programs. It also means oath hint (#67) applies unchanged: a stuck obligation can be given a stepping-stone lemma.

Interaction with what exists

Staging

  1. This document, accepted. The identity decision is load-bearing for everything after it.
  2. SPEC §1 encoding for a refine type constructor, with the hard requirement that unrefined types stay byte-identical, plus fixtures proving the corpus hashes do not move.
  3. Checker: bidirectional checking with annotations at binders; subtyping as an SMT query; obligations recorded with the ladder above.
  4. Blind-kernel implementation from the spec, as with #67/#71/#72 — expected to surface ambiguities, which is the point.
  5. Discovery integration (relating logically-equal refinements) — last, and explicitly not part of identity.

Open questions, not settled here


Rendered verbatim from docs/refinements.md in the repository. The markdown is the single source; this page is a copy checked for drift in CI, so what you read here is what an implementer reads.