Skip to content

feat: widen == between T? and T, and widen generic-fn optional arguments - #807

Merged
kacy merged 1 commit into
mainfrom
widen-optional-eq-and-generic-args
Aug 19, 2026
Merged

feat: widen == between T? and T, and widen generic-fn optional arguments#807
kacy merged 1 commit into
mainfrom
widen-optional-eq-and-generic-args

Conversation

@kacy

@kacy kacy commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Two widening holes from the ergonomics sprint (task #239), closed together because they share the machinery and the safety argument.

== / != between T? and T

o == 5 where o: Int? used to report E217. The checker now accepts the mixed comparison (both orders) when exactly one side is an optional whose inner type matches the other side structurally. The emitter lowers it as is_some and value == vnone never equals a plain value, != negates, and a String? compares by content through __str_eq, the same as the existing T? == T? compare.

The mixed arm is deliberately the last arm of the optional-compare dispatch: every both-sides-optional recovery (including the generic-specialization tid recovery) gets first refusal, and a side whose check-time tid is TID_ERR or TID_NONE is excluded by name — both are primitives with tids >= 0, so tid >= 0 alone is not an "is typed" test. A fresh string on the plain side is released after the compare, the same rule the value operator applies to its operands.

Deliberately unchanged: ordering operators (< etc.) stay rejected on optionals; bare none keeps its #680 rules (only an optional accepts it, and nothing here lets a plain value satisfy none).

generic-function optional arguments

pick(x, 3) against fn pick[T](a: T, b: Int?) now widens, in both the inferred and the explicit pick[String](x, 3) forms, by routing the two generic validation loops through the same argument_widens_to_optional record every non-generic argument position already uses — the emitter wrap comes for free through ir_emit_call_arg, so the emitter needed no change for this half.

History: #683 wired exactly this up, tested it, and backed it out because of the #148 specialization miscompile (a specialization read a parameter's optional-ness off the call site, so one none call site corrupted its siblings). #148 is fixed — declaration-driven optional tids — and its evidence was re-run on main before re-landing: none and a value through one shared specialization, read back via == none, unwrap_or, and match, all correct.

docs/limitations.md updated: the == entry and the generic-function bullet now describe the widened behavior, and the "argument positions that still need a T? local" list drops from three to two (builtin container store/query, enum variant payload).

what was tested

  • new tests/cases/test_optional_widen_eq.pith golden: Int?/String? mixed compares both orders, none vs value, !=, and the generic widening through one shared specialization read back three ways (the feat: add websocket session surface #148 evidence shapes)
  • new tests/leaks/leak_optional_plain_compare case, added to the leak gate: flat at 200k vs 800k rounds (2800 vs 2756 KB peak); a separate 900k-round probe with a fresh-string operand also flat at 2.3 MB
  • rejections that must survive, verified still rejected: x < 5 on Int? (E217), x == "hi" on Int? (E217), f(none) for a non-optional parameter (E219)
  • make bootstrap-verify: bootstrap ir fixed point verified (includes self regressions + std tests)
  • make check-invalid-only: 51 passed, 0 failed
  • fmt + lint clean on all changed files; bootstrap seed regenerated

Two widening holes, closed together because they share the machinery and
the safety argument.

`==` / `!=` between an optional and a plain value of its inner type used
to report E217. The checker now accepts the mixed comparison (both
orders) when the inner type matches structurally, and the emitter lowers
it as `is_some and value == v` — none never equals a plain value, neq
negates, and a String? compares by content through __str_eq, the same as
the existing T? == T? compare. The mixed arm is deliberately the LAST
arm of the optional-compare dispatch: every both-sides-optional recovery
(including the generic-specialization tid recovery) gets first refusal,
and a side whose check-time tid is TID_ERR or TID_NONE is excluded by
name since both are primitives with tids >= 0. A fresh string on the
plain side is released after the compare, the same rule the value
operator applies. Ordering operators stay rejected on optionals, and
bare none keeps its own rules (#680).

A generic function's optional parameter now widens a plain argument —
`pick(x, 3)` against `fn pick[T](a: T, b: Int?)` — in both the inferred
and explicit-type-args forms, by routing the two generic validation
loops through the same argument_widens_to_optional record every
non-generic argument position already uses; the emitter wrap comes for
free through ir_emit_call_arg. #683 wired this up and backed it out
because of the #148 specialization miscompile; #148 is fixed
(declaration-driven optional tids), and its evidence was re-run first:
none and a value through one shared specialization, read back via
== none, unwrap_or, and match, all correct.

docs/limitations.md: the `==` entry and the generic-function bullet
now describe the widened behavior; the "three argument positions"
list drops to two (container store/query, enum variant payload).

## what was tested

- new tests/cases/test_optional_widen_eq.pith golden: Int?/String?
  mixed compares both orders, none vs value, neq, and the generic
  widening through one specialization read back three ways (the #148
  evidence shapes)
- new tests/leaks/leak_optional_plain_compare case, added to the leak
  gate: flat at 200k vs 800k rounds (2800 vs 2756 KB peak); a 900k-round
  probe with a fresh-string operand also flat at 2.3 MB
- rejections that must survive, verified rejected: `x < 5` on Int?
  (E217), `x == "hi"` on Int? (E217), `f(none)` for a non-optional
  parameter (E219)
- make bootstrap-verify: bootstrap ir fixed point verified (includes
  self regressions + std tests)
- make check-invalid-only: 51 passed, 0 failed
- fmt + lint clean on all changed files; bootstrap seed regenerated
@kacy
kacy merged commit 07692b3 into main Aug 19, 2026
0 of 2 checks passed
@kacy
kacy deleted the widen-optional-eq-and-generic-args branch August 19, 2026 04:39
kacy added a commit that referenced this pull request Aug 19, 2026
…s checksum (#808)

The gate contract is that a case prints peak_kb() and nothing else; the
case from #807 printed its hit counter instead, so the "growth" the gate
compared was 800k*3 - 200k*3 = 1,800,000 — the difference of two
checksums, not two peaks — and main went red on a case that does not
leak.

## what was tested

- the gate's own measure sequence against the rebuilt case:
  PITH_LEAK_ROUNDS=200000 -> 2616 kb, 800000 -> 2656 kb, growth 40 kb
  against the 2048 kb limit
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