Skip to content

feat(ddx-ad): one transpose per relational primitive; grad and vjp (M3 6) - #74

Merged
alxmrs merged 10 commits into
mainfrom
m3/vjp
Oct 3, 2026
Merged

alxmrs merged 10 commits into
mainfrom
m3/vjp

Conversation

@alxmrs

@alxmrs alxmrs commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

Stacked on #73.

Rewritten after the ontology review on #80. The rules are now one per relational primitive, visible in the code. vjp takes a real cotangent, and grad requires a scalar loss, as in JAX. Nothing needs a label: a SUM is linear whatever it is summing.

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:

Primitive SQL Transpose
map a projected expression y = f(x₁, …) x̄ᵢ += ȳ · ∂f/∂xᵢ row by row, partials from ddx-core (#71)
select WHERE, join conditions, semi-joins, a filter on a rank the cotangent stays on the kept rows; the recomputed region contains only those
broadcast a join sum the cotangent back by the input's dims
reduce grouped SUM broadcast the group's cotangent onto every summed row

A matrix product is not a primitive. It is broadcast, map and reduce, and composing their transposes gives Ā = Σ_out C̄·B and B̄ = Σ_batch A·C̄ (design.md §4.3). COUNT's transpose is zero. Other aggregates are refused for now; MAX, MIN and AVG get 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 a NULL group 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.

grad and vjp (program.rs)

let program = ddx_ad::grad(&plan, &[ColumnRef::new("weight", "val")])?;
  • vjp(plan, wrt) pulls a cotangent of the output back to the wrt columns. The caller supplies the cotangent as the table __ddx_cotangent, with the output's dims and its values that depend on wrt, named as in the output (BackwardProgram::cotangent lists them). The output must keep every dim, so each row is identified.
  • grad(plan, wrt) is vjp of a loss seeded with 1. The output must be one row and one column, as jax.grad requires a scalar. More columns, or a row per dim, is AdError::NotScalar, and the message says to sum into one row or use vjp.

A BackwardProgram is steps to run in order, each materialized under its name:

  1. __ddx_saved_{n} for each saved aggregate, then __ddx_value, the query's own result, so a program is value_and_grad;
  2. __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;
  3. __ddx_grad_{table} for each wrt table, shaped like the table (its dims and wrt values under the table's own names), with 0 where 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 ALL and a grouped SUM. A join would drop the rows one contribution lacks.

Tests

ddx-datafusion/tests/grad.rs runs each program on DataFusion (with a runner in tests/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_value reproduces 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 a wrt table the loss ignores (all zeros). vjp pulls a per-group cotangent back exactly. Refusals: STDDEV, and grad of two columns or of a row per dim.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CvszJhH8pn8H9G69wEPMU2

@alxmrs

alxmrs commented Sep 24, 2026

Copy link
Copy Markdown
Member Author

Added 915a3e8: grad now refuses a loss that may have many rows. It skipped constant inputs, so a one-row loss cross-joined to a three-row data table was accepted and the sum of three rows was differentiated. It now requires every input the output reads to be one row by the plan's own evidence (an ungrouped aggregate, or data shown to be one row). Dividing by an ungrouped COUNT(*) over data still works. Found by /code-review.

@alxmrs

alxmrs commented Sep 29, 2026

Copy link
Copy Markdown
Member Author

🤖😈 Adversarial review — #74 (transposes, grad, vjp)

Before the defects, credit where it's due. I ran a finite-difference sweep of 20 awkward shapes through grad at the stack tip: LayerNorm-style val - AVG(val) via a cross join, grouped variance, HAVING, IN/NOT IN semi/anti-joins, LEFT JOIN with the wrt table on the preserved side, an exp/SUM normalizer, MAX over a saved SUM, AVG of AVG, a self-join on a.i = b.i + 1, power(2.0, val), and an OR filter over a varied value. All 17 that ddx accepted match finite differences. The rule structure is sound. The three defects below are in how results are keyed and named, not in the calculus. Failing tests are pushed as 59aa505 in grad.rs.

1. Repeated dims → silently wrong gradient, then row explosion

d(i, val) = (0,1.0),(0,2.0),(1,3.0) and SUM(val*val):

gradient   : (0,6.0) (0,6.0) (1,6.0)     want (0,2.0) (0,4.0) (1,6.0)
SGD w - .25g: 5 rows out of 3 rows in

dense_gradient re-reads the table's dims, so every duplicate row gets the group's sum. The user's JOIN g ON w.i = g.i then fans out 2×2. The documented assumption is doing the damage here; see my note on #73 for a cheap run-time refusal. Test: a_table_whose_dims_repeat_is_not_given_summed_gradients.

2. The NULL-row convention is emergent, not pinned

For wn(i, val) = (0,1),(1,NULL),(2,3), the gradient at the NULL row depends on how the loss is written:

loss grad at NULL row
SUM(val) 1.0 (the seed passes straight through)
SUM(val*val) 0.0 (2·NULL is NULL, then coalesce(…, 0))
AVG(val) (#76) 0.5

v1 fixed exactly this class of bug in #60 and pinned NULL. Design §5 requires the NULL convention to be pinned, not emergent. Separately, coalesce(grad, 0.0) in dense_gradient conflates "no contribution reached this row" with "the contribution was NULL". The first should be 0, the second probably NULL. Test: a_null_rows_gradient_does_not_depend_on_how_the_loss_is_written.

3. gradient_name isn't injective

format!("__ddx_grad_{}", names.join("_")) maps both a_b.c and a.b_c to __ddx_grad_a_b_c. The second step overwrites the first, and on the SQL surface (#83) both calls read back the second table's gradient: 2 becomes 20, with no error. Quote or escape the parts (or number the tables), and apply the same fix in sql_all's __ddx_grad_{p}_{…} and in the Python mirror. Test: tables_whose_names_join_alike_get_their_own_gradient_steps.

Smaller

  • dtype: a REAL table's gradient comes back Float64, because the seeds and coalesce use f64 literals. JAX returns a gradient with its primal's dtype. Here w.val - lr * g.val silently widens a REAL parameter table to DOUBLE after one step. This is the same schema drift the nn example (feat(ddx-datafusion): nn.py trained with grad in SQL (M4 3) #80) already works around for nullability, by registering every column nullable.
  • scalar_output: the one-row check is conservative, which I agree with. It refuses … GROUP BY g ORDER BY g LIMIT 1, which is fine, but the message should say it's a proof limitation rather than claiming the loss "may have many rows".

alxmrs added a commit that referenced this pull request Sep 29, 2026
…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
alxmrs added a commit that referenced this pull request Sep 29, 2026
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
alxmrs added a commit that referenced this pull request Sep 29, 2026
…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
@alxmrs

alxmrs commented Sep 29, 2026

Copy link
Copy Markdown
Member Author

All addressed in ab69269, and the three tests in 3398f43 pass.

  1. Repeated dims: each program now carries checks, which are plans that must return no rows. The dims check returns every dim tuple of a wrt table that appears more than once. ad::run runs the checks first and fails with AdError::InvalidWrt("… share their dims"). Your test asserts that refusal.
  2. NULL rows: now pinned, following v1: a NULL value gets a NULL gradient, and a row nothing reached gets 0. dense_gradient is CASE WHEN val IS NULL THEN NULL ELSE coalesce(g, 0) END, so "no contribution" and "NULL value" are no longer conflated. The three spellings (SUM(val), SUM(val*val), AVG(val)) now agree.
  3. Names: every table a program writes is __ddx_{id}_…, with an id that is fresh per program. Gradients are {prefix}grad_{i}_{table}, numbered by position, so a_b.c and a.b_c can't collide.

Smaller:

  • dtype: the gradient is cast to its value column's type, so a REAL table gets a REAL gradient (a_gradient_has_its_values_type).
  • scalar_output: the message now says ddx can't show from the plan that the query returns one row, rather than claiming it may return many.

alxmrs added a commit that referenced this pull request Sep 29, 2026
…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
@alxmrs
alxmrs force-pushed the m3/graph branch 2 times, most recently from 27444cf to b8b2d98 Compare September 29, 2026 17:26
alxmrs added a commit that referenced this pull request Sep 29, 2026
…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
alxmrs added a commit that referenced this pull request Oct 3, 2026
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
@alxmrs

alxmrs commented Oct 3, 2026

Copy link
Copy Markdown
Member Author

😈🧪 Adversarial tester: ✅ approved for correctness. Reviewed at d2c7bdc (identical tree to today's rebase febfc15). Workspace tests, doctests, a 20-min soak (0 failures) and a mutation spot-check all pass. The one blocker in the stack is on #79 (#79 (comment)); the full report is in #102 (comment).

alxmrs added a commit that referenced this pull request Oct 3, 2026
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
alxmrs added a commit that referenced this pull request Oct 3, 2026
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
alxmrs added a commit that referenced this pull request Oct 3, 2026
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
alxmrs added a commit that referenced this pull request Oct 3, 2026
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
alxmrs added a commit that referenced this pull request Oct 3, 2026
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
alxmrs added a commit that referenced this pull request Oct 3, 2026
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
alxmrs added a commit that referenced this pull request Oct 3, 2026
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
alxmrs added a commit that referenced this pull request Oct 3, 2026
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
alxmrs added a commit that referenced this pull request Oct 3, 2026
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
alxmrs and others added 10 commits October 3, 2026 07:04
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

@alxmrs alxmrs left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

//! 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

@github-actions github-actions Bot mentioned this pull request Oct 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant