Evidence

Correctness

The evidence for grad and for the checker.

Edit this page on GitHub

Correctness

What twill's two central claims mean, how each is tested, and where each one stops. The claims are that grad computes the derivative and that the shape checker reports only real mistakes. Both are tested here against something other than themselves.

Every number below is regenerated by:

go test ./internal/tensor/  -run TestGradient -v -count=1
go test ./internal/checker/ -run TestChecker  -v -count=1

1. Gradient checking over the full operator set

What is checked

Every differentiable operator in internal/tensor, in 105 cases covering the broadcasting regimes, both axes of a non-square matrix, and the combinations where a bug shows up only in composition. The list is not maintained by hand. TestGradientCheckCoversEveryOperator parses the package's own source with go/parser, collects every exported function that takes or returns a *Tensor, and fails if one has neither a gradient-check case nor an entry in nonDifferentiable saying why it has none.

That closure property is the difference between "the full operator set" as a claim and as a fact. A new operator cannot be added without someone deciding, in writing, whether it carries a gradient.

Current coverage: 64 differentiable operators checked, 31 declared non-differentiable, 95 exported in total.

The non-differentiable list is not a way of quietly excusing operators. It holds the index-valued ones (argmax, argmin, argsort, argtopk, whose outputs are positions, integer and locally constant), the boolean comparisons, the two quantisers (QuantizeI8 and QuantizeI4 are step functions, though the gradient through the product they produce is checked), Cast, and the shape and dtype helpers that are not operators at all.

The method

For a case producing out = op(x), the harness differentiates the scalar

L(x) = sum(w * out)

for a fixed deterministic cotangent w, and compares reverse mode against a Richardson-extrapolated central difference:

D(h) = (L(x+h) - L(x-h)) / 2h            truncation O(h^2)
D*   = (4*D(h/2) - D(h)) / 3             truncation O(h^4)

Three details are load-bearing.

The cotangent is not all ones. An all-ones w makes an entire class of index-shuffling bugs invisible, because a permutation of the output has the same sum as the output. Every transpose, flip, roll, sort, gather and concat case would pass while scattering its gradient to the wrong elements. w is sin(1.7i + 0.3) * (1 + 0.25(i mod 5)): deterministic, no seed to get out of step, and irregular in sign and magnitude.

The extrapolation is what makes a tight tolerance honest. A plain central difference at the best available step carries roughly 1e-10 relative error in f64, which is close enough to a real small-magnitude gradient bug that a failure would be ambiguous. D* lands near 1e-12, so a disagreement above 1e-7 is a defect and not the difference method breathing. Measured error across the cases runs from 1e-13 to 1e-11, leaving four orders of headroom over the observed noise floor.

The step is scaled to the point, h = 1e-4 * max(1, |x_i|), so a coordinate of size 1000 is not probed with an absolute step its own rounding swallows.

Where finite differences cannot adjudicate, and what is done instead

relu, abs, clip, maximum, minimum, max, min, median, sort, topk, cummax, cummin and maxpool2d are piecewise. At a kink the derivative does not exist, and a central difference straddling one reports the average of the two one-sided slopes, which is not what any autodiff system returns. Reporting that as a defect would be reporting a property of the difference method.

So every case for a piecewise operator is sited away from its kinks: nothing near zero for relu, nothing near a bound for clip, no ties in any comparison or ordering. The kinks themselves are then asserted directly in TestGradientKinkConventions, because the convention twill picks is a decision and should be a stable one:

At twill returns PyTorch returns
relu'(0) 0 0
clip gradient exactly at a bound 0 passes through
maximum(a, b) gradient at a tie all to the left operand split evenly
max(t) gradient at a tie first occurrence takes it split evenly

Three of these differ from PyTorch. None is wrong: the subgradient at a kink is a set, and any element of it is defensible. They are written down so that changing one is a deliberate act rather than a regression nobody notices.

The result

All 105 cases agree. Worst relative error by case, highest first:

case worst relative error
composite/mlp-layer 6.4e-08
conv2d/multi-channel 6.1e-10
composite/attention-scores 4.6e-10
qlinear-i4/activation 3.4e-10
logsumexp/axis-1 1.0e-10
everything else below 1e-10

composite/mlp-layer is two orders above the rest and is not a defect. It is a softmax over a tanh over a linear layer, so the finite difference is taken through a function whose output is bounded in (0, 1) and whose gradient is correspondingly small; the difference quotient loses digits to cancellation before any derivative is formed. It sits below the 1e-7 tolerance with room, and the tolerance is uniform rather than tuned per case.

One case carries a deliberately loosened tolerance, composite/long-cumprod at 1e-5, and the reason is stated in the source: a twelve-element cumulative product spans three orders of magnitude, so the forward pass is ill-conditioned before differentiation begins. It is loosened rather than removed, and the loosening is visible.

What this found

One real defect, and it is the headline of docs/BUGS.md: QLinear and QLinear4 returned tensors with no autodiff graph behind them, so grad through a quantised linear layer answered zero for every upstream parameter while still returning the right value. Fixed in 5344302.

It is worth being precise about why exhaustiveness rather than representativeness found it. The two quantised kernels were the least-exercised operators in the package. A gradient check over the operators someone thought to check would never have reached them, and the defect they had was the worst in the package: not a wrong number, but a plausible one.

What this does not check

Finite differences validate the derivative of the function the interpreter actually computes. If a forward kernel is wrong, the gradient check will happily confirm that its derivative is computed correctly. Forward correctness rests elsewhere: on the golden corpus, on the differential comparison against the self-hosted implementation, and on the closed forms the examples are measured against, such as the Black-Scholes price, delta and vega that examples/montecarlo_option.tw reproduces to four figures.


2. Shape checker soundness

The claim, stated precisely

The checker makes one claim and explicitly declines the other.

It claims: every diagnostic it reports is a real mistake. No false positives.

It does not claim: that a clean check means the program runs. The package comment says it "reports a diagnostic only when a mismatch is certain" and docs/design.md calls the bias toward precision over recall deliberate.

These are not two halves of one property, and conflating them is how a correctness section ends up asserting something untrue. What follows tests the first and measures the second. I am not going to argue that the checker is sound in the sense of accepting only correct programs, because it is not, and the counterexample is six lines.

The checker accepts programs that fail at runtime

let n = len([1.0, 2.0, 3.0])
let A = zeros(2, n)
let x = zeros(2)
let y = A @ x
print(y)
$ twill check unsound.tw
unsound.tw: no shape problems found

$ twill run unsound.tw
unsound.tw:4: runtime error: shape mismatch in @: [2 3] @ [2] (inner 3 != 2)
  4 | let y = A @ x

The mechanism is exactly the design. The checker cannot fold len([1.0, 2.0, 3.0]) to 3, so n types as Unknown, so zeros(2, n) types as Unknown, and an Unknown operand makes the @ undecidable. It stays silent, as designed, and the mismatch reaches the runtime.

This is not an obscure corner. Any shape derived from data read at runtime, from read_csv, from a length, or from a loop-carried value has the same property, and those are ordinary things for a program to do.

There is a second entrance to the same gap that has nothing to do with runtime data, and it is worth naming because it is easy to walk into: grad is a shape barrier. A lambda applied directly is checked. The identical lambda wrapped in grad is not.

let h = fn(v) = sum(zeros(2, 3) @ v)
print(h(zeros(2)))
$ twill check direct.tw
direct.tw:1: shape error: shape mismatch in @: [2, 3] @ [2] (inner 3 != 2)
  1 | let h = fn(v) = sum(zeros(2, 3) @ v)
let g = grad(fn(v) = sum(zeros(2, 3) @ v))
print(g(zeros(2)))
$ twill check viagrad.tw
viagrad.tw: no shape problems found

$ twill run viagrad.tw
viagrad.tw:1: runtime error: shape mismatch in @: [2 3] @ [2] (inner 3 != 2)
  1 | let g = grad(fn(v) = sum(zeros(2, 3) @ v))

Same body, same argument, same mistake; the only difference is grad. In the first program the checker pushes the applied argument's [2] into v and decides the @. In the second, grad returns a function whose parameter shape the checker does not relate to the shape of the lambda it was given, so v types as Unknown and the @ becomes undecidable.

This is the same Unknown-propagation mechanism as above rather than a new one, but it lands in the place twill most advertises, which makes it the version worth knowing. It is a completeness gap and not a soundness one: the checker still says nothing false. Relating grad(f)'s parameter to f's would close it and is a bounded change, since grad preserves the shape of the argument it differentiates with respect to.

Annotating the lambda parameter does not close it either, because the argument that goes wrong is supplied at the application of g, not inside the body.

The claim it does make, tested against the interpreter

Testing "no false positives" on hand-written examples proves little, since the examples are written by the same person who wrote the rules. So it is tested differentially against the interpreter over generated programs.

internal/checker/soundness_test.go generates programs from a small grammar: matmuls, matrix-vector products, elementwise combinations with and without broadcasting, reshapes, concats, calls to shape-annotated functions, and chains where a mismatch is several operations from its cause. Every dimension is a literal drawn from {1, 2, 3, 4}, so the checker has everything it needs to decide, roughly half the programs are broken, and 1 is in the set because it broadcasts against anything and is the case a naive equality rule gets wrong. The seed is fixed, so a disagreement found once can be found again.

Each program is both checked and run, and the two outcomes are treated asymmetrically:

  • Checker reports a diagnostic and the program runs. A false positive. Breaks the claim. Fails the test.
  • Checker is silent and the program fails at runtime. A false negative. Permitted by design. Counted and reported, so the number is visible rather than merely admitted to.

Result over 4,000 generated programs:

count
rejected by the checker, and every one really is broken 2,646
accepted, ran clean 1,354
accepted, failed at runtime 0

Zero false positives. The claim the checker makes holds over 4,000 programs it did not see while it was being written.

And the completeness it declines to claim, measured

Because every generated program has literal dimensions throughout, a shape error in one is statically decidable in principle. So the same corpus measures what fraction the checker actually decides, which turns "best-effort" from an adjective into a number.

Of 2,646 statically decidable shape errors, the checker caught 2,646, or 100%.

TestCheckerCatchesWhatItCanSee asserts a 95% floor rather than the exact figure, so improving the checker does not break the test and regressing it does.

Reading that honestly

100% on this corpus is a real result and a bounded one. It says that where shapes are statically knowable the checker does not miss, and the grammar covers the operations a numeric program is mostly made of. It does not say the checker is complete, because the corpus is by construction the decidable case. The gap between the two is exactly the example at the top of this section, and it is not narrowing: it is where the design put it.

The corpus is also small in another way. A generated program is at most five lines. It does not exercise nested closures, records, loops that reshape, or grad, all of which the checker leaves Unknown. Extending the generator is the obvious next step and would lower the 100%, which is the point of measuring it.

The other evidence

TestExamplesRunClean in internal/interp/examples_test.go shape-checks and runs every program in examples/, asserting zero diagnostics and a clean run. That is the no-false-positive property on 23 real programs rather than generated ones, and it has been in the suite since before this document.


3. What is verified, in one place

Property Tested by Result
grad matches finite differences, every operator TestGradientCheckFullOperatorSet 105/105, worst 6.4e-08
No operator escapes the gradient check TestGradientCheckCoversEveryOperator 64 checked, 31 exempt with reasons, closed against the source
Kink conventions are stable TestGradientKinkConventions 4 pinned, 3 differ from PyTorch by choice
Checker reports no false positives TestCheckerReportsNoFalsePositives 0 in 4,000
Checker catches decidable errors TestCheckerCatchesWhatItCanSee 2,646/2,646
grad is a shape barrier (a known open gap) TestGradIsAShapeBarrier direct case caught, grad case not
Examples check clean and run TestExamplesRunClean all
Forward numerics match the self-hosted implementation the differential harness, tools/diff/ 443 files on check, 89 on fmt

The gate, and why it is the whole of CI

make check runs what CI runs: build, gofmt, vet, the full test suite, and the race pass with CI's own flags and timeout. make ci adds the two linters, which are separate only because they fetch a tool and need the network.

This is written down because the gate was wrong once and it cost a release. check was vet test plus a gofmt check, and was commented "What CI runs", which it had stopped being: CI also ran go test -race -short -timeout 25m and a lint job. 1.6.5 was tagged on a green local run and its CI failed, because the test suite had grown past the race pass's budget and nothing local was measuring that.

The failure was a timeout rather than a data race, and the cause is worth knowing for anyone adding to the corpus: two of the examples train a small model and take fifteen seconds each where every other example is milliseconds, and the corpus is walked twice -- once as written, once as formatted. Under the race detector that was most of the budget. Those two, and the self-hosted differential runs, are skipped when -short is passed, which is the flag the race pass uses and the only pass that uses it. The ordinary run has no -short and runs all of them on every build.

So: a new example that trains anything belongs in heavyExamples, and a new test that spawns the self-hosted compiler belongs behind skipUnderShort. Neither reduces what is checked; both keep the race pass measuring what it is for, which is the parallel tensor kernels rather than a tree walk over a twill source file.