checker: scalar * value with a user Multiply on the number type lowers as the builtin product #440

Closed
opened 2026-10-23 16:27:48 +00:00 by orion · 6 comments
Collaborator

Context

Found by the scratch probe of the std/tensor design record (#438, step 1 of #82; docs/design/TENSOR.md, "Operators"). A scalar on the left of an operator whose implementation is a user extend of the number type is accepted by the checker and then lowered as the builtin operation.

Reproduction

type Tensor<Scalar: Numeric = Float> {
  shape: List<Int>
  data: Buffer<Scalar>
}

extend Float64 with Multiply<Tensor<Float64>, Tensor<Float64>> {
  fn multiply(other: Tensor<Float64>): Tensor<Float64> {
    other
  }
}

const a: Tensor<Float> = Tensor([2], Buffer.from([1.0, 2.0]))
const b = 3.0 * a
print b.data

torb check answers 1 file, no problems; torb run answers internal error: t_probe2 b0: multiply.f64works onFloat64, and %0 is Record(T_probe2_Tensor__Float64)``. The operator form picks the primitive multiply.f64 of the left operand and never looks for the user's implementation (the method form 3.0.multiply(a) was not probed). Probed with the nightly of 2026-10-23.

Goal

scalar * value with a user-declared Multiply<Value, Value> on the number type resolves to that implementation in the IR lowering (or the checker refuses it with a clear message if the language decides it must not exist; LINEAR section 6 says coherence forbids it for Vector2, and the probe shows the checker does not).

Acceptance criteria

  • The program above prints [1.0, 2.0] in the VM and natively, or torb check refuses it with a diagnostic that says why.
  • A conformance program pins the chosen behaviour for Float64, Int and a user type on the right.

Seed

Touches the seed: probably (the lowering of an operator, compiler/src/ir/lower); the two-commit rule applies if the fix changes what the compiler reads. The seed refresh stays with the owner.

Out of scope

std/tensor slices: they use tensor * scalar and do not wait for this.

## Context Found by the scratch probe of the `std/tensor` design record (#438, step 1 of #82; `docs/design/TENSOR.md`, "Operators"). A scalar on the left of an operator whose implementation is a user `extend` of the number type is accepted by the checker and then lowered as the builtin operation. ## Reproduction ```trb type Tensor<Scalar: Numeric = Float> { shape: List<Int> data: Buffer<Scalar> } extend Float64 with Multiply<Tensor<Float64>, Tensor<Float64>> { fn multiply(other: Tensor<Float64>): Tensor<Float64> { other } } const a: Tensor<Float> = Tensor([2], Buffer.from([1.0, 2.0])) const b = 3.0 * a print b.data ``` `torb check` answers `1 file, no problems`; `torb run` answers `internal error: t_probe2 b0: `multiply.f64` works on `Float64`, and %0 is `Record(T_probe2_Tensor__Float64)``. The operator form picks the primitive `multiply.f64` of the left operand and never looks for the user's implementation (the method form `3.0.multiply(a)` was not probed). Probed with the nightly of 2026-10-23. ## Goal `scalar * value` with a user-declared `Multiply<Value, Value>` on the number type resolves to that implementation in the IR lowering (or the checker refuses it with a clear message if the language decides it must not exist; LINEAR section 6 says coherence forbids it for `Vector2`, and the probe shows the checker does not). ## Acceptance criteria - [ ] The program above prints `[1.0, 2.0]` in the VM and natively, or `torb check` refuses it with a diagnostic that says why. - [ ] A conformance program pins the chosen behaviour for `Float64`, `Int` and a user type on the right. ## Seed Touches the seed: probably (the lowering of an operator, `compiler/src/ir/lower`); the two-commit rule applies if the fix changes what the compiler reads. The seed refresh stays with the owner. ## Out of scope `std/tensor` slices: they use `tensor * scalar` and do not wait for this.
Author
Collaborator

[loop:torbscript] intake: in scope (author orion, label loop). claim refine by session:loop-torbscript-20261024 until 2026-10-24T00:00Z

[loop:torbscript] intake: in scope (author orion, label loop). claim refine by session:loop-torbscript-20261024 until 2026-10-24T00:00Z
Author
Collaborator

[loop:torbscript] refine: ready, next: implement

Claim released. Refined against docs/ROADMAP.md (the std/tensor step of the linear-algebra line), docs/design/TENSOR.md section 7 (decision 14, finding 3), docs/design/LINEAR.md section 6, CONCEPT.md (orphan rule) and the code at main (fe8c4b8b).

Findings

  • The program is legal. checkOrphan (compiler/src/semantics/checker/implementation.trb, ownsTraitArgument) deliberately lets the package that owns Tensor write extend Float64 with Multiply<Tensor<Float64>, Tensor<Float64>>; CONCEPT records that rule ("counting a type named as an argument of the trait as owning it"). So the second option of the issue goal (refuse in the checker) would contradict a decided rule. LINEAR section 6 ("coherence forbids it: std/linear owns neither Float64 nor Multiply") predates that rule and is stale.
  • The checker is right: operatorBound picks the user's Multiply<Tensor<Float64>, ...> from the right operand's type and records the resolution.
  • The bug is in the lowering: lowerArithmetic (compiler/src/ir/lower/expression.trb) takes the intrinsic as soon as the left slot is a built-in number (numericKindOf(..., first)) and never looks at the right operand. The intrinsic is only the right answer when the right operand has the same built-in number type; otherwise the operator is the user's member and belongs to lowerOperator (compiler/src/ir/lower/call.trb), which already reads the checker's recorded resolution.

decided by the loop: the operator form is supported (fix the lowering), not refused. Why: the orphan rule decided in CONCEPT permits exactly this implementation and the checker already resolves it; refusing would need a new coherence rule, a breaking change to a decided contract, and would leave Float64 with From<Celsius>-style implementations as the odd one out. Fixing the lowering is the smaller change (one guard) and no published contract changes. Not an owner question (internal technical design, per the contract).

Goal

scalar <op> value, where the left operand is a built-in number and the right operand has a type other than that number, is lowered as a call of the implementation the checker recorded, in the VM and natively. scalar <op> scalar stays the one intrinsic instruction it is today.

Acceptance criteria

  • The program of the issue prints [1.0, 2.0] with torb run (VM) and natively; torb check stays clean.
  • A new conformance program tests/conformance/scalar-left-operator.trb (with .expected, .exit, .allocations as its neighbours) runs natively and in the VM and pins scalar * value for Float64, for Int and for a user type (a generic one, as in the issue, and a plain record) on the right; it also pins that Float64 * Float64 and Int * Int in the same program are unchanged.
  • No other conformance program changes its .expected or .allocations (the guard must not turn a plain number product into a call); the IR print of 1.0 * 2.0 is still one multiply.f64.
  • The method form 3.0.multiply(a) is probed in the same scratch run. If it fails, it is recorded in a new issue (after a keyword search for a duplicate) and is not part of this PR.
  • docs/design/LINEAR.md section 6 gives the real reason scalar * vector is not in std/linear (a library choice: it needs an extend Float64 in the vector's package, and the spelling is vector * scalar), no longer "coherence forbids it". docs/design/TENSOR.md section 7 (finding 3) links this fix as closed once it lands; the first-release decision (no scalar * tensor) is unchanged. skills/ regenerated, pdisc.py check ok.

Design point: TENSOR.md decision 14 / section 7 (finding 3, blocks none of the slices) and LINEAR.md section 6; ROADMAP: the std/tensor line (step 1 of #82 found it, #438). Related: #441 (the overlap finding of the same probe, refined separately; no dependency in either direction).

Seed: no. The change is a guard inside the lowering of compiler/src; no syntax, no new native, no driver flag and no std name looked up by string, and compiler/ and std/ do not use a scalar-left operator on a foreign right operand, so the seed compiles the new source unchanged. Two-commit rule not needed; seed refresh stays with the owner.

Size: one PR, two Conventional Commits:

  1. fix(ir): lower a built-in number's operator with a foreign right operand as the recorded member (the guard in lowerArithmetic, plus the conformance program and expected files),
  2. docs(linear): state the real reason scalar * vector is not provided (LINEAR section 6, TENSOR section 7 pointer, regenerated skills/).

Implementation hints (data for the implementer, not a contract): in lowerArithmetic, take the intrinsic only if the type of second equals the type of first (or both resolve to the same numericKindOf); shifts (Int << UInt8) keep their intrinsic because their right operand is a built-in number, so test the guard on "right operand is a built-in number" rather than strict type equality. Check the checker leaves a resolution at the expression span for this case; if it only records one for non-built-in left operands, record it there. Verify in forge CI only (bootstrap and gates), no local gates.

Token usage of this refine worker: about 75k.

[loop:torbscript] refine: ready, next: implement Claim released. Refined against `docs/ROADMAP.md` (the `std/tensor` step of the linear-algebra line), `docs/design/TENSOR.md` section 7 (decision 14, finding 3), `docs/design/LINEAR.md` section 6, CONCEPT.md (orphan rule) and the code at `main` (`fe8c4b8b`). **Findings** - The program is legal. `checkOrphan` (`compiler/src/semantics/checker/implementation.trb`, `ownsTraitArgument`) deliberately lets the package that owns `Tensor` write `extend Float64 with Multiply<Tensor<Float64>, Tensor<Float64>>`; CONCEPT records that rule ("counting a type named as an argument of the trait as owning it"). So the second option of the issue goal (refuse in the checker) would contradict a decided rule. LINEAR section 6 ("coherence forbids it: `std/linear` owns neither `Float64` nor `Multiply`") predates that rule and is stale. - The checker is right: `operatorBound` picks the user's `Multiply<Tensor<Float64>, ...>` from the right operand's type and records the resolution. - The bug is in the lowering: `lowerArithmetic` (`compiler/src/ir/lower/expression.trb`) takes the intrinsic as soon as the **left** slot is a built-in number (`numericKindOf(..., first)`) and never looks at the right operand. The intrinsic is only the right answer when the right operand has the same built-in number type; otherwise the operator is the user's member and belongs to `lowerOperator` (`compiler/src/ir/lower/call.trb`), which already reads the checker's recorded resolution. **decided by the loop: the operator form is supported (fix the lowering), not refused.** Why: the orphan rule decided in CONCEPT permits exactly this implementation and the checker already resolves it; refusing would need a new coherence rule, a breaking change to a decided contract, and would leave `Float64 with From<Celsius>`-style implementations as the odd one out. Fixing the lowering is the smaller change (one guard) and no published contract changes. Not an owner question (internal technical design, per the contract). **Goal** `scalar <op> value`, where the left operand is a built-in number and the right operand has a type other than that number, is lowered as a call of the implementation the checker recorded, in the VM and natively. `scalar <op> scalar` stays the one intrinsic instruction it is today. **Acceptance criteria** - [ ] The program of the issue prints `[1.0, 2.0]` with `torb run` (VM) and natively; `torb check` stays clean. - [ ] A new conformance program `tests/conformance/scalar-left-operator.trb` (with `.expected`, `.exit`, `.allocations` as its neighbours) runs natively and in the VM and pins `scalar * value` for `Float64`, for `Int` and for a user type (a generic one, as in the issue, and a plain record) on the right; it also pins that `Float64 * Float64` and `Int * Int` in the same program are unchanged. - [ ] No other conformance program changes its `.expected` or `.allocations` (the guard must not turn a plain number product into a call); the IR print of `1.0 * 2.0` is still one `multiply.f64`. - [ ] The method form `3.0.multiply(a)` is probed in the same scratch run. If it fails, it is recorded in a new issue (after a keyword search for a duplicate) and is not part of this PR. - [ ] `docs/design/LINEAR.md` section 6 gives the real reason `scalar * vector` is not in `std/linear` (a library choice: it needs an `extend Float64` in the vector's package, and the spelling is `vector * scalar`), no longer "coherence forbids it". `docs/design/TENSOR.md` section 7 (finding 3) links this fix as closed once it lands; the first-release decision (no `scalar * tensor`) is unchanged. `skills/` regenerated, `pdisc.py check` ok. **Design point**: TENSOR.md decision 14 / section 7 (finding 3, blocks none of the slices) and LINEAR.md section 6; ROADMAP: the `std/tensor` line (step 1 of #82 found it, #438). Related: #441 (the overlap finding of the same probe, refined separately; no dependency in either direction). **Seed**: no. The change is a guard inside the lowering of `compiler/src`; no syntax, no new native, no driver flag and no std name looked up by string, and `compiler/` and `std/` do not use a scalar-left operator on a foreign right operand, so the seed compiles the new source unchanged. Two-commit rule not needed; seed refresh stays with the owner. **Size**: one PR, two Conventional Commits: 1. `fix(ir): lower a built-in number's operator with a foreign right operand as the recorded member` (the guard in `lowerArithmetic`, plus the conformance program and expected files), 2. `docs(linear): state the real reason scalar * vector is not provided` (LINEAR section 6, TENSOR section 7 pointer, regenerated `skills/`). **Implementation hints** (data for the implementer, not a contract): in `lowerArithmetic`, take the intrinsic only if the type of `second` equals the type of `first` (or both resolve to the same `numericKindOf`); shifts (`Int << UInt8`) keep their intrinsic because their right operand is a built-in number, so test the guard on "right operand is a built-in number" rather than strict type equality. Check the checker leaves a resolution at the expression span for this case; if it only records one for non-built-in left operands, record it there. Verify in forge CI only (bootstrap and gates), no local gates. Token usage of this refine worker: about 75k.
Author
Collaborator

[loop:torbscript] claim implement by session:loop-torbscript-20261024 until 2026-10-24T04:30Z

[loop:torbscript] claim implement by session:loop-torbscript-20261024 until 2026-10-24T04:30Z
Author
Collaborator

[loop:torbscript] implement: checkpoint, PR #446 is open (guard in lowerArithmetic, conformance program, docs commit); waiting for the forge CI gates. Method form 3.0.multiply(a) probed with the 0.1.0 toolchain: works, only the operator form was broken. The conformance allocation budget is a first guess and is set from the CI count.

[loop:torbscript] implement: checkpoint, PR https://git.torb.dev/torbscript/language/pulls/446 is open (guard in lowerArithmetic, conformance program, docs commit); waiting for the forge CI gates. Method form 3.0.multiply(a) probed with the 0.1.0 toolchain: works, only the operator form was broken. The conformance allocation budget is a first guess and is set from the CI count.
Author
Collaborator

[loop:torbscript] release implement: the implement worker died on a usage limit (weekly limit of its account) after opening PR #446; forge CI is still running. Left: set the conformance allocation budget from the CI count, then fix rounds. next: waiting (CI running on PR #446)

[loop:torbscript] release implement: the implement worker died on a usage limit (weekly limit of its account) after opening PR https://git.torb.dev/torbscript/language/pulls/446; forge CI is still running. Left: set the conformance allocation budget from the CI count, then fix rounds. next: waiting (CI running on PR #446)
argus closed this issue 2026-10-23 23:20:45 +00:00
Author
Collaborator

[loop:torbscript] close: merged via PR (main CI green on the landed commit bc504c24), issue closed. next: done

[loop:torbscript] close: merged via PR (main CI green on the landed commit bc504c24), issue closed. next: done
Sign in to join this conversation.
No labels
loop
ready
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
torbscript/language#440
No description provided.