compute: Dual numbers (forward mode) #83

Closed
opened 2026-10-05 21:07:18 +00:00 by orion · 7 comments
Collaborator

Context

Forward-mode automatic differentiation as a dual number: Dual<Scalar> in a new package std/gradient, pure TorbScript, no foreign functions, no dependency on std/tensor. Because Dual<Scalar> is itself Numeric, Signed and Real, every function that is generic over Real (geometry, animation, physics, a Tensor<Dual<Scalar>>) can be differentiated without being written for it. Reverse mode (#86) joins the same package later.

Source

  • COMPUTE.md sections 1 and 2: "forward mode as Dual<Scalar> with Real"
  • TENSOR.md section 9: a Tensor<Dual<Scalar>> must be a tensor; Compare on a dual compares its primal part
  • Real, Signed, Numeric in std/number/src/lib.trb; the model implementor with a generic body is Fixed in std/linear/src/fixed.trb
  • ROADMAP, "Computation and machine learning"

Design (decided by the loop, see the comment of 2026-10-24)

  • Package std/gradient (project.trb with name = "std/gradient", src/lib.trb, src/dual.trb, tests/dual.test.trb), as COMPUTE.md names it. Reverse mode (#86) lands in the same package later.
  • public type Dual<Scalar: Real = Float> with the two fields primal: Scalar and tangent: Scalar (primal is the word TENSOR.md already uses). It declares Signed, Real, Show, TryFrom<String, NumberParseError>, and Equals/Compare as Numeric requires.
  • Construction: Dual.constant(value) (tangent zero), Dual.variable(value) (tangent one), the plain constructor for both fields, and derivative(of: function, at: value): Scalar as the one-call form (the exact signature is the implementer's, following the closest existing function-argument convention).
  • Equals and Compare look at the primal part only, so ==, < and compare agree and a branch in a generic body (if x < Scalar.zero) takes the same path as the undifferentiated function. The tangent is read with the field.
  • Statics (zero, one, pi, tau, e, epsilon) are constants: the scalar's value with tangent zero. TryFrom<String> and From<Scalar> give constants.
  • Rules (the chain rule, in the scalar's own arithmetic; a generic body writes Scalar.zero / Scalar.one, never a literal):
    • + - * and /: sum, difference, product and quotient rule; %: tangent a' - b' * trunc(a / b); unary - and absolute (tangent sign(primal) * tangent, zero at zero).
    • squareRoot, exponential, naturalLogarithm, logarithm(base:), the six trigonometric functions and arcTangentDivided(by:): the textbook derivative. Where the scalar's own function is undefined or infinite (square root at zero, arcSine at +-1, logarithm at zero) the tangent is whatever the scalar's division gives; the scalar panics where the scalar panics (arcSine/arcCosine outside [-1, 1]).
    • floor, ceiling, round: tangent zero (the derivative almost everywhere).
    • halved, doubled, radiansOfDegrees, degreesOfRadians: scale the tangent like the primal.
    • Power (dual exponent): f ** g, tangent f ** g * (g' * ln f + g * f' / f), but when g.tangent is zero use g * f ** (g - 1) * f', so a negative base with a constant exponent has no logarithm of a negative number in it. Power<Int64>: n * f ** (n - 1) * f'.
  • Design record: COMPUTE.md gets a short section "Forward mode: Dual" (layout, the rules table above, the == decision, the pitfalls: discontinuities, floor) and its status line stops saying std/gradient does not exist; ROADMAP item 4 names Dual as built.

Acceptance criteria

  • std/gradient exists as designed above; Dual<Float> and Dual<Fixed> both satisfy Real (a generic function fn f<Scalar: Real>(x: Scalar): Scalar is callable with either).
  • Tests in std/gradient/tests/dual.test.trb, passing in the interpreter and in a compiled binary (the test file imports Fixed as std/linear/fixed and the dual by relative path, like std/geometry/tests):
    • the derivative of every rule above against its known closed form (at least: x * x, x / (1 + x), squareRoot, exponential, naturalLogarithm, sine, cosine, tangent, arcTangent, arcSine, arcTangentDivided, ** n, ** g with a constant and a variable exponent, absolute on both sides of zero, floor/round giving tangent zero);
    • the primal part of every operation equals the scalar's own answer;
    • a function generic over Real, written without any knowledge of Dual, differentiated by Dual<Float> and Dual<Fixed> (the Fixed case exact to the part, the Float case with a tolerance);
    • ==/</compare ignore the tangent, Show and TryFrom<String> round-trip the primal.
  • A geometry function from the library differentiates unchanged, e.g. Vector2<Dual<Float>>.length() has the expected tangent, and nested duals (Dual<Dual<Float>>) give a second derivative of x ** 3 equal to 6x. If either is blocked by a checker or monomorphisation gap, drop that test, state the gap in the PR body and file it as its own issue (no workaround in the library).
  • tools/gates.sh a green in forge CI (and b if CLAUDE.md asks for it for a new package); torb format --check clean on touched files; docs changed as above and skills/ regenerated.

Scope

One PR, one or two Conventional Commits, e.g. feat(std/gradient): add Dual for forward-mode differentiation and docs(compute): record the forward-mode Dual. No change to std/number, std/linear, the compiler or the VM; if the implementer finds that one is needed, stop and report which gap it is.

Seed

Touches the seed: no. A new package of ordinary TorbScript: no syntax change, no native, no driver flag, no std name the compiler looks up by string.

Dependencies

None open, verified against main on 2026-10-24: Real, Signed, Numeric, Power and Power<Int64> exist in std/number; Fixed carries Real in std/linear; TENSOR.md is merged (#438) and its section 9 is compatible with this design. #82 (std/tensor) is a sibling, not a prerequisite. Blocks #86 (together with #85).

Moved from the planning lists of the docs; the design record stays the specification.

## Context Forward-mode automatic differentiation as a dual number: `Dual<Scalar>` in a new package `std/gradient`, pure TorbScript, no foreign functions, no dependency on `std/tensor`. Because `Dual<Scalar>` is itself `Numeric`, `Signed` and `Real`, every function that is generic over `Real` (geometry, animation, physics, a `Tensor<Dual<Scalar>>`) can be differentiated without being written for it. Reverse mode (#86) joins the same package later. ## Source - [COMPUTE.md sections 1 and 2](https://git.torb.dev/torbscript/language/src/branch/main/docs/design/COMPUTE.md): "forward mode as `Dual<Scalar>` with `Real`" - [TENSOR.md section 9](https://git.torb.dev/torbscript/language/src/branch/main/docs/design/TENSOR.md): a `Tensor<Dual<Scalar>>` must be a tensor; `Compare` on a dual compares its primal part - `Real`, `Signed`, `Numeric` in `std/number/src/lib.trb`; the model implementor with a generic body is `Fixed` in `std/linear/src/fixed.trb` - ROADMAP, "Computation and machine learning" ## Design (decided by the loop, see the comment of 2026-10-24) - Package `std/gradient` (`project.trb` with `name = "std/gradient"`, `src/lib.trb`, `src/dual.trb`, `tests/dual.test.trb`), as COMPUTE.md names it. Reverse mode (#86) lands in the same package later. - `public type Dual<Scalar: Real = Float>` with the two fields `primal: Scalar` and `tangent: Scalar` (`primal` is the word TENSOR.md already uses). It declares `Signed, Real, Show, TryFrom<String, NumberParseError>`, and `Equals`/`Compare` as `Numeric` requires. - Construction: `Dual.constant(value)` (tangent zero), `Dual.variable(value)` (tangent one), the plain constructor for both fields, and `derivative(of: function, at: value): Scalar` as the one-call form (the exact signature is the implementer's, following the closest existing function-argument convention). - `Equals` and `Compare` look at the primal part only, so `==`, `<` and `compare` agree and a branch in a generic body (`if x < Scalar.zero`) takes the same path as the undifferentiated function. The tangent is read with the field. - Statics (`zero`, `one`, `pi`, `tau`, `e`, `epsilon`) are constants: the scalar's value with tangent zero. `TryFrom<String>` and `From<Scalar>` give constants. - Rules (the chain rule, in the scalar's own arithmetic; a generic body writes `Scalar.zero` / `Scalar.one`, never a literal): - `+ - *` and `/`: sum, difference, product and quotient rule; `%`: tangent `a' - b' * trunc(a / b)`; unary `-` and `absolute` (tangent `sign(primal) * tangent`, zero at zero). - `squareRoot`, `exponential`, `naturalLogarithm`, `logarithm(base:)`, the six trigonometric functions and `arcTangentDivided(by:)`: the textbook derivative. Where the scalar's own function is undefined or infinite (square root at zero, `arcSine` at +-1, logarithm at zero) the tangent is whatever the scalar's division gives; the scalar panics where the scalar panics (`arcSine`/`arcCosine` outside `[-1, 1]`). - `floor`, `ceiling`, `round`: tangent zero (the derivative almost everywhere). - `halved`, `doubled`, `radiansOfDegrees`, `degreesOfRadians`: scale the tangent like the primal. - `Power` (dual exponent): `f ** g`, tangent `f ** g * (g' * ln f + g * f' / f)`, but when `g.tangent` is zero use `g * f ** (g - 1) * f'`, so a negative base with a constant exponent has no logarithm of a negative number in it. `Power<Int64>`: `n * f ** (n - 1) * f'`. - Design record: COMPUTE.md gets a short section "Forward mode: `Dual`" (layout, the rules table above, the `==` decision, the pitfalls: discontinuities, `floor`) and its status line stops saying `std/gradient` does not exist; ROADMAP item 4 names `Dual` as built. ## Acceptance criteria - [ ] `std/gradient` exists as designed above; `Dual<Float>` and `Dual<Fixed>` both satisfy `Real` (a generic function `fn f<Scalar: Real>(x: Scalar): Scalar` is callable with either). - [ ] Tests in `std/gradient/tests/dual.test.trb`, passing in the interpreter and in a compiled binary (the test file imports `Fixed` as `std/linear/fixed` and the dual by relative path, like `std/geometry/tests`): - the derivative of every rule above against its known closed form (at least: `x * x`, `x / (1 + x)`, `squareRoot`, `exponential`, `naturalLogarithm`, `sine`, `cosine`, `tangent`, `arcTangent`, `arcSine`, `arcTangentDivided`, `** n`, `** g` with a constant and a variable exponent, `absolute` on both sides of zero, `floor`/`round` giving tangent zero); - the primal part of every operation equals the scalar's own answer; - a function generic over `Real`, written without any knowledge of `Dual`, differentiated by `Dual<Float>` and `Dual<Fixed>` (the `Fixed` case exact to the part, the `Float` case with a tolerance); - `==`/`<`/`compare` ignore the tangent, `Show` and `TryFrom<String>` round-trip the primal. - [ ] A geometry function from the library differentiates unchanged, e.g. `Vector2<Dual<Float>>.length()` has the expected tangent, and nested duals (`Dual<Dual<Float>>`) give a second derivative of `x ** 3` equal to `6x`. If either is blocked by a checker or monomorphisation gap, drop that test, state the gap in the PR body and file it as its own issue (no workaround in the library). - [ ] `tools/gates.sh a` green in forge CI (and `b` if `CLAUDE.md` asks for it for a new package); `torb format --check` clean on touched files; docs changed as above and `skills/` regenerated. ## Scope One PR, one or two Conventional Commits, e.g. `feat(std/gradient): add Dual for forward-mode differentiation` and `docs(compute): record the forward-mode Dual`. No change to `std/number`, `std/linear`, the compiler or the VM; if the implementer finds that one is needed, stop and report which gap it is. ## Seed Touches the seed: no. A new package of ordinary TorbScript: no syntax change, no native, no driver flag, no std name the compiler looks up by string. ## Dependencies None open, verified against `main` on 2026-10-24: `Real`, `Signed`, `Numeric`, `Power` and `Power<Int64>` exist in `std/number`; `Fixed` carries `Real` in `std/linear`; TENSOR.md is merged (#438) and its section 9 is compatible with this design. #82 (`std/tensor`) is a sibling, not a prerequisite. Blocks #86 (together with #85). _Moved from the planning lists of the docs; the design record stays the specification._
Author
Collaborator

[loop:torbscript] intake: in scope (author orion, label loop), source COMPUTE.md section 2, not a duplicate, no collision with a design record. Pure TorbScript beside std/tensor, no seed impact expected. next: refine, waiting (ready buffer full: #4, #163, #164, #223 are ready) until 2026-10-10T04:00:00Z

[loop:torbscript] intake: in scope (author orion, label loop), source COMPUTE.md section 2, not a duplicate, no collision with a design record. Pure TorbScript beside `std/tensor`, no seed impact expected. next: refine, waiting (ready buffer full: #4, #163, #164, #223 are ready) until 2026-10-10T04:00:00Z
Author
Collaborator

[loop:torbscript] claim refine by session:loop-torbscript-20261024 until 2026-10-24T02:15:21Z

[loop:torbscript] claim refine by session:loop-torbscript-20261024 until 2026-10-24T02:15:21Z
Author
Collaborator

[loop:torbscript] decided by the loop: the design of #83 is fixed in the issue body, and nothing in it is an owner question.

  • Location and name: std/gradient, because COMPUTE.md sections 1 and 2 put forward mode there ("std/gradient: forward mode as Dual<Scalar> with Real") and reverse mode (#86) joins it. Not std/tensor: #83 has no dependency on it.
  • Fields primal and tangent: TENSOR.md section 9 already says "primal part"; full words per the naming convention.
  • Equals and Compare on the primal only: TENSOR.md requires Compare on the primal (minimum/maximum); making == agree keeps ==, < and compare consistent, and a branch in a generic body takes the same path as the plain function. A structural == would make compare == equal and == false possible together.
  • floor/ceiling/round: tangent zero (derivative almost everywhere); absolute at zero: tangent zero; Power with a constant exponent avoids the logarithm of the base, so a negative base works. These are mathematical conventions, recorded in the design section COMPUTE.md gets.
  • Size: one package of about 400 to 600 lines plus tests, one PR with two commits (feat(std/gradient), docs(compute)). Not an epic. No performance question, nothing to measure.
  • Seed: not touched. No native, no syntax change, no compiler change; nested duals and Vector2<Dual<...>> are acceptance tests with a stated fallback if a checker gap shows up, not a prerequisite.
    Duplicate search (open and closed) for dual, gradient, forward mode, differentiation: no other issue covers this; #86 depends on it.
[loop:torbscript] decided by the loop: the design of #83 is fixed in the issue body, and nothing in it is an owner question. - Location and name: `std/gradient`, because COMPUTE.md sections 1 and 2 put forward mode there ("`std/gradient`: forward mode as `Dual<Scalar>` with `Real`") and reverse mode (#86) joins it. Not `std/tensor`: #83 has no dependency on it. - Fields `primal` and `tangent`: TENSOR.md section 9 already says "primal part"; full words per the naming convention. - `Equals` and `Compare` on the primal only: TENSOR.md requires `Compare` on the primal (`minimum`/`maximum`); making `==` agree keeps `==`, `<` and `compare` consistent, and a branch in a generic body takes the same path as the plain function. A structural `==` would make `compare == equal` and `== false` possible together. - `floor`/`ceiling`/`round`: tangent zero (derivative almost everywhere); `absolute` at zero: tangent zero; `Power` with a constant exponent avoids the logarithm of the base, so a negative base works. These are mathematical conventions, recorded in the design section COMPUTE.md gets. - Size: one package of about 400 to 600 lines plus tests, one PR with two commits (`feat(std/gradient)`, `docs(compute)`). Not an epic. No performance question, nothing to measure. - Seed: not touched. No native, no syntax change, no compiler change; nested duals and `Vector2<Dual<...>>` are acceptance tests with a stated fallback if a checker gap shows up, not a prerequisite. Duplicate search (open and closed) for dual, gradient, forward mode, differentiation: no other issue covers this; #86 depends on it.
Author
Collaborator

[loop:torbscript] refine: body rewritten to goal, design decisions, testable acceptance criteria, scope (std/gradient/*, COMPUTE.md, ROADMAP), seed statement (no) and dependencies verified against main (none open; Real/Signed/Numeric/Power in std/number, Fixed in std/linear, TENSOR.md merged); label ready set. Worker usage: about 60k tokens. Claim by session:loop-torbscript-20261024 released. next: implement

[loop:torbscript] refine: body rewritten to goal, design decisions, testable acceptance criteria, scope (`std/gradient/*`, COMPUTE.md, ROADMAP), seed statement (no) and dependencies verified against `main` (none open; `Real`/`Signed`/`Numeric`/`Power` in `std/number`, `Fixed` in `std/linear`, TENSOR.md merged); label `ready` set. Worker usage: about 60k tokens. Claim by session:loop-torbscript-20261024 released. next: implement
Author
Collaborator

[loop:torbscript] claim implement by session:loop-torbscript-20261024 until 2026-10-24T02:25:29Z

[loop:torbscript] claim implement by session:loop-torbscript-20261024 until 2026-10-24T02:25:29Z
Author
Collaborator

[loop:torbscript] implement: std/gradient with Dual<Scalar: Real = Float> and derivative(of:at:) implemented, 28 tests pass in the interpreter locally (nightly torb), format clean; PR #454 (two commits, Closes #83). Two deviations from the design, both forced by the checker and recorded in COMPUTE.md section 4: the field is rate (not tangent, which Real.tangent() already occupies in the member namespace), and there is no From<Scalar> (overlap with the blanket From<Never>, filed as #455). The Vector2<Dual<Float>> and nested-dual tests work, nothing dropped. Forge CI pending. next: review

[loop:torbscript] implement: `std/gradient` with `Dual<Scalar: Real = Float>` and `derivative(of:at:)` implemented, 28 tests pass in the interpreter locally (nightly torb), format clean; PR https://git.torb.dev/torbscript/language/pulls/454 (two commits, Closes #83). Two deviations from the design, both forced by the checker and recorded in COMPUTE.md section 4: the field is `rate` (not `tangent`, which `Real.tangent()` already occupies in the member namespace), and there is no `From<Scalar>` (overlap with the blanket `From<Never>`, filed as https://git.torb.dev/torbscript/language/issues/455). The `Vector2<Dual<Float>>` and nested-dual tests work, nothing dropped. Forge CI pending. next: review
Author
Collaborator

[loop:torbscript] implement: update 02:52 - the forge runs of PR #454 are still queued (status waiting, runner backlog; commit messages, what the change touches and pdisc are green, the gates have not started). Nothing to fix on the branch so far. next: review (a red gate on #454 goes to a fix round)

[loop:torbscript] implement: update 02:52 - the forge runs of PR #454 are still queued (status `waiting`, runner backlog; `commit messages`, `what the change touches` and `pdisc` are green, the gates have not started). Nothing to fix on the branch so far. next: review (a red gate on #454 goes to a fix round)
argus closed this issue 2026-10-24 02:21:49 +00:00
Sign in to join this conversation.
No labels
loop
ready
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#83
No description provided.