Development workflow
How work is done on this repository: issue tracker, versioning, and the gates a commit must pass. Written for Claude Code sessions, but it describes the human workflow too.
1. Environments
Python is not on PATH under a plain shell. Use the named conda environments:
purpose |
interpreter |
|---|---|
default — generators, docs and tests |
|
per-version test matrix |
|
venvExuP313 covers regeneration, the docs build, the docstring check and the test suite. Its
packages are the dependency groups in pyproject.toml (pip install --group dev, pip >= 25.1) plus
the [tests] extra of the locally built wheel; the recipe is in
docs/howTo/condaEnvironments.md. The
driver exudev selects the environment itself – --py P310…P314, --py all, or
--env NAME – so nothing has to be activated by hand; see
tools/exudev/README.md.
Check before trusting a test run.
import exudyn; exudyn.__version__must matchversion.txt. The base Anaconda environment carries a stale 1.10.0, so a test suite run from it silently tests old binaries.
Run the generators and the docs build from a Windows shell (PowerShell): without a
.gitattributes, generating from a different shell can produce spurious line-ending diffs.
Documentation build, from the repository root:
sphinx-build -b html . _build -E
The printable documentation is a release artifact: exudev docs --pdf needs a LaTeX installation with
xelatex, writes dist/exudynDocumentationV<version>.pdf (decision D17), and takes about 2.5 minutes.
exudev docs --check builds it and checks its LaTeX log against tools/pdfLogBaseline.json
(tools/checkPdfLog.py): an error, a missing file or character, an undefined reference, a float or a box too large
for its page and a line more than 20pt too wide fail unless the baseline has them, and the report gives the page.
It is part of gate 4 for a change to docs/, definitions/ or conf.py (#2856). A finding that is accepted - or
one fixed, which the check reports as gone - goes into the baseline with python tools/checkPdfLog.py --write-baseline, committed with the change.
Dependencies
numpy is the only hard requirement, and that follows from the nature of the C++/Python coupling — it is not a preference and should not grow.
Advanced functionality legitimately needs more: scipy, networkx, Gym, stable-baselines, NGsolve
and others. Those stay optional. Installing exudyn requires the minimum; a function that needs
more raises at the point of use. Today those failures are not consistently ImportError, which is
a known rough edge — not something to fix opportunistically.
When adding code: a new optional dependency behind a clear failure is acceptable; a new mandatory one is not.
The one-time setup of a clone - git config core.hooksPath tools/hooks and
python tools/setupLocalWorkspace.py - is step 4 of
GETTING_STARTED.md, where a reader meets it before the rules rather than
after them. Without it there is no mechanical guard against
pushing the working branch to the public repository.
2. The issue tracker
tools/issueTracker/issueTracker.py is both the issue tracker and the source of truth for the
version number; exudev issue <verb> is its command line. The issues live in
tools/issueTracker/issues/ as JSON (2,568 issues, 270 open as of
1.11.225):
issues/open/2567.json one file per OPEN issue
issues/closed/2473.json ... per issue closed since 1 January of the previous year
issues/archive/2019.json ... per YEAR for the older ones, written once
issues/meta.json the date of the last change and the version it produced
releases.json the releases, their names, and the closed-issue count each began at
The version a closed issue produced is stored in it (resolvedInVersion): the micro version is a running count, so recomputing every number on every run made a
published version depend on a sort by dateResolved — one corrected date renumbered history
and nothing said so. python tools/checkIssues.py recomputes and compares, and that is a check
only because the number was written down once.
Every new issue becomes a step in the revision plan — a new step, or part of an existing step where it belongs; small issues may share a step. The tracker describes the issue, the plan says when and together with what it is done (plan §12).
Run it from its own directory — it uses relative and Windows-style paths:
cd tools/issueTracker
API
From the command line, which is the way to use it:
exudev issue list --open --type FIX --effort LOW #the list a triage pass works from
exudev issue show 2566
exudev issue raise "title" "description" --type FIX --effort LOW --author Claude-JG
exudev issue extend 2566 "what the analysis turned up"
exudev issue remark 2566 "duplicate of #2134, check before starting"
exudev issue resolve 2566 "what was done" --author Claude-JG #bumps the micro version
exudev issue close 2566 "duplicate of #2134" | "superseded by ..." | "won't fix, because ..."
exudev issue triage #the open issues by type and effort
exudev issue plot #the issues over time: total, closed, open; bugs and fixes
exudev issue bump --minor | --major | --to 2.0 #start a RELEASE (maintainer decision)
exudev issue html #write tools/issueTracker/trackerlog.html (ignored by git), e.g. after a checkout
exudev issue serve #the same in a browser, read AND write
exudev issue mode --release | --dev #fact 26
exudev issue serve opens a local page (http://127.0.0.1:8099/, standard library only) that
lists, searches and filters the issues and writes through the same functions as the verbs above
— which is the tool for a backlog pass, where a command line is not. It answers on the loopback
interface only, and deleting an issue is not on it: that stays a file operation with a commit
behind it.
exudev -n issue resolve ... prints what it would do and writes nothing — worth using before
anything that touches the version.
The same through the API, which the CLI calls and which a script can import:
call |
effect |
|---|---|
|
appends a |
| RaiseIssueDict(issueDict) | same, full control over fields |
| ResolveIssue(issueNumber, notes='', author='JG') | marks RESOLVED, stamps date, bumps the micro version; notes becomes releaseNotes and the working remarks are cleared |
| CloseIssue(issueNumber, reason, author='JG') | closes without resolving; the reason is mandatory and becomes releaseNotes |
| ExtendIssue(issueNumber, text, author='JG') | appends a dated paragraph to the description of an OPEN issue and changes nothing else (R8.3.3) |
| RemarkIssue(issueNumber, text, author='JG', replace=False) | writes workingRemarks of an OPEN issue; appends by default (R8.5.3) |
| ChangeIssue(issueNumber, key, value) | change one field; the enum fields are checked here too |
| ModifyDictIssue(issueDict) | replace a whole issue (needs number) |
| GetIssue(n) / GetIssues() / NumberOfIssues() | read-only |
| VersionString() / GetMajorMinorMicroVersion() | current version |
Fields: number, issue, author, status, description, type, priority, date raised, deadline, date resolved, resolved author, file, line, releaseNotes, workingRemarks, effort.
Two kinds of note, two fields.
releaseNotesis written when the issue closes and is published — indocs/generated/trackerlog.mdand, later, inCHANGELOG.md.workingRemarksis what the work knows meanwhile: duplicate of #2134, marked for deprecation, check whether this still happens, part A solved, B open. It is worthless once the issue closes, soResolveIssueandCloseIssueclear it, andRaiseIssuerefuses a release note: there is nothing to release yet.
effortsorts the backlog, in human working hours without AI assistance:LOWwithin 2,MEDIUMwithin 16,HIGHwithin 40,HUGEabove 40. It is a classification and not an estimate anyone is held to; empty means not classified.priorityis a separate question and may stay empty, which is what 2,405 of the 2,567 issues say — but its spelling is now one ofLOW,NORMAL,HIGH, enforced wherever an issue is written.
Extending an issue instead of rewriting it. When the first analysis turns up more than the issue says,
ExtendIssueappends it with the date and the author. It refuses a closed issue: that text has already been published, so it is reopened or superseded, never rewritten.
Author attribution. When Claude raises or resolves an issue on the maintainer’s behalf, pass
author='Claude-JG'to bothRaiseIssueandResolveIssue— the two fields are separate (authorandresolved author), so both need it.JGalone means Johannes worked it himself. This keeps the tracker honest about who did what without needing a separate audit trail.
The value lists live in issueTracker.py (issueStatuses, issueTypes, issuePriorities,
issueEfforts) and nowhere else — this used to be a second list here and the two disagreed:
status:RAISED,RESOLVED,CLOSED—CLOSEDmeans everything except resolved (obsolete, won’t fix, duplicate, superseded, not reproducible, abandoned), with the kind in the mandatory reasontype:BUG, FIX, CHANGE, EXTENSION, IMPROVEMENT, TESTING, DOCU, EXAMPLE, CHECK, IDEApriority:''(none),LOW,NORMAL,HIGHeffort:''(not classified),LOW,MEDIUM,HIGH,HUGE
BUG vs FIX — the distinction is user-facing
BUG is not “anything broken”. It is reserved for defects a package user has to know about,
because they explain why something silently did the wrong thing:
a solver, formulation or model gives wrong results;
something crashes during a simulation;
a documented feature does not work at all, with no clear message.
FIX covers everything that is merely annoying to us: a build that does not compile, a
packaging or CI defect, documentation drift, a feature unavailable in some mode. These are
visibly failing with the cause in the message, so a user never mistakes one for correct output.
Why it matters mechanically: ConvertToLatex/ConvertToHTML select on type == 'BUG' to build
the open-bug list and the “resolved BUG” lines of the release notes. Anything typed BUG is
therefore published to users. Mistyping a build defect as BUG costs them attention for
something that could never have affected their results; mistyping a wrong-result defect as FIX
hides it.
When the cause is not yet known, prefer BUG — it is the safe direction.
What a tracker write touches
Every verb that changes an issue rewrites the pages; only a verb that closes an issue moves the version, because the micro version is the count of closed issues.
file |
written by |
in git |
|---|---|---|
|
the verb itself |
yes |
|
every verb (date and version of the last change) |
yes |
|
every verb ( |
no — a local overview |
|
every verb ( |
yes — the published issue list, every release before the current one, in full |
|
every verb ( |
yes — the current release for users, with a table of all of them |
|
|
yes |
|
the same |
yes |
|
the same |
yes |
the version line of |
the same |
yes |
|
|
yes |
Nothing else. exudev docs renders docs/ to HTML and writes no tracker file; it only
reads docs/generated/trackerlog.md, which the tracker had already written. So after raising
an issue the working tree carries the new issue file, meta.json and trackerlog.md — and if
that issue is deleted again by hand, trackerlog.md still lists it until the next tracker write
regenerates it (python tools/checkIssues.py does not regenerate; any verb does).
Deleting an issue by hand is legitimate for the NEWEST open issue, which is why nothing
automates it: its number is simply handed out again. A gap anywhere else is reported by
tools/checkIssues.py, and a CLOSED issue must never be deleted — it is one count of the micro
version and its number stands in a released changelog.
An issue file can be read and even edited by hand — that was the point of R8.5 — but go through
exudev issuewhere a verb exists: it checks the enum fields, moves a closed issue fromopen/toclosed/, and rewrites the version files, which a hand edit does not.python tools/checkIssues.pysays whether the store is consistent; it runs in the commit gate.
Known inconsistencies (raise as issues; do not fix inline)
~~The header documents
priorityvaluesNO, LOW, NORMAL, HIGH, butConvertToHTMLcolours onhigh/med/low~~ — settled: one enum, normalized in the data and enforced on write.~~Historical
typevalues include typos and variants outside the documented set~~ — settled in R8.7 (#2519): 39 spellings became 10 types, checked where an issue is born.The batch scripts that used to live in
tools/buildAndGenerate/carried several such leftovers, among them acdinto a directory removed years ago. They were replaced byexudevand moved out of the repository.
What next?
Answer in this order:
Open
BUGs (status != RESOLVED and type == 'BUG') — currently 9.Open issues by priority (
HIGH, thenMED, thenLOW) — currently 9 / 5 / 4.The current phase of
docs/revision/exudynRevisionPlan2026.md.
cd tools/issueTracker
$env:USERPROFILE/Anaconda/envs/venvP312/python.exe -c "import issueTracker as it; [print(i['number'], i['priority'], i['type'], i['issue']) for i in it.GetIssues() if i['status'].strip()!='RESOLVED' and i['priority'].strip().lower()=='high']"
3. Versioning
The micro version is derived, not written. GetMajorMinorMicroVersion() counts RESOLVED
issues and subtracts the baseline for the current minor version, so resolving an issue is the
version bump. ResolveIssue() then rewrites, in one pass:
tools/issueTracker/issues/<the issue>.json and issues/meta.json
tools/issueTracker/trackerlog.html the overview (not in git)
docs/generated/trackerlog.md
src/Autogenerated/versionCpp.cpp the version reported by exudyn at runtime
version.txt the single version source, at the repository root
tools/issueTracker/versionName.txt the jazz-musician release name
Never edit any of those seven files by hand.
How a version is named in prose (maintainer, 2026-09-22)
Two audiences, two forms, and mixing them is what confuses people:
for users — name the release that ships a feature: “since 1.12”. A micro version such as 1.11.238 is a number they cannot install, and telling them one they cannot get from
pipis worse than telling them nothing.for development — name the exact version: “since 1.11.238”. That is what the colleagues on the internal GitLab run, it is checkable against
version.txt, and the changelog carries it for every issue anyway.
So the manual, the landing page and the revisions chapter say 1.12;
CHANGELOG.md, the issue tracker and notes between developers say 1.11.238. Since the micro
version only ever grows, a reader comparing the two still gets the order right.
1.12 is not a public release (decision D16): it marks the completion of the 2026 revision and stays internal — no PyPI wheels, no GitHub release. 1.13 is the first release that is published, after the integration round of the colleagues, the documentation review and the macOS wheels.
Release mode vs development mode
versionDev in tools/issueTracker/issueTracker.py selects one of two intended
modes. Both are kept; this is not a leftover switch.
mode |
|
version |
modules built |
cp313 build |
|---|---|---|---|---|
release |
|
|
|
86 s (2026-09-16, two modules; was 168.9 s with three) |
development |
|
|
one |
58.1 s |
Switch by editing the line and running UpdateFiles() from tools/issueTracker/.
Two things follow from a switch:
The version string changes, so the version line of
README.rstpicks up (or loses) the.dev1suffix when the tracker stamps it — see §8 gate 2 on ordering.A
.dev1version is not installed by a plainpip install exudyn— only with--preor an exact version. A development build therefore cannot reach users by accident.
Use development mode for ordinary work: outside Python 3.13 it builds one module instead of two and is roughly 2× faster. Switch to release only when producing a release.
Starting a release is the maintainer’s decision, and it is one command:
exudev issue bump --minor #1.11 → 1.12
exudev issue bump --major --name ... #1.11 → 2.0
It appends the release to tools/issueTracker/releases.json with the current count of closed
issues as its baseline, and the micro version restarts at 0. Claude asks first, every time.
4. Branches and remotes
Full picture in plan §2a. The short version, which is what matters day to day:
branch |
purpose |
|---|---|
|
read-only mirror of public GitHub |
|
the working branch of the 2026 revision; the name is historical (the revision was planned as 1.11 → 2.0 and completes as 1.12) |
|
release preparation |
origin → git@<internal-gitlab>:<group>/exudyn.git internal (v2-dev tracks origin/v2-dev)
github → git@github.com:jgerstmayr/EXUDYN.git public (master tracks github/master)
origin is the internal server, so a bare git push from v2-dev syncs there — the safe default,
and the normal thing to do. The internal repository carries full history; this clone is still
--depth 1 and stays small.
Work on
v2-dev; never commit onmaster.Push
v2-devtooriginfreely.Nothing reaches GitHub before the 1.13 release. 1.12 marks the completion of the 2026 revision and stays internal: no PyPI wheels, no GitHub release, no promotion (decision D16).
tools/hooks/pre-pushenforces this, but only wherecore.hooksPathis set — see GETTING_STARTED.md, and tell anyone you add to the server.Claude never pushes to any remote, under any circumstances, and announces network access first.
5. Testing
What the suites are, which of them a change has to run, and what a test model looks like. The gates that require them are §8.
Which tests run when
run |
what |
how |
|---|---|---|
commit gate / pull request |
test models without the slow ones and without optional packages |
|
full local check |
all test models and mini examples, and the tests that are not models - graphics, settings, dialogs, tools |
|
graphics |
what the renderer would draw, every item through its MiniExample, small raytraced images |
|
performance of the items |
every MiniExample, the solver timers |
|
nightly / release |
models, performance tests, all examples and the pytest files |
|
examples |
all 171 examples, in parallel, as an API check |
|
C++ unit tests |
the |
only in a build with the |
The two lists behind this live in runTestSuiteRefSol.py as data - SlowTests() (measured,
above 0.6 s) and OptionalPackageTests() (needs ngsolve, stable-baselines3, …) - and are read
both by --fast and by the pytest markers, so the two runners always skip the same models.
pytest additionally marks sensitive and unresolvedOnLinux from the same file.
The models are not the slow part of a nightly run: the whole suite is 26 seconds, while the 171 examples and the performance tests dominate. Those are the slow part, not this split.
Where a test goes: test model or python/testing/
A test of what a user does with Exudyn is a test model in python/TestModels/: an item, an
output variable, a sensor, a solver setting, a utility such as SystemEnergy - including what it
refuses and with which message. Users search the test models for examples; a test of the energy
output variables in python/testing/ is one they never find. The test model shows the use and
reports one number (§What a test model looks like); a refused input is shown with try/except
and counted in the result. (Maintainer, 2026-10-01, #2202.)
python/testing/ is for what is not an example: the tools, the generators and the definitions,
the renderer, dialogs and settings, the runners, and comparisons between two code paths of the core
(such as test_connectorInterface.py, the connector interface against its legacy path) - tests that
need pytest’s parametrization, fixtures or pytest.raises over internals a user does not touch.
What a test model looks like
A test model is an ordinary Exudyn script that says one thing about itself and reports one
number. Since #2632 that is three lines, and nothing is imported from
python/testing/:
testIsActive = exu.sys.get('testIsActive', False) #True only while a runner runs this file
...
simulationSettings.solution.file.write = not testIsActive
...
if not testIsActive:
SC.renderer.Start()
SC.renderer.RestoreSavedState()
SC.renderer.DoIdleTasks()
...
SC.renderer.Stop()
...
exu.Print('solution of myTest=', testResult)
exu.sys['testResult'] = testResult
exu.sysis the channel. It is a dictionary the C++ side owns and Python can read, it lives as long as the interpreter, and it is reserved for the system, so no model can collide with another. Before RG10.6 a model importedexudynTestGlobalsfrom the test suite inside atry/except, nine lines of it, which is why a model could not be run from anywhere else.The renderer calls stay behind
if not testIsActive:. The runners suppress windows anyway (exu.special.userInterface.SuppressAll(True)), but a test is worth running with graphics by hand, and the branch is what makes that possible.The result is the model’s own number, and the reference lives in
python/testing/runTestSuiteRefSol.py- not in the model. Print it with a name: that is what lets a reference be recomputed without running the suite.Never scale a result to make it fit the tolerance. If a model needs a wider one, say so:
exu.sys['testTolerance'] = 5e-7 #with the reason, in a comment
This is an absolute tolerance and it replaces the default for that model, in the suite, in
pytestand in the parallel path alike. Multiplying the result instead - which one model didhides the tolerance inside the number the test compares, and the reference solution then has to be a scaled number nobody recognises.
A mini example is generated from definitions/ and only writes exu.sys['testResult']; a
performance model additionally calls testRunnerTools.AddTiming(name, mbs, result) once per
simulation run.
pytest
pytest (from the repository root, configured in pyproject.toml) runs
python/testing/test_testModels.py: one test case per test model and per mini example, each in
its own interpreter, judged against the same reference values, tolerance factors and
sensitive/unresolved lists as runTestSuite.py - those have one definition in
runTestSuiteRefSol.py and testRunnerTools.BaseTolerance().
pytest all models and mini examples (149 cases, ~71 s)
pytest -k ANCF a subset by name
pytest -n 8 with pytest-xdist: ~12 s
pytest -k plotSensorTest -s one model with its output
pytest and pytest-xdist are dev tools: install them yourself (pip install pytest pytest-xdist); neither the package nor runTestSuite.py needs them. Do not start pytest with
python/ as working directory - the untracked scratch file python/pytest.py would shadow the
pytest module.
runTestSuite.py stays the runner for the commit gate and the release log: it writes the release-
named log, the coverage report and the overview table, and its exit code is what CI checks.
Running the suite in parallel
runTestSuite.py --parallel runs every test model in its own interpreter; --parallel=N sets the
number of workers (default: half the cores, at least 2, at most 8). Measured on a 32-thread
machine: 22 s serial, 11 s with 8 workers, 9 s with 16; the limit is the interpreter start of
each model, not the models themselves. It is possible because every model writes into its own
output directory (exudyn.config.outputDirectory).
The log reports the models in the order of the reference list, whatever order they finish in, so
log and exit code do not depend on scheduling. The gating run stays serial by default: models
that use multithreaded solvers or ARPACK can shift in the last digits when the machine is loaded
(measured: NGsolveCMStest, objectFFRFreducedOrderTest, superElementRigidJointTest, all far
inside their tolerance). Use --parallel while developing, and the serial run for a commit gate.
Reproducible vs sensitive tests
All three runners take --exit-code and return non-zero when something fails - CI depends on
it, and without it CI cannot fail at all. runTestExamples.py and runPerformanceTests.py got
theirs in #2504; until then they always returned 0 and a caller had
to read the summary out of the log. The exudev driver passes the flag always, with no way to
turn it off, and so do the CI jobs: .gitlab-ci.yml through tools/ci/buildManylinux.sh, the GitHub
wheels through the test-command of [tool.cibuildwheel] in pyproject.toml (#2907).
Two of the three mean nothing NEW broke rather than everything passed. For the examples the
exclusions are testRunnerTools.KnownExampleFailures() - five today, three of them missing an
optional package (#2507) - and an entry that starts passing is reported as a dead exclusion
by name, so the list shrinks instead of rotting. For the test suite it is the two sets below.
runPerformanceTests.py excludes nothing: every performance test passes today.
Not every test is reproducible across machines:
contact and friction models are chaotic; a different machine gives a materially different error, and the size of that error says nothing about correctness
sparse eigenvalue problems go through ARPACK from a random start vector that cannot be seeded
Those are listed in SensitiveTests() in runTestSuiteRefSol.py. They still run, and their
failures are reported prominently — but they do not set the exit code, because a scheduled run
that goes red at random is an alarm nobody reads. Tests needing a looser but still meaningful
tolerance go in TestExamplesToleranceFactors() instead.
SensitiveTests() is currently empty and needs populating from evidence: run the suite on
Windows, Linux and macOS and compare per-test ERROR values; anything varying by orders of
magnitude belongs there. Do not populate it by matching file names — contact/friction/eigen matches
about a third of the suite and would gut the gate.
The third list: known platform differences
UnresolvedOnLinux() holds tests that fail on Linux against the Windows reference values for a
reproducible reason that has not been found yet — currently eight contact and friction models,
measured 2026-09-10. They are excluded from the exit code on Linux only; on Windows they must
still pass, since that is where the reference values come from. Marked L in the overview table,
against * for sensitive.
The distinction matters: sensitive tests are non-deterministic and can never be pinned down; these have a cause and are scheduled for investigation. The list should shrink, and every entry removed is a real fix — treat it as a debt register, not an exemption.
sphereTriangleTest.py is in that list but is not like the others: reference 3.8226, Linux
59370.97. Four orders of magnitude is a divergence, not an accuracy difference.
6. Continuous integration
GitHub Actions only fire on pushes to master and on pull requests, so while master is frozen
at 1.11.0 nothing runs there. .gitlab-ci.yml covers the gap.
leg |
how it is covered |
|---|---|
Linux x86_64, cp310–314 |
|
Windows x64 |
this development machine; |
macOS |
a real Mac, at milestones |
Linux aarch64 |
not covered until GitHub CI resumes — accepted gap |
docs |
|
Nothing is triggered by an ordinary push. Pipelines start from the weekly schedule, the
Run pipeline button, or a tag. The schedule itself lives in the GitLab UI, not in the file:
Settings → CI/CD → Schedules, target branch v2-dev. If that schedule is deleted, CI silently
stops and the repository looks exactly the same — worth checking if the pipeline list goes quiet.
The Linux job runs tools/ci/buildManylinux.sh <pyTag> inside the manylinux image, which is
also what the local docker path uses, so a CI failure reproduces locally with one command:
exudev linux # all five, via docker + WSL; exudev -n linux prints the docker command
Regular CI sets EXUDYN_NOFAST=1, which skips the __FAST_EXUDYN_LINALG binary and roughly halves
build time. Ordinary test runs do not exercise that binary. Release builds must not set it.
The sanitizer job
sanitizers_linux builds Exudyn with AddressSanitizer and UndefinedBehaviorSanitizer and runs
the test suite against it. For a library that calls arbitrary user
callbacks from C++ and hands out references into its own storage, this is the job that turns
“it crashed with no message” into a file and a line.
bash tools/ci/buildSanitizers.sh python3 # also runs locally, in WSL
Four things about it are worth knowing before reading a red run:
No
setup.pychange was needed. The flags travel inEXUDYN_EXTRA_COMPILE_ARGSandEXUDYN_EXTRA_LINK_ARGS, whichsetup.pyalready appends to every extension.CFLAGSdoes not work there.-O1, not-O3. That alone found #2506 on the first build, before a single sanitizer check ran:RaytracingSettings::maxNThreadshad no out-of-class definition, which-O3hid by folding the constant and-O1turned into a module that would not load.LD_PRELOADcarries libasan and the compiler’s libstdc++. Python is not instrumented, so the ASan runtime has to come first; and if the interpreter brings its own C++ runtime - every conda python does - ASan intercepts__cxa_throwagainst the wrong libstdc++ and aborts withCHECK failed: ... real___cxa_throw != 0, which looks like a finding and is only a mismatch.detect_leaks=0. CPython and numpy hold allocations until exit by design; memory errors are still caught, only the exit-time leak report is off.
The job is allow_failure: true on purpose and temporarily: a first sanitizer pass over 107k
lines of C++ finds things, and a job that stays red trains people to ignore it. It flips to false
when the findings are triaged (step R5.6.1). The run log is kept as an artifact whether the job
passes or fails - the log is the result.
7. Commit tiers
tier |
when |
gates |
|---|---|---|
sync |
|
none — but the message must be prefixed |
normal |
the default for every real change |
all four gates in §8 |
release |
a minor version advance, i.e. an Exudyn Release |
§8 plus the full |
8. The four gates for a normal commit
Run in this order; stop at the first failure.
1. The build succeeds
Required for any change to C++, setup.py, or msvc/cppsrc.vcxproj. VS2022
Debug|x64 or Release|x64 from exudyn.sln (created by tools/setupLocalWorkspace.py), or
exudev build (add --fast for the exudynCPPfast module, which is opt-in).
Stale binaries: nothing to do by hand any more. A header change rebuilds because the
Extension lists every header in depends= (#2427), and a change of compiler OPTIONS is caught by
build/exudynBuildFlags.txt, which makes setup.py delete the objects and the linked modules
before compiling (#2468). Both were once manual steps, and the manual advice — delete
build/temp.* — was wrong for a flag change: build/lib.* keeps the previously linked .pyd
and the wheel is assembled from that. If you ever doubt a measurement, delete the whole build/
directory; that is always sufficient. Note that pip wheel hides the build output which would tell
you what happened, unless you pass -v or the build fails.
2. Regeneration is clean — and runs after ResolveIssue
Order matters.
ResolveIssuebumps the micro version and rewritesversion.txt, the version line ofREADME.rstanddocs/generated/trackerlog.md; the wheel in your environment then holds the previous version, which the stub gate refuses to compare against. Resolve, then regenerate, then build.The working order is: resolve the issue first, then regenerate, then commit. During development, run the generators as often as you like; the run that matters is the last one.
Use the tool:
python tools/regenerate.py --check
It runs the six generators from their required working directory and classifies every difference
against HEAD: Tier 1 (the API surface) fails with exit 1, Tier 2 (documentation) warns,
anything outside both tiers is reported as an unexpected gap in the manifest. --no-run checks
without regenerating; --python selects the interpreter. It ignores modifications outside the two
tiers as unrelated work, and ignores files that differ only in their @date ... (last modified)
line — but never excuses a real change inside a tier, however the file got that way.
If your change added an import of a third-party package anywhere in exudyn/, TestModels/ or
Examples/, also run:
python tools/checkExtras.py --check
It compares the [project.optional-dependencies] extras in pyproject.toml against the
imports actually present in the code and fails if something is installed by no extra — so
pip install exudyn[tests] and pip install exudyn[all] cannot quietly stop being sufficient.
A fresh environment is set up with those extras rather than a hand-written package list; see
../howTo/condaEnvironments.md.
If your change touched any file under python/exudyn/, run the linter:
python tools/checkPython.py --check
It runs ruff with the rule set fixed in pyproject.toml — F (pyflakes: undefined names,
names defined twice, unused imports) and E4/E7/E9 (import placement, == None, bare
except:, a file that does not parse), and nothing about formatting — and compares the result
against tools/ci/ruffBaseline.txt. The findings that existed when the check was introduced are
tolerated; a new one fails. The baseline is meant to shrink: after fixing findings, regenerate
it with python tools/checkPython.py --write, and the tool tells you when that is due.
--all lists every finding by rule, ignoring the baseline. ruff is in the lint dependency group
(pip install --group lint); the check fails rather than passes if it is not installed.
If your change touched the stub files or anything that generates them (definitions/, the
emitters), also run:
python tools/checkPython.py --stubs --check
It runs mypy’s stubtest, which imports the module and compares it name by name and signature
by signature against __init__.pyi and symbolic.pyi. Two allowlists: tools/ci/stubtestNoise.txt
is curated (the dunders pybind11 adds to every class, the typing helpers that exist only in the
stub) and tools/ci/stubtestBaseline.txt is the generated backlog, regenerated with
--stubs --write and meant to shrink.
This check reads the INSTALLED package, as do
runTestSuite.py,pytestandrunTestExamples.py: they allimport exudynfrom site-packages, never frompython/exudyn/. A change underpython/exudyn/therefore proves nothing until the package is installed — a passing gate on an uninstalled change is a gate that tested the previous version.
If you added, removed or renamed a .cpp file, do it in msvc/cppsrc.vcxproj — the
Visual Studio project is the source of truth for the compile list — and then regenerate the list
setup.py actually builds from:
python tools/gen_sources.py # rewrites sources.json
python tools/gen_sources.py --check # CI mode: fails on any disagreement
It compares the vcxproj, sources.json and the files on disk case-exactly, because
src/tests/X.cpp and src/Tests/X.cpp are the same file on Windows and two different ones on
Linux. Commit sources.json with the change; an sdist without it cannot build. The minimal
list in that file is not derived — --minimal also defines EXUDYN_MINIMAL_COMPILATION, so it
still has to be kept in sync with the C++ #ifdefs by hand.
Regenerate with python tools/regenerate.py (add --check to fail on Tier 1 drift). It validates
definitions/, runs every generator and emitter in the required order from any directory, and
reports Tier 1 (plan §4.2) and Tier 2 (plan §4.3) differences. The order lives in one place, its
generatorScripts list — do not run the scripts by hand: the order in that list is
the one that works.
makeAllBinariesScripts.py is not part of it; it writes only a volatile build date.
Two measured caveats (2026-09-09, plan §3 facts 11 and 13):
src/Autogenerated/pybind_manual_classes.his rewritten unconditionally byautoGeneratePyBindings.py(nowtools/generators/pybindEmitter.py), so its// AUTO: last modifiedline always shows as modified. Ignore that one line; treat any other change in it as real drift.The committed documentation at
e44aca1is already out of sync withpython/Examples/— a fresh regeneration changes 50 doc files and adds 6. Until that is reconciled, compare against a fresh regeneration, not against the committed state.
3. The full test suite passes
cd python/TestModels
$env:USERPROFILE/Anaconda/envs/venvP312/python.exe runTestSuite.py -quiet
About 22 s serially — far shorter than the compile — so it runs in full, before every commit, never as a subset. Record the failed-test count and compare it against the run before the change.
Two things that will otherwise look like breakage:
Pin scipy to 1.15.2. scipy 1.18.0 slows the suite from ~22 s to over 10 minutes, apparently in the eigensolver path. The
devgroup pins it except on macOS and Python 3.14+, which have no 1.15.2 wheel (#2886). If a run suddenly takes minutes, check the scipy version before looking for a regression in Exudyn.The global tolerance is 5e-14 and some models sit close to it. A failure just above it — for example
movingGroundRobotTest.pyat5.0688e-14, with result and reference agreeing to ~13 significant digits — is floating-point noise from a different numpy/BLAS build, not a real break. Compare the reportedRESULTandrefsolbefore treating it as one.
Committed logs are protected — the runner diverts rather than overwriting. All three runners
(runTestSuite.py, runTestExamples.py, runPerformanceTests.py) write a release-named log into a
tracked directory, truncating it at startup before any test runs. Since 2026-09-10 they check
first:
target does not exist → written normally (the release flow: the version was just bumped)
target exists → the log is diverted to
python/logs/tmp/(gitignored), the tests run as usual, and a message names the override--overwrite-log→ the existing log is replaced deliberately
Existence is the test, so this also covers the multi-machine case: the committed log from another machine with the same platform and Python is present, and is not clobbered.
python/logs/tmp/ is one shared directory for all three runners, so clearing it is a single
delete. The release-named logs go to python/logs/{testmodels,examples,performance}/.
What is in a log
The header records what results depend on: Exudyn version and build date, CPU and core count, and
the installed versions of the relevant packages (numpy, scipy, matplotlib, ngsolve, …,
listed as not installed when absent). scipy 1.18 vs 1.15 already changed suite runtime by more
than an order of magnitude, so this is not decoration.
The log ends with a per-test overview — one fixed-width line per test with result, error, effective
tolerance and runtime, for both TestModels and MiniExamples, with sensitive tests marked *. That
table is how SensitiveTests() gets populated: diff two of them from different machines and the
non-reproducible tests stand out.
Which compiled module is under test. A release ships two: exudynCPP, built for the baseline
instruction set, and exudynCPPfast, without range checks and with AVX2. By default every runner
uses the first. To run against the second:
python runTestSuite.py --fast-module # also works with --parallel
python runPerformanceTests.py --fast-module
EXUDYN_MODULE=fast python -m pytest -q -n 8 # pytest has no flag; the variable is the mechanism
--fast-module sets EXUDYN_MODULE=fast, which is read by exudyn/__init__.py before the C++
module is imported. It is an environment variable rather than a flag precisely because child
processes inherit it: --parallel and pytest -n run each model in its own interpreter. An
explicit sys.exudynFast in a script still wins over it. Note --fast is a different option
entirely — the pull-request subset.
Two things follow automatically. The suite applies AVX2ReferenceSolutionUpdate() (a second set of
reference values for the models whose results move under AVX2) and says so in the log; and the log
file gets a _fast suffix, so the two runs do not overwrite each other. If the fast module cannot
be loaded — no AVX2 on this CPU, or it is not in the installed package — the run stops rather
than quietly testing the regular module under a fast-module log name.
Release testing matrix
Decided 2026-09-16, adjusted to the two variants of step R2.10:
what |
against which Python versions |
|---|---|
full |
every supported version |
full |
the oldest and the second newest (today 3.10 and 3.13) |
examples |
one version, default module |
|
|
The newest version is deliberately not the fast-mode target: right after a release its packages are
the unstable part, so a failure there would almost never be about the module. Keep every log —
the _fast suffix is what makes that possible.
Performance logs are per machine. Set EXUDYN_MACHINE_ID once per machine (e.g. i7-1370P)
and runPerformanceTests.py files its logs in that subfolder, since timings from a laptop and a
workstation are not comparable. Without it, a legacy fallback still routes any 20-core machine to
i7-1370P/; that fallback goes away once the variable is set everywhere.
Examples are not run here: they take many minutes and only check that scripts do not crash
(they time out after a few seconds each, and are not compared for identical results). Run them
with exudev examples for releases and large steps only.
4. Docs and plan are updated
docs/manual/*.md, wherever user-visible behaviour changed; the generated pages follow fromdefinitions/and the docstrings.exudev docsbuilds without a warning; for a change todocs/,definitions/orconf.py,exudev docs --check- the PDF as well, its log against the baseline (§1, #2856).The step status in
docs/revision/exudynRevisionPlan2026.md. If a fact in info document §3 turned out to be wrong, correct it there rather than working around it.
9. Committing
After the gates pass:
ResolveIssue(issueNumber, notes='...')— this bumps the micro version and rewrites the version files listed in §3. Then re-run the generators and build the wheel, because the documentation and the installed module carry the version string — see gate 2.Present the maintainer with an overview: files changed, gate results (build, drift, test count), the issue resolved, and the proposed commit message.
Wait for explicit approval. Claude commits only on a clear go-ahead.
Claude never pushes, to any remote, ever. The
pre-pushhook intools/hooks/refuses a push togithubfrom any ref butmaster/release/*.
Commit message convention — reuse the tracker’s own vocabulary so commits and issues speak the same language:
<TYPE> #<issue number>: <one-line summary>
<why, and anything the diff does not make obvious>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Example: BUG #2107: fix 32-byte alignment of VectorBase under AVX2
10. Build and release scripts
One driver, exudev (exudev.bat in the repository root; python tools/exudev elsewhere). It
replaced the sixteen batch files of tools/buildAndGenerate/ Every
command has a --help, quiet is the default and -v/--verbose turns the tools’ output back on;
exudev -n <command> prints the command lines it would run and runs nothing, which is the
quickest way to see how a step actually works. The commands are listed in
tools/exudev/README.md: generate, build, test, examples,
perf, docs, linux, release, clean, env.
Two points that catch people out. --fast is opt-in: pyproject.toml has
compileExudynFast = true, so a plain pip wheel . builds exudynCPPfast while exudev build
does not unless asked. And exudev env is the first thing to run when a test fails in one
environment only - it prints python, exudyn and numpy per environment, which is how the numpy
dependence of issues #2501 and #2502 was found.