Coupled discrete adjoints: the SU2 cross-term lesson, end to end - #788
Merged
Conversation
…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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Choji review — Looks good The delta is a single fixture update — one new JSON entry registering the No findings — looks good. Reviewed |
…adjoints-su2-9eedaf # Conflicts: # packages/kernel-wasm/vcad_kernel_wasm_bg.wasm
chojiai
Bot
dismissed
their stale review
August 8, 2026 17:30
Dismissing prior approval to re-evaluate 4370878.
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
Bot
dismissed
their stale review
August 8, 2026 17:47
Dismissing prior approval to re-evaluate 798ed47.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
vcad-kernel-adjoint— cross-term ledger as a type, SU2's 4-op block-Jacobi driver, sensitivity vocabulary with trust radius, FD-sweep + ablation harnessvcad-eval::sensitivity—d(quantity)/d(named parameter)with a searched topology trust radiussensitivityMCP tool + receipt claimsd(cost per good part)/d(tolerance)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-6d(hotspot)/d(inlet velocity)— rel err 8.1e-5, and that one is 100% coupling-derivedAblation on the same fixture:
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
Unverifiableand 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:
serde_json::json!builds aValue::Object→ serdeserialize_map→ serde-wasm-bindgen emits a JSMap, so.source/.unsupportedwereundefined. Only site in the file; every other export is a derived struct (serialize_struct→ plain object).ScrubInput::commit()didparseFloatand 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."2:radius"landed on whatever node 2 became (aScalewrapper) and failed the whole document.migrate_v1_mappednow returns the correspondence; the field picks the role.--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.Predicted, neverVerified. 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.conjugate_adjoint.rsruns ~70s (FD sweeps over a full CFD⇄thermal loop). Worth it — it's the evidence — but flagging it for CI budgeting.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