The full encoder backward
# grade this stage (from your course directory)
npx theinference test --stage full-chain-grad --watchAssemble the whole thing. Given a batch of (anchor, positive) pairs and a seeded init, run the full contrastive backward: loss → dScores → dQ/dP → through L2-norm, linear2, tanh, linear1, mean-pool, and scatter into the table rows. This is the intellectual peak of the course — every Jacobian you built, chained.
contrastiveBackward(qCaches, pCaches, dScores, params)
→ { dE, dW1, db1, dW2, db2 }
dE is SPARSE: only rows for tokens that appeared in the batch are nonzero.
What to build
Implement the full encoder backward
Wire up contrastiveBackward so the encoder's forward caches its intermediates (pooled, h1, a1, h2, norm, ids, mask) and the backward produces all five parameter gradients. The graded adapter runGradW2(anchors, positives, seed) re-inits from seed, runs forward+backward on the fixture batch, and returns dW2.
export function runGradW2(
anchors: string[], positives: string[], seed: number,
): number[][] {
// initParams(seed) → encode both sides → infonceBackward → contrastiveBackward → .W2
}
This is the full composed chain — L2-norm + two linears + tanh + masked pool + sparse table scatter — so a long float32 reduction accumulates differently across implementations. The grader compares dW2 to the reference with allclose, atol 1e-4, rtol 1e-3 (looser than the single-op checks), and the reference itself is double-anchored by finite differences (rel error ≤ 1e-2). A correct gradient clears both; a sign error or a missing L2-norm term fails both.
Only the rows of E for tokens that actually appeared in the batch get a nonzero gradient. Each non-pad token row receives dPooled / (#non-pad) from the mean-pool backward. Tokens that never appear stay exactly zero — initializing dE to a full zero matrix and scattering into it is the clean way.
If runGradW2 is off, run gradCheck over W2 against the InfoNCE loss. The finite-diff estimate is assumption-free — if it disagrees with your analytic dW2, the analytic one is wrong (it almost always is).