Conversation
|
Added |
|
🤖😈 Adversarial review — #74 (transposes, Before the defects, credit where it's due. I ran a finite-difference sweep of 20 awkward shapes through 1. Repeated dims → silently wrong gradient, then row explosion
2. The NULL-row convention is emergent, not pinnedFor
v1 fixed exactly this class of bug in #60 and pinned NULL. Design §5 requires the NULL convention to be pinned, not emergent. Separately, 3.
|
…ReLU tests A rank filter in a region is recomputed in the backward pass, and SQL leaves ties unordered, so an engine could keep a different row than the forward pass kept and send it the gradient. The reviewer could not break DataFusion, but M5's engine ranks in parallel. A window whose PARTITION BY and ORDER BY do not include every dim of the rows it ranks is now refused, naming the fix: add the dims to ORDER BY. Tests: AVG gives a NULL row a NULL gradient (the convention #74 pins); ReLU written as CASE and as greatest matches finite differences on DataFusion. From the adversarial review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvszJhH8pn8H9G69wEPMU2
From the adversarial review of #79: - ad::run runs the program's checks first; a failed check is AdError::InvalidWrt. ad::run_checks is public for step-by-step callers. - Each program's tables carry its own prefix (from #74), so a user's table and another program's tape are left alone (the reviewer's tests/ad_isolation.rs now passes). Once the gradients are written, run drops the saved aggregates and cotangents; ad::release drops the value and gradients too. - ddx-ad hides forward, emit, expr and relation (and Forward, Elementwise, Functions, Extensions, Table) from the docs: they are public only for this workspace. bind_reads and unbound_reads move to the crate root. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvszJhH8pn8H9G69wEPMU2
…n drops them From the adversarial review of #83: sql_all kept each gradient as `__ddx_grad_{p}_{parts joined by _}`, so `a_b.c` and `a.b_c` shared a name and one call read the other's gradient, and `p` restarting at 0 let two concurrent calls on one context overwrite each other. Each program's tables now carry the program's own prefix (#74), so sql_all reads the gradients where the program wrote them, and releases every program once the statements are planned: the DataFrames hold the tables they read, and the catalog keeps nothing. The WITH RECURSIVE refusal is now documented as ddx's own limit, separate from DataFusion's (#88). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvszJhH8pn8H9G69wEPMU2
|
All addressed in ab69269, and the three tests in 3398f43 pass.
Smaller:
|
…n drops them From the adversarial review of #83: sql_all kept each gradient as `__ddx_grad_{p}_{parts joined by _}`, so `a_b.c` and `a.b_c` shared a name and one call read the other's gradient, and `p` restarting at 0 let two concurrent calls on one context overwrite each other. Each program's tables now carry the program's own prefix (#74), so sql_all reads the gradients where the program wrote them, and releases every program once the statements are planned: the DataFrames hold the tables they read, and the catalog keeps nothing. The WITH RECURSIVE refusal is now documented as ddx's own limit, separate from DataFusion 54's recursive-CTE bug. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvszJhH8pn8H9G69wEPMU2
27444cf to
b8b2d98
Compare
…ReLU tests A rank filter in a region is recomputed in the backward pass, and SQL leaves ties unordered, so an engine could keep a different row than the forward pass kept and send it the gradient. The reviewer could not break DataFusion, but M5's engine ranks in parallel. A window whose PARTITION BY and ORDER BY do not include every dim of the rows it ranks is now refused, naming the fix: add the dims to ORDER BY. Tests: AVG gives a NULL row a NULL gradient (the convention #74 pins); ReLU written as CASE and as greatest matches finite differences on DataFusion. From the adversarial review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvszJhH8pn8H9G69wEPMU2
A three-hour, three-region soak (about 55,800 cases) against the stack with #74's batched cotangents, #76's jitter rule for ties and #79's ShortNames consumer, plus a benchmark of how a program's cost scales. Harness: - The forward-mode oracle mirrors #76's rule: each relation records whether its value can jitter (it reads an aggregate's output; a table's values and elementwise functions of them cannot), MAX/MIN tie tests use the 8-ulp window only there, and a disagreement is a [tie-rule] failure. Agreeing with exact equality (jax.grad's convention) always passes. - `ulps` mode: parameters a few ulps apart, near-ties that are not ties. Soak-only while the finding below is open. DDX_V2_WRT_ALL differentiates every parameter table a loss reads. - A twin whose loss varies from run to run (sin of 3e80) is tallied as ill-conditioned; DDX_V2_DEBUG compares each twin with its relation. - tests/ad_perf.rs: forward, build and run times and plan sizes by family (nn.py's MLP by samples, a matrix product by rows, layers, residual blocks, fan-in, depth within a region, a reused CTE). Found, pinned in ad_findings.rs: - #76's rule gives constant data the tolerance, but a table outside wrt is as repeatable as a wrt table: MAX(p·d) over products 2 ulps apart still gives (0.5, 0.5), not (0, 1), and a table's gradient changes with which other tables are differentiated (the soak's ties region, 59 seeds). - A filter above an anti-join is lost when the region is recomputed: with push_down_filter off, gradient reaches a row the query excludes. - Upstream: DataFusion's grouped MAX skips NaN for one group and returns it for another in the same query, depending on merge order. ShortNames and the batched cotangents: no failure in about 37,000 cases in the general and scale regions (88,000 exact comparisons). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012NE18ox5ivwTc7ZSbHZUGu
|
😈🧪 Adversarial tester: ✅ approved for correctness. Reviewed at |
All seven now pass on the stack below: the MAX/MIN near-tie (#76); fan-in, the stack overflow and vjp's cotangent keys (#74); the deep chain (#79); comments in grad(…) (#83); the simple CASE (#71). So: - ad_findings.rs runs them as ordinary tests; the stack-overflow repro's child process no longer passes --ignored, which would now filter it out. - The plan-size measures (consumed_plan_bytes, cost_checks) consume a step as ad::run does, through ad::logical_plan, which names computed columns briefly; DataFusion's default consumer names them by their expressions. - The cost bound and the SQL-text fuzz run in the PR gate again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvszJhH8pn8H9G69wEPMU2
A three-hour, three-region soak (about 55,800 cases) against the stack with #74's batched cotangents, #76's jitter rule for ties and #79's ShortNames consumer, plus a benchmark of how a program's cost scales. Harness: - The forward-mode oracle mirrors #76's rule: each relation records whether its value can jitter (it reads an aggregate's output; a table's values and elementwise functions of them cannot), MAX/MIN tie tests use the 8-ulp window only there, and a disagreement is a [tie-rule] failure. Agreeing with exact equality (jax.grad's convention) always passes. - `ulps` mode: parameters a few ulps apart, near-ties that are not ties. Soak-only while the finding below is open. DDX_V2_WRT_ALL differentiates every parameter table a loss reads. - A twin whose loss varies from run to run (sin of 3e80) is tallied as ill-conditioned; DDX_V2_DEBUG compares each twin with its relation. - tests/ad_perf.rs: forward, build and run times and plan sizes by family (nn.py's MLP by samples, a matrix product by rows, layers, residual blocks, fan-in, depth within a region, a reused CTE). Found, pinned in ad_findings.rs: - #76's rule gives constant data the tolerance, but a table outside wrt is as repeatable as a wrt table: MAX(p·d) over products 2 ulps apart still gives (0.5, 0.5), not (0, 1), and a table's gradient changes with which other tables are differentiated (the soak's ties region, 59 seeds). - A filter above an anti-join is lost when the region is recomputed: with push_down_filter off, gradient reaches a row the query excludes. - Upstream: DataFusion's grouped MAX skips NaN for one group and returns it for another in the same query, depending on merge order. ShortNames and the batched cotangents: no failure in about 37,000 cases in the general and scale regions (88,000 exact comparisons). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012NE18ox5ivwTc7ZSbHZUGu
All seven now pass on the stack below: the MAX/MIN near-tie (#76); fan-in, the stack overflow and vjp's cotangent keys (#74); the deep chain (#79); comments in grad(…) (#83); the simple CASE (#71). So: - ad_findings.rs runs them as ordinary tests; the stack-overflow repro's child process no longer passes --ignored, which would now filter it out. - The plan-size measures (consumed_plan_bytes, cost_checks) consume a step as ad::run does, through ad::logical_plan, which names computed columns briefly; DataFusion's default consumer names them by their expressions. - The cost bound and the SQL-text fuzz run in the PR gate again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvszJhH8pn8H9G69wEPMU2
A three-hour, three-region soak (about 55,800 cases) against the stack with #74's batched cotangents, #76's jitter rule for ties and #79's ShortNames consumer, plus a benchmark of how a program's cost scales. Harness: - The forward-mode oracle mirrors #76's rule: each relation records whether its value can jitter (it reads an aggregate's output; a table's values and elementwise functions of them cannot), MAX/MIN tie tests use the 8-ulp window only there, and a disagreement is a [tie-rule] failure. Agreeing with exact equality (jax.grad's convention) always passes. - `ulps` mode: parameters a few ulps apart, near-ties that are not ties. Soak-only while the finding below is open. DDX_V2_WRT_ALL differentiates every parameter table a loss reads. - A twin whose loss varies from run to run (sin of 3e80) is tallied as ill-conditioned; DDX_V2_DEBUG compares each twin with its relation. - tests/ad_perf.rs: forward, build and run times and plan sizes by family (nn.py's MLP by samples, a matrix product by rows, layers, residual blocks, fan-in, depth within a region, a reused CTE). Found, pinned in ad_findings.rs: - #76's rule gives constant data the tolerance, but a table outside wrt is as repeatable as a wrt table: MAX(p·d) over products 2 ulps apart still gives (0.5, 0.5), not (0, 1), and a table's gradient changes with which other tables are differentiated (the soak's ties region, 59 seeds). - A filter above an anti-join is lost when the region is recomputed: with push_down_filter off, gradient reaches a row the query excludes. - Upstream: DataFusion's grouped MAX skips NaN for one group and returns it for another in the same query, depending on merge order. ShortNames and the batched cotangents: no failure in about 37,000 cases in the general and scale regions (88,000 exact comparisons). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012NE18ox5ivwTc7ZSbHZUGu
All seven now pass on the stack below: the MAX/MIN near-tie (#76); fan-in, the stack overflow and vjp's cotangent keys (#74); the deep chain (#79); comments in grad(…) (#83); the simple CASE (#71). So: - ad_findings.rs runs them as ordinary tests; the stack-overflow repro's child process no longer passes --ignored, which would now filter it out. - The plan-size measures (consumed_plan_bytes, cost_checks) consume a step as ad::run does, through ad::logical_plan, which names computed columns briefly; DataFusion's default consumer names them by their expressions. - The cost bound and the SQL-text fuzz run in the PR gate again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvszJhH8pn8H9G69wEPMU2
A three-hour, three-region soak (about 55,800 cases) against the stack with #74's batched cotangents, #76's jitter rule for ties and #79's ShortNames consumer, plus a benchmark of how a program's cost scales. Harness: - The forward-mode oracle mirrors #76's rule: each relation records whether its value can jitter (it reads an aggregate's output; a table's values and elementwise functions of them cannot), MAX/MIN tie tests use the 8-ulp window only there, and a disagreement is a [tie-rule] failure. Agreeing with exact equality (jax.grad's convention) always passes. - `ulps` mode: parameters a few ulps apart, near-ties that are not ties. Soak-only while the finding below is open. DDX_V2_WRT_ALL differentiates every parameter table a loss reads. - A twin whose loss varies from run to run (sin of 3e80) is tallied as ill-conditioned; DDX_V2_DEBUG compares each twin with its relation. - tests/ad_perf.rs: forward, build and run times and plan sizes by family (nn.py's MLP by samples, a matrix product by rows, layers, residual blocks, fan-in, depth within a region, a reused CTE). Found, pinned in ad_findings.rs: - #76's rule gives constant data the tolerance, but a table outside wrt is as repeatable as a wrt table: MAX(p·d) over products 2 ulps apart still gives (0.5, 0.5), not (0, 1), and a table's gradient changes with which other tables are differentiated (the soak's ties region, 59 seeds). - A filter above an anti-join is lost when the region is recomputed: with push_down_filter off, gradient reaches a row the query excludes. - Upstream: DataFusion's grouped MAX skips NaN for one group and returns it for another in the same query, depending on merge order. ShortNames and the batched cotangents: no failure in about 37,000 cases in the general and scale regions (88,000 exact comparisons). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012NE18ox5ivwTc7ZSbHZUGu
All seven now pass on the stack below: the MAX/MIN near-tie (#76); fan-in, the stack overflow and vjp's cotangent keys (#74); the deep chain (#79); comments in grad(…) (#83); the simple CASE (#71). So: - ad_findings.rs runs them as ordinary tests; the stack-overflow repro's child process no longer passes --ignored, which would now filter it out. - The plan-size measures (consumed_plan_bytes, cost_checks) consume a step as ad::run does, through ad::logical_plan, which names computed columns briefly; DataFusion's default consumer names them by their expressions. - The cost bound and the SQL-text fuzz run in the PR gate again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvszJhH8pn8H9G69wEPMU2
A three-hour, three-region soak (about 55,800 cases) against the stack with #74's batched cotangents, #76's jitter rule for ties and #79's ShortNames consumer, plus a benchmark of how a program's cost scales. Harness: - The forward-mode oracle mirrors #76's rule: each relation records whether its value can jitter (it reads an aggregate's output; a table's values and elementwise functions of them cannot), MAX/MIN tie tests use the 8-ulp window only there, and a disagreement is a [tie-rule] failure. Agreeing with exact equality (jax.grad's convention) always passes. - `ulps` mode: parameters a few ulps apart, near-ties that are not ties. Soak-only while the finding below is open. DDX_V2_WRT_ALL differentiates every parameter table a loss reads. - A twin whose loss varies from run to run (sin of 3e80) is tallied as ill-conditioned; DDX_V2_DEBUG compares each twin with its relation. - tests/ad_perf.rs: forward, build and run times and plan sizes by family (nn.py's MLP by samples, a matrix product by rows, layers, residual blocks, fan-in, depth within a region, a reused CTE). Found, pinned in ad_findings.rs: - #76's rule gives constant data the tolerance, but a table outside wrt is as repeatable as a wrt table: MAX(p·d) over products 2 ulps apart still gives (0.5, 0.5), not (0, 1), and a table's gradient changes with which other tables are differentiated (the soak's ties region, 59 seeds). - A filter above an anti-join is lost when the region is recomputed: with push_down_filter off, gradient reaches a row the query excludes. - Upstream: DataFusion's grouped MAX skips NaN for one group and returns it for another in the same query, depending on merge order. ShortNames and the batched cotangents: no failure in about 37,000 cases in the general and scale regions (88,000 exact comparisons). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012NE18ox5ivwTc7ZSbHZUGu
All seven now pass on the stack below: the MAX/MIN near-tie (#76); fan-in, the stack overflow and vjp's cotangent keys (#74); the deep chain (#79); comments in grad(…) (#83); the simple CASE (#71). So: - ad_findings.rs runs them as ordinary tests; the stack-overflow repro's child process no longer passes --ignored, which would now filter it out. - The plan-size measures (consumed_plan_bytes, cost_checks) consume a step as ad::run does, through ad::logical_plan, which names computed columns briefly; DataFusion's default consumer names them by their expressions. - The cost bound and the SQL-text fuzz run in the PR gate again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvszJhH8pn8H9G69wEPMU2
transpose.rs gives each relational primitive its rule, as JAX gives each primitive one: map (the chain rule through a projected expression, with ddx-core's partials), select (the recomputed rows are the kept rows), broadcast (a join's transpose sums the cotangent by the input's dims) and reduce (a grouped SUM's transpose broadcasts the group's cotangent). A matrix product is these composed. COUNT's transpose is zero. program.rs puts grad and vjp on top. vjp pulls a cotangent relation, supplied as __ddx_cotangent with the output's dims and values, back to the wrt columns. grad is vjp of a loss seeded with 1, and refuses an output that is not one row and one column. Either returns a BackwardProgram: the saved aggregates, the value, the cotangents, and a dense gradient per wrt table, shaped like the table. Contributions to a relation read twice are added with UNION ALL and SUM. Tests run programs on DataFusion and compare every gradient entry with a finite difference computed by DataFusion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvszJhH8pn8H9G69wEPMU2
grad required the output to read no table or grouped aggregate with dims, but skipped constant inputs, so a one-row loss cross-joined to a many-row data table was accepted, and its rows' sum was differentiated. grad now requires every input the output reads to be one row by the plan's own evidence (an ungrouped aggregate, constant data shown to be one row), and otherwise raises NotScalar. Found by /code-review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvszJhH8pn8H9G69wEPMU2
…name collision Adversarial review: this test fails on purpose and documents a defect; see the PR comment for the evidence and suggested fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…yped gradients Four defects from the adversarial review, all in how results are keyed and named rather than in the calculus. The reviewer's failing tests now pass. - Rows that share dims got their summed gradient (6, 6, 6 for 2, 4, 6), and an SGD join then multiplied rows. A plan cannot show a key, so each program now carries checks, plans that must return no rows, one per wrt table (dims GROUP BY ... HAVING COUNT(*) > 1). An adapter runs them first and refuses the program with the check's message. - The gradient at a NULL row was 1, 0 or 0.5 depending on how the loss was written. It is now pinned, as v1 pins it (#60): NULL where the value is NULL; 0 where no gradient reached a row. - Step names were global and gradient names joined name parts with _, so a_b.c and a.b_c shared a step. Every name now starts with a prefix fresh to the program (__ddx_{id}_), and gradient steps are numbered. VALUE and COTANGENT give way to BackwardProgram::value and ::cotangent_table. - A REAL table's gradient came back DOUBLE. Each gradient is now cast to its value column's type. grad's one-row refusal now says it is a limit of what the plan shows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvszJhH8pn8H9G69wEPMU2
…o coalesce From the v2 soak (#89, #90, #92): - SUM skips a row whose argument is NULL, but the reduce rule still gave that row the group's cotangent, so its other inputs got gradient: the p of SUM(p + q) with q NULL, or SUM(d - p) with a NULL in constant data. The seed is now NULL where the argument is. - A gradient step was named after its table case and all (`…_grad_0_W`); DataFusion folds the unquoted name it is registered under, so it could not be found again. Step names are lower case. - The gradient step used coalesce, which DataFusion 54 runs only after SimplifyExpressions has rewritten it; it is now a CASE, so a program runs on a context without that rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvszJhH8pn8H9G69wEPMU2
From a v2 soak on the fixed stack (seed 200886): vjp seeded every output row with the caller's cotangent, a row whose value is NULL included. In `p + SUM(p)` with p NULL at row 1, that row's cotangent reached the SUM and gave the other rows gradient, where grad(SUM(s · c)), which skips the NULL row as the reduce rules now do, gives none. vjp's seed is now NULL where the output value is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvszJhH8pn8H9G69wEPMU2
A NULL seed means no contribution: a row an aggregate skips, and (since #76's fix) a row that does not attain an extreme. But a column read by two places summed its cotangent terms with +, so NULL + t lost t: in SUM(p + q) + SUM(p) with q NULL at a row, p there got no gradient at all, and in AVG(v) + 0.5 * MAX(v) (found by the v2 soak's bounded suite) every row but the maximum lost its AVG share. The terms are now folded so a NULL one is skipped, each term a column first so the fold repeats only field references. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvszJhH8pn8H9G69wEPMU2
… keys From the v2 soak's round two (#98): - Fan-in was exponential. A value read by N columns gets N cotangent terms, which the NULL-skipping fold summed by nesting the running sum, naming it three times a step. DataFusion's Substrait consumer names a column by its expression, so ten readers made a 117 MB plan and twelve did not fit in 4 GB. The terms are now one flat sum: NULL only if every term is, each term in it a fixed number of times. - Deep plans overflowed the stack. The backward step appended one projection per cotangent column, so a wide region (a CTE read twice per layer, nine layers) nested hundreds of relations deep, and cloning it on a 2 MB stack (a tokio worker's) aborted the process. Cotangents are now projected in batches, a new one only where a term reads a column still in the current batch, so the step is as deep as the chain of columns reading each other, not as the region is wide. - vjp joined a cotangent whose keys repeat as it was, doubling that row's gradient. Its program now carries a check that the cotangent has one row per key, like a wrt table's dims check; the check reads the cotangent table unbound, so the harness binds check reads as it binds a step's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvszJhH8pn8H9G69wEPMU2
…s matched as documented From the composability review on #74: - grad_with/vjp_with take Options: a ddx-core engine and an optional namespace. With a namespace the same plan gives the same program byte for byte, for golden tests and caches; it must keep the reserved __ddx_ prefix (AdError::InvalidOptions otherwise). - BackwardProgram, Step, Gradient and Check are non_exhaustive: read, not built, outside the crate, so a new field is not a semver break. - decode_plan and Step/Check::plan_bytes let a host that holds plans as protobuf bytes use ddx without linking the same substrait crate. - How a wrt table name matches is written down on ColumnRef::table. A bare name matching tables in two schemas is refused with both listed, and a qualified name the plan lacks hints at the bare one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvszJhH8pn8H9G69wEPMU2
__ddx_a ran into its step names (__ddx_asaved_0), and an engine folds __ddx_Foo_ to lower case, which gradient_name already avoids (composability re-review on #74). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvszJhH8pn8H9G69wEPMU2
| //! cotangent to the columns it reads. Select needs no step of its own, because | ||
| //! the recomputed region contains only the rows the forward pass kept. At an | ||
| //! input, the broadcast rule sums the cotangent by the input's dims: that is | ||
| //! the input's contribution. |
There was a problem hiding this comment.
I wonder if we could do a sort of pushdown here and omit taking the transpose of things that we never select (maybe this already happens at a higher level).
Stacked on #73.
One rule per primitive (
transpose.rs)JAX differentiates a function by giving each primitive a rule and composing them. A query is a composition of relational primitives, and this PR gives each one its transpose:
y = f(x₁, …)x̄ᵢ += ȳ · ∂f/∂xᵢrow by row, partials fromddx-core(#71)WHERE, join conditions, semi-joins, a filter on a rankSUMA matrix product is not a primitive. It is broadcast, map and reduce, and composing their transposes gives
Ā = Σ_out C̄·BandB̄ = Σ_batch A·C̄(design.md §4.3).COUNT's transpose is zero. Other aggregates are refused for now;MAX,MINandAVGget rules in #76.A saved aggregate's transpose joins the recomputed region beneath it to its cotangent on the grouping keys (
IS NOT DISTINCT FROM, so aNULLgroup keeps its gradient). Then the region is walked right to left. Each column's cotangent is appended as a new column rather than inlined, so a shared subexpression is not duplicated.gradandvjp(program.rs)vjp(plan, wrt)pulls a cotangent of the output back to thewrtcolumns. The caller supplies the cotangent as the table__ddx_cotangent, with the output's dims and its values that depend onwrt, named as in the output (BackwardProgram::cotangentlists them). The output must keep every dim, so each row is identified.grad(plan, wrt)isvjpof a loss seeded with 1. The output must be one row and one column, asjax.gradrequires a scalar. More columns, or a row per dim, isAdError::NotScalar, and the message says to sum into one row or usevjp.A
BackwardProgramis steps to run in order, each materialized under its name:__ddx_saved_{n}for each saved aggregate, then__ddx_value, the query's own result, so a program isvalue_and_grad;__ddx_cotangent_{n}for each saved aggregate that gradient reaches, with the aggregate's dims and the cotangent of each of its values under the same names;__ddx_grad_{table}for eachwrttable, shaped like the table (its dims andwrtvalues under the table's own names), with0where no gradient reached.Saved aggregates are processed parents first. A relation read in more than one place gets one contribution per read, added with
UNION ALLand a groupedSUM. A join would drop the rows one contribution lacks.Tests
ddx-datafusion/tests/grad.rsruns each program on DataFusion (with a runner intests/common/ad.rs, which becomes public API in M4) and checks every gradient entry against a central finite difference of the query, computed by DataFusion with that one parameter perturbed. It also checks that__ddx_valuereproduces the query. None of the queries has a label. Cases: an elementwise loss, a sum joined to data, two nested aggregates, a broadcast bias, a table joined to itself,SUM(…)/COUNT(…)above the aggregate, and awrttable the loss ignores (all zeros).vjppulls a per-group cotangent back exactly. Refusals:STDDEV, andgradof two columns or of a row per dim.🤖 Generated with Claude Code
https://claude.ai/code/session_01CvszJhH8pn8H9G69wEPMU2