Repository navigation
Keep nil out of the scalar write domain and record why it is there (issue #49) - #121
jiro-by-nori[bot] wants to merge 1 commit into
Conversation
`scalarWriteB` admits Integer, Float and Symbol. Boolean is outside it because TrueClass distinguishes its two inhabitants. nil is outside it for a different reason, and the guard's docstring stated only half of it: a nil-typed field may be absent, so the receiver of the write may be frozen with no fields at all, and the model then builds CRuby's FrozenError message by calling the receiver's own `inspect` (`Interp.resumeFrozen`). That is user code, and it may raise a TypeError. The new control `nilFieldFrozenInspect` runs that program at the booted machine and asserts the TypeError, next to the existing frozen control that asserts the tolerated non-type outcome, and `#guard !scalarWriteB .nilT` pins the exclusion beside the Boolean one. No checker behaviour changes. Co-authored-by: Jiro <noreply@anthropic.com>
There was a problem hiding this comment.
Reviewed head 6b21082.
Checks observed: all green at this head — CI (success), checks not weakened (success), proofs (build, every book, axioms, soundness, comparator) (success), conformance (the model against CRuby) (success), front-end (the desugarer against CRuby) (success), lint (success), docs (success). No unresolved review threads. 52 changed lines across 3 files, none human-gated.
Files reviewed:
books/Books/TypeSoundness/Checker/Guards/ScalarWrite.leanbooks/Books/TypeSoundness/Checker/Controls/ScalarWriteControls.leanbooks/Books/TypeSoundness/Controls/ScalarWriteControls.lean
Validation: cloned the head and read the changed modules plus the definitions the new prose relies on. Confirmed the claims the docstrings make are true of the code: StateOk.frozenFields exists as Conformance/Core/State.lean:1103 (over FrozenFieldsOk), Interp.raiseFrozen starts the .frozenErrorK run (Interp/Support.lean:125) and Interp.resumeFrozen invokes the receiver's own to_s/inspect reflectively (Interp/Frozen.lean:37-66), which is the user-code path the docstring names; Conformance/Heap/ScalarPres.lean and ScalarEq/scalarHeap_pres exist as referenced. Confirmed Controls/ScalarWriteControls.lean is imported by Controls/All.lean, so the new #guard is elaborated and evaluated by make books (as the existing frozenProgram guard is), and the module's #guard !scalarWriteB .nilT is an added check, not a relaxed one (scalarWriteB is int | float | sym => true, _ => false). Checker behaviour is unchanged: validateD and the #guard !validateD (cls "NilBox" .nil) … refusal are untouched, and no #guard or assertion was removed or weakened. I did not run the Lean build myself: this review machine has no Lean/lake toolchain, so the compile-and-evaluate evidence for the new guard is the CI proofs job at this head, which builds every book.
Risk: low. The change is documentation plus one negative control; it records an argument rather than altering the checker, so the only thing a future reader could rely on wrongly is the prose itself. The one claim I could not watch run — that negating the new #guard makes the module fail — is a property of #guard elaboration, and the guard was observed true in CI. The PR body leaves the decision on issue #49 (close or rescope) to the owner, which is a product call, not a merge blocker.
Verdict: approve.
What this does
#49 asks for
.nilTto joinscalarWriteB's domain, on the reading that nil is excludedonly because the frozen branch of the proof was inconvenient — "a proof limitation, not a
disagreement about safety". The exclusion is in fact load-bearing, and this PR records why,
where the next person to try the widening will look.
Checker/Guards/ScalarWrite.lean— the docstring now separates the two exclusions.Boolean is out because its two inhabitants are distinguishable (
TrueClassseparatestruefromfalse). nil is out for safety: a nil-typed field may be absent, so thereceiver of the write may be a frozen object with no fields at all (which is exactly what
StateOk.frozenFieldspermits), and the model then builds CRuby'sFrozenErrormessage bycalling the receiver's own
inspect(Interp.raiseFrozen→Interp.resumeFrozen→ theto_s/inspectdispatch and allocation inRubyCore/Interp/Frozen.lean). That is usercode, and it may raise a
TypeError.Controls/ScalarWriteControls.lean— a new control,nilFieldFrozenInspect, runs exactlythat program at the booted machine and asserts the run ends in a
TypeError; a#guard !scalarWriteB .nilTsits beside the existing#guard !scalarWriteB .bool.Checker/Controls/ScalarWriteControls.lean— the comment on theNilBoxrefusal now sayswhich of the two reasons applies and points at the control.
No checker behaviour changes:
validateDanswers exactly what it answered before, so norecorded result moves.
Why the widening is false rather than merely unproved
scalar_write_run's frozen branch is the whole obstruction. With.nilTadded toscalarWriteBthe file leaves exactly one goal:For Integer/Float/Symbol that goal is discharged by contradiction:
denMof an absent fieldis false and
StateOk.frozenFieldssays a frozen object has no fields, so the receivercannot be in that state. For nil the state is reachable and the goal has to be proved, and it
is false — the run dispatches
inspecton the receiver. Replacing the branch with anassumed lemma (a throwaway
sorry, not in this PR) makes the rest of the file compileunchanged, so
ScalarEq,scalarHeap_pres,SemSafeCtxA.scalarIvarAsgnand the non-frozenbranch already admit nil; the frozen-raise run is the only gap, and it is not a gap.
The same program at the model (
NilBoxhas noinitialize, so the field is absent beforethe write):
A sound widening therefore starts from a hypothesis about the receiver's class (no
user-defined
inspecton it) and then needs aRunSpecfor the frozen-raise run; it does notstart at
scalarWriteB. If you agree with that reading, #49 can be closed or rescoped to thehypothesis work rather than to the guard change it currently proposes.
Test plan
make checkin full, from a clean tree: linters,gen-check, the model, every proof book,the metatheory axiom audit, the soundness theorem with its comparator replay, the checker
over the corpus, the desugarer and emitter unit tests with coverage, the desugar round-trip
against CRuby 4.0.5 (1257 agreed, 0 disagreed, 0 harness errors), difftest tier 0 plus the
regressions and tier-3 tiers, feature loading, and the docs. It ends
make check: ALL CHECKS PASSED.make check-not-weakenedagainstorigin/main:CHECKS NOT WEAKENED— no recorded resultfell, no check or audited theorem was dropped, and no
sorry,native_decide, axiom, lintsuppression or test skip was added.
#guardto the negated condition makes themodule fail with
did not evaluate to true, so the guard really does observe the TypeError.#guard !scalarWriteB .nilTand the existing#guard !validateD (cls "NilBox" .nil) …bothhold, i.e. the refusal scalarIvarAsgn rejects nil field replacement, so 074's admitted scalar domain is narrower than Sorbet's #49 wants flipped is unchanged.
Authored by Jiro, an autonomous coding agent (@nori-sessions).