Skip to content

Coupled discrete adjoints: the SU2 cross-term lesson, end to end - #788

Merged
ecto merged 10 commits into
mainfrom
claude/vcad-discrete-adjoints-su2-9eedaf
Aug 8, 2026
Merged

Coupled discrete adjoints: the SU2 cross-term lesson, end to end#788
ecto merged 10 commits into
mainfrom
claude/vcad-discrete-adjoints-su2-9eedaf

Conversation

@ecto

@ecto ecto commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Started as "what can vcad learn from discrete adjoints in SU2?" (Burghardt, NASA Ames AMS, Sep 2024) and turned into six milestones plus four bug fixes.

The argument

SU2's talk makes one point that lands directly on vcad: chaining two single-physics adjoints across a coupling interface and calling the product a coupled gradient is wrong. Their published numbers — 39.4% error on a CHT heat flux with cross terms dropped; the wrong sign (−0.525 vs +0.251) with all coupling dropped.

vcad had ~13 independently correct adjoints, no shared seam, and one real coupling (flow/conjugate.rs) with no gradient at all — exactly the configuration where somebody chains the two and ships the 39%.

What's here

M0 vcad-kernel-adjoint — cross-term ledger as a type, SU2's 4-op block-Jacobi driver, sensitivity vocabulary with trust radius, FD-sweep + ablation harness 39 tests
M1 flow⇄thermal coupled adjoint 8 tests
M2 vcad-eval::sensitivityd(quantity)/d(named parameter) with a searched topology trust radius 6 tests
M3 sensitivity MCP tool + receipt claims 11 tests
M4 Parameters panel ranks itself by influence 8 tests
M5 d(cost per good part)/d(tolerance) 8 tests

Results

M1 — the conjugate loop factors through R² (the thermal side sees the flow through exactly (h, T_bulk)), so the coupled adjoint is a 2×2 solve regardless of grid size. Validated against converged FD sweeps:

  • d(hotspot)/d(source power) — rel err 1.1e-6
  • d(hotspot)/d(inlet velocity) — rel err 8.1e-5, and that one is 100% coupling-derived

Ablation on the same fixture:

  • drop all coupling → 38.0% error (SU2 published 39.4% — independent reproduction, different code, different discretization)
  • drop the feedback loop → 14.5%
  • inlet velocity, uncoupled → exactly 0.0: "how fast you blow air over the heatsink does not affect how hot it gets", stated with total confidence

M2 — the trust radius is searched for, not assumed. On a 20 mm plate with a through-hole (real boundary at r=10, where the hole becomes tangent to the wall) the bisection returns [2.80, 9.986] without being told the boundary exists.

Getting the signature right needed a finding worth keeping: vertex counts are useless here, because vcad's boolean rims are sag-adaptive — the plate's vertex count climbs 252→452 as the hole grows while nothing topological happens. Faces and edges hold flat at (7,13) and jump to (20,20) at the wall. Reasoning is written into the module.

M5 — an interior optimum no single-discipline analysis produces: ±0.02 mm → 4.700/good part, ±0.283 → 3.778, ±1.0 → 5.895. Found by a derivative, not a sweep. Its ablation: without the scrap coupling the advice is always "loosen", at every tolerance.

Fail-closed throughout

The ledger is not decoration — the driver refuses to run when it disagrees with the registered cross terms, and refuses an incomplete ledger outright. An ablated gradient comes back Unverifiable and won't steer an optimizer, and that propagates all the way to the UI (amber row + "must not steer" banner).

Four bugs fixed

Browser verification of M4 turned up real bugs, each with a root cause worth reading:

  1. Source tab crashed the sidebar. serde_json::json! builds a Value::Object → serde serialize_map → serde-wasm-bindgen emits a JS Map, so .source/.unsupported were undefined. Only site in the file; every other export is a derived struct (serialize_struct → plain object).
  2. Property panel couldn't create bindings. ScrubInput::commit() did parseFloat and silently discarded formulas — the only way to bind a parameter to geometry was to author the document elsewhere. Now accepts expressions, with a second-order fix: the document stores the unbound literal, so bound fields and viewport annotations were showing numbers that never move.
  3. Importer renumbered nodes but not binding keys. A loaded "2:radius" landed on whatever node 2 became (a Scale wrapper) and failed the whole document. migrate_v1_mapped now returns the correspondence; the field picks the role.
  4. Pre-existing clippy: 14 in vcad-eval tests, 2 in vcad-app, 1 in vcad-kernel-wasm — all three now pass --all-targets -- -D warnings.

Reviewer notes

  • vcad-kernel-adjoint's block-Jacobi driver has no consumer yet. Fully tested, but M1 uses the ledger and vocabulary rather than the loop, because the reduced 2-D formulation didn't need it. It's there for the next coupling that isn't low-rank.
  • The 2×2 interface block is finite-differenced, so M1's completeness rolls up as Predicted, never Verified. That's deliberate and recorded in the ledger — making it analytic needs a thermal-lattice adjoint on the LBM side. It's a later milestone, not a hole; the block is implemented either way.
  • Test cost: conjugate_adjoint.rs runs ~70s (FD sweeps over a full CFD⇄thermal loop). Worth it — it's the evidence — but flagging it for CI budgeting.
  • Discrete parameters (hole counts, pattern instances) have no gradient on this path, and geometry parameters that move a voxel mask are FD-only. Both stated in the docs rather than papered over.
  • Every derivative was validated against a finite-difference reference that had to plateau before it was allowed to judge anything.

Verification

All Rust and TS suites green. M4 verified live in the browser: on a 40×40×6 plate with a 6 mm hole the panel reports 1487 g/mm and −226 g/mm against closed forms 1486.90 and −226.19; editing a parameter clears the rows (stale gradients never sit beside an edited model) and recomputing tracks the change.

🤖 Generated with Claude Code

ecto and others added 8 commits August 8, 2026 10:19
…adient

SU2's discrete-adjoint talk (Burghardt, NASA Ames AMS, Sep 2024) makes one
argument that lands directly on vcad: chaining two single-physics adjoints
across a coupling interface and calling the product a coupled gradient is
wrong by 39% in their CHT case, and returns the wrong sign in their
three-physics case. vcad had ~13 independent adjoints, no shared seam, and
one real coupling (flow⇄thermal conjugate.rs) with no gradient at all —
exactly the configuration where somebody chains the two and ships the 39%.

M0 — new crate `vcad-kernel-adjoint`, the vocabulary and the harness:
  * ledger: the z×z cross-term ledger as a *type*. Every Jacobian block is
    Absent / Implemented(method) / Frozen(assumption, bound) / Missing, and
    the roll-up maps onto ClaimBasis+ClaimVerdict so an incomplete gradient
    can never be reported as a passing claim. SU2 documents this in prose;
    here the driver refuses to run without it.
  * couple: SU2's four operations (ComputeAdjoints/Iterate/AddExternal/
    UpdateCrossTerm) as two traits plus a block-Jacobi fixed point. Refuses
    a ledger that disagrees with the registered cross terms, and refuses an
    Incomplete ledger outright.
  * sensitivity: a derivative carries value, unit, route, basis, verdict and
    a *trust radius* — the interval where it stops being true (curvature,
    voxel resolution, declared bounds). Ranking is |dJ/dθ|×span, not
    elasticity, because half of vcad's objectives are °C and that zero is an
    accident about brine.
  * validate: FD step sweeps that must *plateau* before they may serve as a
    reference, plus ablation — deleting a term and requiring the error to
    grow, because a passing FD check does not prove a term is wired in.

M1 — the coupled gradient itself:
  * thermal: added dJ/d(ambient) to the adjoint. A convection BC has two
    halves and a conjugate coupling needs both.
  * flow/conjugate.rs: split the primal loop into its two halves (price_film,
    conduct) so the adjoint reuses the exact primal map, not a copy of it.
  * flow/conjugate_adjoint.rs: the loop factors through R² — the thermal side
    sees the flow through exactly (h, T_bulk) — so the coupled fixed point is
    2-D and the coupled adjoint is a 2×2 solve regardless of grid size.
    dJ/dθ = ∂Ĵ/∂θ + μᵀ ∂Ψ/∂θ with (I−A)ᵀμ = g, where A is the product of
    SU2's two cross terms.

Validated against converged FD sweeps:
  d(hotspot)/d(source power)   rel err 1.1e-6
  d(hotspot)/d(inlet velocity) rel err 8.1e-5   (100% coupling-derived)

Ablation, on the same fixture:
  drop all coupling      → 38.0% error   (SU2 published 39.4%)
  drop the feedback loop → 14.5% error
  inlet velocity, uncoupled → exactly 0.0 — "how fast you blow air over the
  heatsink does not affect how hot it gets", stated with total confidence.

Ablated gradients come back Unverifiable and refuse to steer an optimizer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…radius

Connects the differentiable seam to the layer a person and an optimizer
actually consume: `d(quantity)/d(named .vcad parameter)`, carrying units,
route, provenance, and the interval over which it is meaningful.

The part that is not a re-skin of `diff.rs` is the trust radius. SU2
differentiates a mesh through FFD boxes, which are smooth by construction,
so "over what range is this derivative valid?" barely arises. vcad
differentiates the *feature tree* — strictly more useful, and not smooth:
a fillet outgrows its edge, a boolean changes face count, a hole reaches
the wall. Across any of those the derivative is not inaccurate, it is
describing a different solid.

`topology_trust_radius` does not assume a radius or ask the author for one.
It bisects outward from θ₀ until the document's topology signature changes.
On a 20 mm plate with a centred through-hole — whose real boundary is at
r = 10, where the hole becomes tangent to the side faces — the search
returns [2.80, 9.986] without being told the boundary exists.

Getting that signature right needed a finding: vertex counts are useless
here. vcad's boolean rims are sag-adaptive, so the plate's vertex count
climbs smoothly with hole radius (252 at r=3 → 452 at r=9.9) while nothing
topological happens. Faces and edges hold flat at (7, 13) across that whole
sweep and jump to (20, 20) once the hole breaks through. Signature is
faces+edges per part; the reasoning is written down in the module so the
next person does not rediscover it.

Also:
  * exact seam route for volume/mass/centroid (part- and document-scoped,
    with the quotient rule for the mass-weighted centroid), FD route for
    bbox extents — an extent is a max over vertices and cannot be priced
    exactly. Rows say which they are, and the FD ones can never claim
    Verified.
  * `rank_parameters` — which knob commands a quantity, by |dJ/dθ|×span.
  * `Sensitivity::with_note` now accumulates instead of overwriting; a row
    can have both a non-smoothness note and a trust-radius note.

Validated: d(volume)/d(hole_r) matches a rebuild finite difference to
4.9e-12 relative. The reference is a rebuild, not a closed form —
sag-adaptive rims mean an inscribed-N-gon closed form checks the wrong
thing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The product surface for M2. One call answers the question every CAD user has
asked and no CAD tool has ever answered: *which knob actually moves this
number, and by how much?*

  sensitivity({ document_id, quantities: ["mass"] })

returns a table ranked by influence (|dJ/dθ| × span), where each row carries
its unit, the route that produced it, whether that route is exact, and the
trust radius — so the top row is the knob that commands the quantity and the
interval says how far you may act on it.

The incumbent answer to this question is a DOE: N rebuilds. This is one
backward pass plus a bounded topology search.

  * `document_sensitivity_report` in vcad-eval: parses quantity names,
    defaults to every declared parameter, and attaches one receipt claim per
    row — including `Unverifiable` ones, which are reported rather than
    dropped. A sensitivity can now be carried *into* a receipt instead of
    being re-derived beside one.
  * `documentSensitivities` WASM export + `Engine.documentSensitivities`.
  * `sensitivity` MCP tool, registered and typed end to end.

Tests (11, through the real MCP server): ranking and signs, units, exact-vs-FD
routes, receipt claims, unknown-quantity rejection, and the trust-radius
search finding the plate's topology boundary at r≈10 over the wire.

Generated kernel-wasm artifacts were rebuilt locally to run these and then
restored — wasm-refresh.yml on main is their single writer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Puts M3's answer where the decision gets made. Pick a quantity, hit Rank, and
the panel **reorders itself** so the knob that commands that quantity is at
the top — with its derivative, a bar showing how much of the quantity it
commands relative to the other knobs, and the interval over which the answer
holds.

The bar is |dJ/dθ| × trust span, not the raw slope. A derivative alone ranks
by units: a steep knob you can move 0.02 mm commands less than a gentle one
with 10 mm of travel, and only the product says so.

Fail-closed reaches the UI: a row whose derivative could not be established
renders as *unverifiable* with its reason, in amber, and the panel says how
many rows "must not steer a change" above the list. A gradient with a missing
coupling term can carry the wrong sign — the UI must not launder that into a
tidy number next to a scrub input.

Computed on a button, not on every keystroke: the sweep is one seam pass per
parameter plus a bounded topology search — cheap next to the rebuild-per-knob
any other CAD tool needs to answer this, far too expensive per edit. The
report invalidates itself when the document changes, so a stale gradient never
sits beside an edited model.

Writing the tests turned up one real design smell and two formatting flaws:
  * the revision key was defined inline in the component — now
    `documentRevision()` in the store, so "changed" has one definition;
  * derivative and trust-radius rounding were per-number, rendering an
    interval as "0.0000–10.00" — two different measurements rather than one
    range. Precision is now chosen once from the larger end.

8 tests: ranking by command (not slope), rows without a radius sorting last,
formatting across magnitudes, panel reordering, the unverifiable path, and the
finite-difference marker.

Not visually verified in the browser: the app is behind a Terms-of-Service
acceptance gate, and accepting terms is not mine to do. The panel is covered
by rendering tests instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last rung, and the one SU2 will never climb: differentiate the number the
decision is actually made on.

  C_good(t) = Σ Cᵢ(tᵢ) / Y(t)
  dC_good/dtᵢ = [ (dCᵢ/dtᵢ)·Y − C·(dY/dtᵢ) ] / Y²

Loosening a tolerance makes the part cheaper and the yield worse. Both effects
were already exactly computable in this crate — `CostModel::d_cost_d_tol` and
the closed-form `d_yield_d_sigma` — and neither alone tells you what to do.
The quotient does, and it has an interior optimum that no single-discipline
analysis can produce. On the test chain:

  ±0.02 mm → 4.700 per good part   (paying for precision nobody needs)
  ±0.283   → 3.778 per good part   ← the derivative finds this
  ±1.0     → 5.895 per good part   (scrapping more than you save)

20% cheaper than the tight end, 36% cheaper than the loose one, located by a
gradient rather than a sweep.

`dollars_per_yield_point` = (dC/dt)/(dY/dt) makes two dimensions comparable
when their tolerances are in different units of difficulty — the number a
manufacturing engineer actually argues about.

Fail-closed, as everywhere else in this ladder: a contributor whose
distribution is a vendor-band uniform or a measured empirical fit has no
tolerance→σ chain, so it is *skipped with a reason* rather than given a
fabricated sigma convention, and a chain that is not all-normal says so —
the yield derivatives inherit the RSS normal-gap premise and the reader is
told when that premise broke.

Rows render into the shared `SensitivityTable`, so cost sits in the same
table as geometry and physics, with the allocation box as its trust radius.

8 tests, including: the decision number validated against a central finite
difference of cost-per-good-part (rel 1e-4); the interior optimum bisected and
confirmed cheaper than both ends; and — the ablation of this milestone —
without the scrap coupling the advice is *always* "loosen", at every tolerance,
which is the degenerate answer a cost model alone gives and the reason the
coupling exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by verifying M4 in the browser: the Influence panel read
`useDocumentStore.document` and handed that straight to
`documentSensitivities`. But named parameters and their bindings live in
`useParametersStore` — the document only carries them once merged, which is
what every `engine.evaluate` call already does via
`mergeParametersIntoDocument`. Differentiating the raw document silently saw
zero parameters and reported nothing.

Silently: no error, no empty state, just a panel that stayed blank after you
pressed Rank. The unit tests could not catch it because they seed both stores
directly.

Verified in the running app on a 40x40x6 plate with a 6 mm through-hole, both
parameters bound:

  #1 plate_t  1487 g/mm   valid 2.00-12.00 (bounds)
  #2 hole_r   -226 g/mm   valid 2.00-18.00 (bounds)

against the closed forms d(mass)/dt = 40*40 - pi*r^2 = 1486.90 and
d(mass)/dr = -2*pi*r*t = -226.19. Panel ranked plate_t first, signs correct
(thicker adds mass, wider hole removes it).

Also confirmed live: editing plate_t 6 -> 9 cleared the influence rows and
reverted the button to "Rank" — a stale gradient never sits beside an edited
model — and recomputing gave 1487 (unchanged: dV/dt is the plate area,
independent of t) and -339 (closed form -2*pi*6*9 = -339.29).

The test now mirrors the same merge, so it would fail on a regression.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**1. Source tab crashed the sidebar.** `documentToLoonChecked` built its result
with `serde_json::json!`, which is a `Value::Object` — serde emits that through
`serialize_map`, and serde-wasm-bindgen's default serializer turns maps into a
JS `Map`. So `.source` and `.unsupported` were both `undefined`, and reading
`.unsupported.length` took the whole ChatSidebar down to an ErrorBoundary.
Every other export in that file is a derived struct, which goes through
`serialize_struct` and becomes a plain object — which is why this was the only
site. Fixed with `Serializer::json_compatible()`, plus a guard in SourcePanel:
a read-only *view* must never unmount the sidebar, so malformed output now
degrades to an empty view with an error line.

**2. The property panel could not create bindings.** `ScrubInput::commit()` did
`parseFloat` and silently discarded anything else, so typing `wall` into a
dimension snapped back to the number — the only way to bind a parameter to
geometry was to author the document elsewhere. ScrubInput now takes an
expression: a bare number still wins (and clears any binding — that is how you
unbind), anything else is validated and bound. Bound fields render the formula
in brand colour with the resolved value beside it, refuse to scrub (dragging
would write a number over the formula), and keep what you typed on a typo
rather than eating it. `useFieldBinding` wires it up and names the error
precisely ("unknown parameter: wal" beats "invalid expression"). Wired onto
cube/cylinder/sphere dimensions.

Fixing that exposed a second-order bug: the document stores a node's *unbound*
literal, so anything reading `document.nodes[...]` shows a number that never
moves once bound. The viewport dimension annotation said "4.0 mm" next to a
30 mm slab. `resolveBoundFields` fixes the overlay and the panel together.

**3. The importer renumbered nodes but not binding keys.** Migration collapses
a `Translate → Rotate → Scale → primitive` chain into one CRDT feature and the
materializer expands it into four fresh ids — so a loaded `"2:radius"` landed
on whatever node 2 became (a `Scale` wrapper) and failed the whole document
with "field path not valid on op Scale", costing every other binding too.
`migrate_v1_mapped` now returns the old-node → (feature, role) correspondence,
and `remap_bindings` composes it with `PartInfo`'s per-feature node ids. The
*field* picks the role — `offset` follows the translate, `radius` the leaf —
because the old node id alone cannot say. Bindings that did not survive are
dropped with a reason instead of poisoning the evaluation.

**4. Pre-existing clippy failures.** 14 in vcad-eval's older test files
(needless range loops, `format!` inside `println!`, an unused constant), 2 in
vcad-app, 1 in vcad-kernel-wasm. All three crates now pass
`--all-targets -- -D warnings`.

Verified in the running app: Source tab renders full loon source; typing
`wall` into a cube's D binds it, `wall = 30` drives a 30 mm slab and both the
field and the viewport annotation read 30; the .vcad that previously failed
now loads clean, and `hole_r = 15` visibly widens the hole through the
remapped `2:radius`.

Tests: 3 new migration/remap gates in vcad-app (98 lib tests green), vcad-eval
and MCP suites unchanged and green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`vcad_kernel_wasm_bg.wasm` was rebuilt locally to exercise the new exports in
the browser and got swept into an earlier commit. wasm-pack output is not
byte-reproducible, so a feature branch carrying it merge-conflicts with every
other branch that rebuilt it. Restored to origin/main's copy —
.github/workflows/wasm-refresh.yml is its single writer, and PR CI builds the
WASM from source rather than consuming the checked-in blob.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
vcad-mcp Building Building Preview Aug 8, 2026 5:46pm
3 Skipped Deployments
Project Deployment Actions Updated (UTC)
mecheval Ignored Ignored Aug 8, 2026 5:46pm
vcad Ignored Ignored Aug 8, 2026 5:46pm
vcad-docs Ignored Ignored Aug 8, 2026 5:46pm

Request Review

@chojiai

chojiai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Choji review — Looks good — no findings

Choji review — Looks good

The delta is a single fixture update — one new JSON entry registering the sensitivity MCP tool's schema in the test surface fixture. No logic, no runtime code, no authorization boundary changed; the schema looks consistent with the tool implementation described in the PR.

No findings — looks good.


Rate findings

Reviewed 798ed47 · Choji updates this comment as you push · Mention @chojiai in a comment to discuss, re-review, or request a fix

chojiai[bot]
chojiai Bot previously approved these changes Aug 8, 2026
Comment thread packages/app/src/stores/sensitivity-store.ts
…adjoints-su2-9eedaf

# Conflicts:
#	packages/kernel-wasm/vcad_kernel_wasm_bg.wasm
@chojiai
chojiai Bot dismissed their stale review August 8, 2026 17:30

Dismissing prior approval to re-evaluate 4370878.

chojiai[bot]
chojiai Bot previously approved these changes Aug 8, 2026
The TypeScript CI job failed on `tool-surface.test.ts`: the advertised MCP
surface is snapshotted in a committed fixture, and M3 added a 174th tool
(173 expected, 174 got). Regenerated via the fixture's own documented path,
`UPDATE_TOOL_SURFACE=1`, which exists for exactly this — a deliberate,
reviewed surface change.

The diff is purely additive: 48 lines, the `sensitivity` entry alone, with no
existing tool's name, order, description, schema, or annotations touched. That
is the property the fixture is there to protect, so it is worth stating.

Full workspace suite green: 145 test files, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chojiai
chojiai Bot dismissed their stale review August 8, 2026 17:47

Dismissing prior approval to re-evaluate 798ed47.

@ecto
ecto merged commit 58f5a8b into main Aug 8, 2026
15 checks passed
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