You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The tests we have for the adjoint solver (unit tests, end2tests, etc) are in a rough state. Here are some things we can do to improve things:
Document which end2end cases we have as a matrix (e.g. objective quantity vs simulation dimensionality vs design grid dimensionality vs boundary condition). Use this to pick up on gaps.
Consolidate end2end tests to a single driver that loop over different simulation/optimization setups. This makes it easier to audit the code. The above test matrix makes this really intuitive.
Establish a unified tolerance for all single precision tests and all double precision tests (which, of course, are different from each other). Over the years, these tolerances have drifted higher and higher with the addition of new features as we continued to introduce more and more bugs.
Clearly outline all the unit tests we have for the python front-end vs the C++ backend (in particular, the recombination step in meepgeom.cpp).
This is very much aligned with what I've been running into while working on #3263/#3274 — happy to help here.
One suggestion for the consolidation step: if the unified driver emits a small
structured record per case (objective, simulation dim, design-grid dim,
boundary, precision, measured error, tolerance), the test matrix in point 1
stops being documentation and becomes a CI output. From that same record, CI
can render:
the coverage matrix (tested / passing / missing combinations);
measured error vs. allowed tolerance per case, which makes tolerance drift
visible — passes that consume >50% of their tolerance are exactly the ones
worth auditing before unifying thresholds;
Mockup of what this could look like (illustrative data):
Recording the measured error per case also forces the refactor that point 3
needs anyway — most current tests assert deep inside and never expose the
actual error.
Assertions would remain the sole source of pass/fail; reporting would be
opt-in via an environment variable so local make check-adjoint is unchanged,
and matplotlib is already in python/requirements.txt so there are no new
dependencies. A first PR could be just the record schema + a GitHub Actions
job summary, with the plots as a follow-up.
Do you already have a shape in mind for the unified driver? I'd rather fit
the record schema to that than design it separately.
Thanks! I think a visual dashboard like what you describe above would be fantastic. Some challenges we might hit (would like to hear your thoughts wrt how we could tackle them):
Is there a clean way to integrate this within our CI? How can we access a dashboard like this with the current setup?
Adjoint tests are really expensive (especially the 3D) and we don't run them all by default in CI. Is there a way we can optionally trigger a full "dashboard" run on a PR or branch (e.g. right before we merge)?
Both are answerable against build-ci-adjoint.yml as it stands, which is
convenient — I don't think either needs new infrastructure.
1. Where the dashboard lives
The GitHub Actions job summary ($GITHUB_STEP_SUMMARY) is the piece that
makes this work without hosting anything. Anything a step appends to that file
is rendered as markdown on the run's own page, directly under the job. So the
coverage matrix and the tolerance-utilization table become part of the run:
no external service, no pages branch, no token, no permissions beyond the
default read-only GITHUB_TOKEN — which also means it works unchanged on pull
requests from forks, where a bot-comment approach would need pull_request_target and the security review that comes with it.
The mechanics, on top of the existing workflow:
Set MEEP_ADJOINT_REPORT=${{ runner.temp }}/adjoint-records on the existing Run adjoint tests step. Unset — which is every local make check-adjoint
— the tests behave exactly as they do now.
Each case appends one JSON line: objective, sim dim, design-grid dim,
boundary, precision, measured error, tolerance.
A new step with if: always() renders those records to $GITHUB_STEP_SUMMARY. always() matters: the interesting run is the one
where something failed.
Two limits worth designing around rather than hitting: the summary is capped at
1 MiB per step, and it renders markdown, not images. That splits the output
naturally — tables go to the summary, plots go to artifacts. The residual
heatmaps become an actions/upload-artifact@v7 upload, which the workflow
already does for test-suite.log; it is the same step with if: failure()
relaxed to if: always() for the report bundle. Anyone reading the run
downloads one zip.
If a summary table ever needs to be visible from the PR conversation rather than
the run page, that is a later, separable step and I'd rather not pay for it up
front.
2. Triggering the expensive run
The workflow already carries workflow_dispatch and a weekly schedule, so the
two expensive-run stories are mostly a matter of gating:
On demand, any branch. Give workflow_dispatch an input (e.g. suite: quick | full) so a maintainer can start a full run from the Actions
tab against a branch without pushing anything.
Right before merge, from the PR. Add labeled to the pull_request
trigger types and gate the expensive cases on the label:
on:
pull_request:
types: [opened, synchronize, reopened, labeled]
Applying the full-adjoint label then is the "run the dashboard before we
merge" gesture, and removing and re-adding it re-runs. Note this stays on pull_request, not pull_request_target, so fork PRs keep a read-only token.
Always-on baseline. The existing Sunday schedule entry runs the full
suite on master, which is what makes tolerance drift visible over time — a
single run's tolerance-utilization column is much less informative than the
same column a month apart.
so pushing three times in ten minutes to a full-adjoint-labelled PR does not
queue three full runs.
One thing that makes the cost question smaller than it looks: emitting the
record costs nothing — it is a few JSON lines from tests that are already
running, so the default PR run gets the coverage matrix and the tolerance
column for free. What the label gates is the additional expensive cases (3d,
MPI, single precision), not the reporting. That also means the schema gets
exercised on every PR from day one instead of only on labelled runs, which is
the failure mode I'd worry about most for a reporting path.
Suggested order
Record schema + the if: always() summary renderer, wired into the existing
serial job. Small, and it makes the coverage matrix real immediately.
Trigger/label gating and the expensive matrix.
Residual heatmaps as artifacts.
Happy to take (1) as a PR against whatever the unified driver turns out to be —
which is the part I'd still rather fit to your design than guess at. If the
driver is going to be a parameterized matrix over a single case class, the
record is one dataclass and one asdict at teardown, and I can write it
against your skeleton as soon as there is one.
Activity
jin-castle commentedon Aug 25, 2026
This is very much aligned with what I've been running into while working on
#3263/#3274 — happy to help here.
One suggestion for the consolidation step: if the unified driver emits a small
structured record per case (objective, simulation dim, design-grid dim,
boundary, precision, measured error, tolerance), the test matrix in point 1
stops being documentation and becomes a CI output. From that same record, CI
can render:
visible — passes that consume >50% of their tolerance are exactly the ones
worth auditing before unifying thresholds;
grid (the chunk-partition bug in [1/2] Fix adjoint gradient chunk-dependence without gathering the DFT monitors #3274 shows up as a block pattern at chunk
boundaries, which is far more diagnostic than a scalar L2 error).
Mockup of what this could look like (illustrative data):
Recording the measured error per case also forces the refactor that point 3
needs anyway — most current tests assert deep inside and never expose the
actual error.
Assertions would remain the sole source of pass/fail; reporting would be
opt-in via an environment variable so local
make check-adjointis unchanged,and matplotlib is already in
python/requirements.txtso there are no newdependencies. A first PR could be just the record schema + a GitHub Actions
job summary, with the plots as a follow-up.
Do you already have a shape in mind for the unified driver? I'd rather fit
the record schema to that than design it separately.
smartalecH commentedon Aug 27, 2026
Thanks! I think a visual dashboard like what you describe above would be fantastic. Some challenges we might hit (would like to hear your thoughts wrt how we could tackle them):
jin-castle commentedon Sep 5, 2026
Both are answerable against
build-ci-adjoint.ymlas it stands, which isconvenient — I don't think either needs new infrastructure.
1. Where the dashboard lives
The GitHub Actions job summary (
$GITHUB_STEP_SUMMARY) is the piece thatmakes this work without hosting anything. Anything a step appends to that file
is rendered as markdown on the run's own page, directly under the job. So the
coverage matrix and the tolerance-utilization table become part of the run:
no external service, no pages branch, no token, no permissions beyond the
default read-only
GITHUB_TOKEN— which also means it works unchanged on pullrequests from forks, where a bot-comment approach would need
pull_request_targetand the security review that comes with it.The mechanics, on top of the existing workflow:
MEEP_ADJOINT_REPORT=${{ runner.temp }}/adjoint-recordson the existingRun adjoint testsstep. Unset — which is every localmake check-adjoint— the tests behave exactly as they do now.
boundary, precision, measured error, tolerance.
if: always()renders those records to$GITHUB_STEP_SUMMARY.always()matters: the interesting run is the onewhere something failed.
Two limits worth designing around rather than hitting: the summary is capped at
1 MiB per step, and it renders markdown, not images. That splits the output
naturally — tables go to the summary, plots go to artifacts. The residual
heatmaps become an
actions/upload-artifact@v7upload, which the workflowalready does for
test-suite.log; it is the same step withif: failure()relaxed to
if: always()for the report bundle. Anyone reading the rundownloads one zip.
If a summary table ever needs to be visible from the PR conversation rather than
the run page, that is a later, separable step and I'd rather not pay for it up
front.
2. Triggering the expensive run
The workflow already carries
workflow_dispatchand a weeklyschedule, so thetwo expensive-run stories are mostly a matter of gating:
On demand, any branch. Give
workflow_dispatchan input (e.g.suite: quick | full) so a maintainer can start a full run from the Actionstab against a branch without pushing anything.
Right before merge, from the PR. Add
labeledto thepull_requesttrigger types and gate the expensive cases on the label:
Applying the
full-adjointlabel then is the "run the dashboard before wemerge" gesture, and removing and re-adding it re-runs. Note this stays on
pull_request, notpull_request_target, so fork PRs keep a read-only token.Always-on baseline. The existing Sunday
scheduleentry runs the fullsuite on master, which is what makes tolerance drift visible over time — a
single run's tolerance-utilization column is much less informative than the
same column a month apart.
Worth adding while touching the triggers:
so pushing three times in ten minutes to a
full-adjoint-labelled PR does notqueue three full runs.
One thing that makes the cost question smaller than it looks: emitting the
record costs nothing — it is a few JSON lines from tests that are already
running, so the default PR run gets the coverage matrix and the tolerance
column for free. What the label gates is the additional expensive cases (3d,
MPI, single precision), not the reporting. That also means the schema gets
exercised on every PR from day one instead of only on labelled runs, which is
the failure mode I'd worry about most for a reporting path.
Suggested order
if: always()summary renderer, wired into the existingserial job. Small, and it makes the coverage matrix real immediately.
Happy to take (1) as a PR against whatever the unified driver turns out to be —
which is the part I'd still rather fit to your design than guess at. If the
driver is going to be a
parameterizedmatrix over a single case class, therecord is one
dataclassand oneasdictat teardown, and I can write itagainst your skeleton as soon as there is one.