Skip to content

Generate, build, run and inspect guarded motion specifications - #5

Merged
vamsikalagaturu merged 656 commits into
mainfrom
dev
Oct 5, 2026
Merged

vamsikalagaturu merged 656 commits into
mainfrom
dev

Conversation

@vamsikalagaturu

Copy link
Copy Markdown
Contributor
  • Lower motion-spec-dsl models, with their scenes and FSMs, through SHACL validation to an IR and generated C++ controllers
  • Run them in MuJoCo/KDL simulation (mj_kdl_wrapper 0.5.0) or on Kinova Gen3 + Robotiq hardware (robif2b)
  • Archive every run with provenance and a full-rate frame log that can be replayed, verified and recovered into RDF
  • Inspect generations and runs in the dashboard
  • Set up a workspace from the .repos manifests; check it with motion-spec health
  • Sphinx documentation and CI

KishanSawant and others added 30 commits August 14, 2026 16:41
A controller commanding a Cartesian force writes the magnitude the wrench
operation reads. The feed-forward branch claimed the id first and named it
`cmd_`, the forwarding form, so a feed-forward force command left the wrench
reading a signal nothing wrote. Only a command that goes straight to a device
is `cmd_`.
An error states which way the quantity has to move: positive to rise, as an
equality's `setpoint - measured` does and as a band's lower edge does. The
greater-than gate returned the negation, so a controller driving it to zero
pushed the quantity further below the threshold it was meant to reach. The
less-than gate beside it already read the right way round.

Monitors compare the magnitude, so every gate authored today keeps its verdict.
The per-axis fan-out and its pose-difference errors are unchanged; only the
destination differs. A moment plan's axes are f_ext directions, so they claim
neither an acceleration row nor any of the solver's row budget.
The cartesian-force pass ran before the control laws were appended, so every
commanded wrench was built from the previous tick's magnitude. Held back and
emitted after the controllers instead; the walk still claims in place, so
shared prerequisites keep their position.
…ries

A pose of a scene object, or of one of the frames that object carries, is
answered by the simulator's scene -- no forward kinematics of the arm reaches
a drawer. Three things kept that from resolving, each keying behaviour on a
spelling rather than on what the graph states:

- the object lookup compared the scene's body name, which keeps its dashes,
  against the generated id, which spells them as underscores, so only an
  object whose body name held no dash ever matched;
- the same call passed that generated id on as the body to look up at run
  time, where the name MuJoCo compiled is the scene's own spelling;
- a frame stood for its body only when named `<body>_origin`, though the body
  states its root in the graph and `placement_frame` already reads it.

A frame that is not its body's root is now read off the site the scene
already marks it with, which `_scene_frames` mints and `get_site_frame`
answers; nothing composes an offset onto the chain. `site` is None rather
than "" when absent, because ST4 reads an empty string as true.

Generated C++ for the three maintained models is byte-identical; their IR
moves only where a root frame now reports its body (`world` -> `world_body`,
`table_top` -> `table`), which reaches no template.
Two constraint handlers may author the same controller name. The IRI keeps
them apart, `<handler>/<name>`, but the generated id is the local name
alone, so both controllers and everything derived under them claimed one
id and generation failed on the first derivation registered twice.

Qualify a handler-owned name by its handler when a node under a second
handler carries the same name, the way a context quantity is already
qualified by its owner. It covers the nodes minted beside a controller --
saturation, angle normalization, integral state -- which would have merged
silently rather than failing. A name no second handler uses keeps its id,
so existing models generate unchanged.

Raise ConstraintViolation, not RuntimeError, when two ids still collide,
and say which entities claim the id and what to do about it.
Three fixtures had drifted from what they read:

- A path driver owns a velocity profile since motion-spec-dsl 299d9f3, so
  the dual-arm model lowers two profiles per arm -- one shaping a measured
  input, one owning a tangent speed -- and pick_above schedules its profile
  between the projection that measures the parameter and the evaluator that
  samples the curve.
- bdd_ros2_interfaces gave TrinaryStamped a `reason` field, so the message
  no longer has the single payload field the publish sugar needs. Name the
  field, and cover the sugar on a type that still leaves no choice.
- The subscription cases lowered `detect_pick_single`, deleted as redundant
  in bdd_collab_bhv_cpp 99d09dd. Read `real_perception_test`, which is
  maintained and states the same chain.
A context declaration may now state `- <offset>`, which lowers to an op
whose operands are named rather than collected: subtraction does not read
the same either way round, so the minuend and the subtrahend each have
their own predicate and the closure renders them in that order.
`config_poses` refuses to generate a real-world model whose `[config.<key>]`
section the deployment config does not state, and the pre-launch check then
refused to run because that same section matched no device. Between them no
model reading a pose from its deployment could run at all: every `[poses.*]`
section came back as "configures nothing this run binds".

Count those sections as bound, and re-check their numbers here, since they
are the ones a deployment may retune without regenerating.

The message now names what the run does bind and what makes a section
bound -- a device by the agent its solver realizes, a pose by a
`[config.<key>]` declaration -- and points at the generation it was
checked against, so a stale section says where to go and look.
`agent_home_positions` reads homes only for a simulated platform, so a
`home` under an agent on hardware is a number the deployment tunes and
nothing acts on -- the arm starts wherever it was left. The pre-launch
check passed it silently, which is the failure the check exists to
prevent: a wrong home is silent, and an unread one should not be.

Every homed section is reported at once, and the message asks for the line
to be commented out rather than deleted, so the numbers stay for the
simulated platform that does reset to them.

`home` and the pose fields are named once in resources.py and read from
there, so the run checks what generation derives rather than a second
spelling of it.
The message said a pose section is named by a `[config.<key>]` declaration
without saying what one looks like, so a reader who had never written one
had to go find out. State it with the offending key already in it.
A deployment keeps as many poses as it likes and a model reads the ones it
names, so an unread `[poses.*]` section is a pose on offer rather than a
mistake -- rejecting it forced the file to be edited per model.

Sections the run does read are still checked for shape, and a section that
is neither a pose nor a bound device still fails: that one reaches hardware
this run never commands.
Tuning a deployment config or a scene the controller reads at startup means
running the same build again, and restating how -- which arguments, which
working directory -- every time. Nothing recorded that, so nothing could
repeat it.

Each run now writes `invocation.json` beside its REC record, and `rerun`
launches the most recent one into a run of its own. It generates and builds
nothing. The record is written before the executable starts, because a run
worth repeating is often one that failed.
`rerun` still had to be told which generation to repeat, which is the one
thing the terminal already knows: a generation's path carries a timestamp,
and pasting it back is the friction the command exists to remove. `gen` and
`run` now note the generation they made, and `rerun` with no argument takes
it.

A generation that has never been run has nothing to repeat, so it is
launched the way `run GENERATION` would launch it rather than refused.
A generation's directory is named by a timestamp, so every command after
`gen` had that path pasted into it -- including `rerun`, which was supposed
to be the one that did not.

`gen` and `run` now point `latest` at what they make: one beside the
generation, one over all the models, the way a colcon workspace carries
`log/latest` and a Bazel one `bazel-bin`. `rerun` follows the second when
given no argument.

A symlink rather than a remembered path, because it is the tree that knows:
it survives across terminals, needs no shell hook -- a command cannot export
into the shell that called it -- and every other command takes it as the
ordinary path it is, so `run "$MOTION_SPEC_GEN/<model>/latest"` goes back to
one model after another has been generated since.
It repeated the previous run's arguments, which meant recording them, a
file in every run directory to record them in, and a line claiming to be
repeating a run when it was starting a new one. None of that was asked for:
the generation is what should not have to be named again.

So it invokes `run` with the same options, on the generation `latest`
points at, under a run id of its own. The invocation record goes with it.
path_project golden-sections the parameter to a bracket of
kPathTangentStep, so it converges to 1 - 5e-5 rather than 1.
remaining_path_length reported that sub-resolution tail, which floored
the velocity profile's braking command at sqrt(2*a*tail) -- 4.5 mm/s on
a 1.4 m arc at 0.2 m/s^2. Nothing opposes that: the position constraint
of a path motion holds only the two path-normal directions, so the arm
tracked the residual command off the end of the path indefinitely and
the arrival monitor never latched.

The tail is below what the projection can resolve, so report it as
zero and let the profile brake to a stop.
`standing_shape` descended the message to the ROS type the quantity maps
to, and the descent skips repeated fields -- so a Detection3DArray, whose
detections are exactly that, reached no pose and was rejected. Nothing
could publish one.

When the payload is reachable only inside a repeated field, the message
carries many of them: `_entry_shape` asks of one entry the questions
`observation_shape` already asks of an arriving detection -- where the
pose goes, which string names what it is about, which header states its
frame -- so a topic can be published in the shape another model reads.

The IR is one entry per reported quantity. What the model settles stays
out of the loop: the array's size, each entry's id and frame are written
once at setup, and the tick writes only the quantity and the stamps.
Where a body sits is composed along the poses that place it, searched
over every `geom:Pose` the graph relates -- which includes the model's
own context quantities. A world pose relates two frames the scene has
already placed, so it is the shortest way between them, and the search
takes it.

Declaring a world pose of the table anchor was enough: the anchor is on
the arm's own placement chain, so the arm resolved through a coordinate
that holds no value until the first cycle and generation stopped. A spec
pose is worse -- `look-wall-pose` relates the same two frames and does
carry authored values, so a placement routed through it would have
resolved to a motion target and said nothing.

The search now runs over a graph without them, cached per model.
The rate is read off the monitor and turned into cycles between two
messages by `annotate_publish_rates`, which runs where the control
period is known -- a rate says how often in seconds, and only the loop
knows how many cycles that is. A monitor stating none carries no
divider, so its publish renders exactly as before.

`ros_standing` had to learn to skip monitors. Both kinds of publish now
state a rate with the same term and a monitor's node is typed a topic,
so without the guard a rated monitor would be lowered as a run-wide
publish and rejected for naming no quantity to report.
A pose read from the deployment config carries no authored components --
its whole frame arrives from the TOML file at runtime -- but the DSL
stamps its orientation Euler-typed for lack of an authored coordinate to
inspect. Motion-spec read that stamp as a literal pose and required every
axis to be filled, so referencing a single axis (e.g. `.position.z`) left
the rest rejected as missing. A config pose is now treated like a
snapshot: never authored, never decomposed.
Owner-qualification matched a node whose namespace is exactly a handler,
so `<handler>/<name>` split by handler but `<handler>/<controller>/<name>`
did not. Once the DSL hangs a controller's profile op under the controller,
two handlers with same-named controllers mint distinct IRIs whose ids still
collapse -- and the id index keeps the first, so one motion's velocity
profile silently disappeared from the IR.

Match anywhere below the handler: a node the model hangs under a controller
took its name from that controller, so it is as handler-scoped as the
controller is.
mj_kdl_wrapper 0.3.6 fixes headless EGL recording picking EGL_DEFAULT_DISPLAY
over a GPU device.

health.py's MUJOCO_BUILD_PACKAGES was still pinned at 0.3.4, one version
behind entry_build.stg's prior 0.3.5 -- health would pass an install the
generated CMakeLists then rejected. Both now read 0.3.6.
A pose referenced only as geom-op:in1 of a ComposeOrientation was invisible
to snapshots_for_motion's expand() (never captured -> read but never written)
and to filter_shared_data (dropped from the blackboard -> C++ missing member).
Both now follow the pose-components operands.
A run killed before the archiving step wrote manifest.json could never be
replayed: resolve_archive hard-required verify_manifest even though the frame
log carries its own decode contract, and run_dir_for's manifest-less fallback
resolved to the wrong directory. Locate the frame log via the conventional
logs/frame_log.pb layout when no manifest exists instead of rejecting outright.
vamsikalagaturu and others added 29 commits September 16, 2026 11:05
…t dev from use

A bare `cmake --build --parallel` emits `make -j`: unlimited jobs, overriding
CMAKE_BUILD_PARALLEL_LEVEL and MAKEFLAGS too, so nothing could cap it and the
machine swapped until the OOM killer took the terminal. build_jobs() now uses
min(usable cores, RAM/2GiB), with -j, the cmake variable and [setup] jobs on
top; the colcon path is capped through MAKEFLAGS.

The console log opens with argv, workspace, job count and host memory, keeps the
CLI's own lines, stamps each command and records the exit status or the signal
that killed it. The file copy drops ANSI and progress-bar redraws.

rdflib goes back to upstream: its multi-type scoped @context bug never fires on
generated models, whose context is a prefix map with no type-scoped entries.

The Python components are pip-installed from their pinned git refs; --dev checks
them out into WORKSPACE/src and installs them editable.
The four CMake packages and STSTv4 have no pip or apt route, so a tree must
exist to build them -- but src/ means "yours to edit", and a plain install had
no business putting checkouts there. source_root(root, dev) now sends them to
WORKSPACE/.ms-sources; --dev puts everything, Python and CMake alike, back in
src/ as before. colcon follows through --base-paths, and `vcs import ws/src`
stays right for the dev layout. Removal finds whichever tree exists, so --clean
works across modes.

Also fixes the log's file copy double-indenting every line that had not been
redrawn, and the setup stubs in test_cli that enumerate every keyword argument
and so rejected jobs and dev.
Nothing uses kdl_parser. Neither generation template mentions it, the built
controller links mj_kdl_wrapper, orocos-kdl, tomlplusplus, coord2b, protobuf
and mujoco but not it, and neither mj_kdl_wrapper nor coord2b references it.
The chain reaches C++ through the generated .kdl.hpp, so nothing parses a URDF
at runtime. Worse, the fork installs no CMake config, so the find_package probe
could never succeed and health failed forever on a good install, offering a
remedy that reinstalls the same two files. Gone from the manifest, COMPONENTS,
health and the docs.

health now closes with what you can actually do -- "sim ready | real NOT ready
(robif2b)" -- counting base, dsl, codegen, build and runtime plus the target's
own pair. ROS and introspection are left out: a model needs those, not a
target. A target whose profiles were not all checked gets no verdict.

Also removes templates/CMakeLists.txt, superseded by entry_build.stg's
cmake_mj_kdl and cmake_robif2b, and documents the .ms-sources layout.
… a run fails

A missing attachment was a runtime error: the controller generated, compiled,
linked and then printed "MJCF attachment not found" on startup. unresolved_assets
now resolves every .xml the IR names the way the generated find_asset_path will
-- absolute, cwd, then the mj_kdl_wrapper cache prefixes -- and generate_code
refuses the IR, naming each path and the roots it searched.

The run summary said "done run <dir>" whatever the exit status, then echoed the
directory again on its own line. A non-zero run now says "run failed (N)" in
red, and the redundant echo is gone -- the path is already on the run directory
line above.
…kers by component

A scene names its assets relative to nothing in particular, and the generated
program resolved them against its working directory, so a model ran only from
the one place its paths happened to suit. resolve_model_assets now rewrites the
files a model keeps beside itself to absolute paths while the model directory is
still known; a vendored path stays as written, for the cache to answer.

Both resolvers expand a leading ~, so a scene can name ~/.cache/mj_kdl_wrapper
outright, and both now match the cache markers with relative_to and
lexically_relative instead of searching the string. src/mj_kdl_wrapper/assets no
longer matches inside mysrc/mj_kdl_wrapper/assets; no path in the models relies
on a marker appearing mid-path.

MissingAssets carries the IR, the paths and the roots it searched rather than a
formatted sentence, and the CLI prints it through _Reported, which stamps every
line like the rest of the log instead of dumping a traceback under an Error:.
…ore release

`setup --dev` wrote a config saying `dev = false`, because the sample hardcoded
the built-in defaults instead of the values in force. A later bare `setup` in
that workspace would read it back, reinstall the Python components from git and
undo the checkout. sample() and write_sample() now take dev and editable, and
setup passes what it resolved; `config --init`, which has no run behind it,
still writes the defaults.

The config format goes back to 1 and the marker to 2, with the pip origin folded
into v2's description. At 0.1.0 with no tags nothing has read either format but
our own test workspaces, so a pre-release format grows in place -- the bumps only
made every existing workspace warn "version 1, now at 2" on each read. No reader
needed them: an older motion-spec meeting a pip marker reads "pip", finds it is
not "cloned", and declines to remove a source, which is already correct.
A ROS setup in a plain venv dies building the mj_kdl_wrapper bindings: its cmake
finds rclcpp, ament runs its own scripts under the venv interpreter, and
ament_package is not there. pip strips PYTHONPATH for an isolated wheel build,
so sourcing the distro does not reach it -- the interpreter has to carry it.
health now probes it beside rosidl_runtime_py and ament_index_python, with
`python3 -m venv --system-site-packages` as the remedy rather than the sourcing
line the other two take.

The verdict was one line stamped by the worst target, so "sim ready | real NOT
ready (robif2b)" arrived as an error even though sim was fine. One line each,
each stamped for itself.
find_package(rclcpp) makes ament run its own cmake scripts under the interpreter
configuring the build, and those import ament_package. It reaches the venv over
PYTHONPATH from the sourced distro, but pip replaces PYTHONPATH with its own for
an isolated build, so `setup --ros` died on the mj_kdl_wrapper bindings with a
bare "returned error code 1" and left the workspace without its environment file.

A ROS install now passes --no-build-isolation, and since pip then installs no
build backend either, the checkout's own build-system.requires go in first --
read from its pyproject, so nothing is hardcoded.

ament_package joins the ROS import probes. Its remedy is the same sourcing line
as its neighbours: with a distro sourced it imports, so health cannot see this
failure, which lives entirely inside pip's isolated build.
Finding out that libeigen3-dev is absent after four checkouts and a build helps
nobody. setup now runs the health checks its components need, keeps the ones
apt must answer, and stops. Everything setup installs itself carries a
`motion-spec setup <name>` remedy instead of an apt one, so it cannot refuse
over the very things it is about to build.

Scoped to what was asked for: `setup coord2b` wants the build profile, not the
Ant and JDK only stst uses, and a Python component needs no profile at all. A
ROS workspace adds colcon and the distribution's Python packages -- which
absorbs the separate gate added for the latter.

A missing yaml is what says the interpreter cannot reach apt's packages. The
pyvenv.cfg flag does not: `uv venv --system-site-packages` sets it while
building on uv's own CPython, whose system packages are not the distribution's.
…tro first

Sourcing it left `motion-spec: command not found`, because the file said nothing
about the environment setup ran in. It now activates that venv, and guards both
ROS sourcings so a shell that already has them is unchanged -- sourcing twice
leaves CMAKE_PREFIX_PATH the length it was. The file is written executable.

The ROS distribution joins the preflight. _write_ros_environment refuses without
one, and that is the last thing setup does: every checkout and every build would
happen first, against whatever was sourced, before the refusal.

--no-build-isolation goes back to the C++ bindings alone. It applies to a whole
pip invocation, so a pure-Python component took its transitive source
dependencies down with it: `--dev --ros` died installing motion-spec-dsl for
want of hatchling, which belongs to a dependency and is in no pyproject setup
can read.
…-dev omits motion-spec

The quickstart described one path -- a checkout, a venv, pip -- and the install
now has three independent choices: using it or working on it, colcon or not, pip
or uv. Each has its own block, and the ROS one says why the environment must be
built on the system interpreter, since a uv environment on uv's own CPython sets
--system-site-packages and still reaches nothing from apt.

--dev gives editable checkouts of every component except the one most likely to
be edited: setup cannot check out the code it is running. It now says so, naming
where motion-spec is installed from and where to clone it instead.
It reached into the venv with `uv pip install --python <path>`, which is what
setup does internally and not what anyone should type. Create, activate,
install, as in the three blocks above it.
Three ways a workspace can say what it builds, none of which it could before.

A checkout already in the workspace is adopted at whatever ref it is on, rather
than stopping the run for not being the pin. It is built as it stands, the drift
is named, and the marker records the commit that was built -- so a rerun
compares against the tree, not against a pin it never matched. Uncommitted
changes are built too, and rebuilt every time, because nothing recorded can
describe them. Only a directory that is not a git checkout is still refused.

--external names a component you supply yourself: dropped before anything reads
the list, so no clone, build, marker or prerequisite check, and health still
reports whether it resolves.

--repos replaces the manifest outright. What it omits must already be checked
out, and setup says which components are left with neither a pin nor a source
before it clones anything. The pins are passed down rather than rebound onto a
module global, and health reads the manifest in force per call instead of the
version it happened to import.
…xamples

A fresh clone recorded the pin it was asked for -- a branch name -- while every
later run resolved the checkout's HEAD, so a sha was compared against "master"
and every component was rebuilt on every run. The clone now records the commit
it landed on. Adoption follows the tree rather than the mode: source_tree takes
this mode's directory when it holds a checkout and the other mode's when it does
not, so a plain install builds src/<repository> where it finds one instead of
cloning a second copy into .ms-sources.

--clean never removes a source tree, whoever cloned it. It takes the installed
files, the build directory and the marker, names the checkouts it left behind,
and asks before each one: a prompt per component listing the paths and their
sizes, default no. --all offers the whole build, install and log trees and the
environment files, which is wider than any marker reaches; -y answers every
prompt for a script, and end of input instead of an answer says to pass it.

The colcon call names its build and install bases, so --prefix reaches the place
the markers and the environment file describe rather than colcon's cwd default.
The ROS prerequisite checks -- distro, colcon, the distribution's Python
packages -- run even for a selection that needs no health profile, which is
where a Python-only setup used to fail at the last step, after installing.
is_installed reads the first marker line, so a half-finished install is no
longer reported as done.

motion-spec examples copies the models that now ship inside motion_spec_dsl into
WORKSPACE/src/ms-examples, or --into anywhere else. It adds only: a file already
at the destination is kept and reported, never overwritten. The tutorials and
three fixtures follow the models to their new home.

The README is a quickstart again, with the core requirements as a table and the
RA-L paper as a citation at the end. setup.rst gains uv, the job count, --repos
and --external, drops a flag that never existed, and answers what happens to a
checkout that is already in the workspace.
The C++ components have adopted an existing checkout since 98f5dcf, while the
Python ones kept taking the pip route before they looked: a src/motion-spec-dsl
you were editing was ignored, and the pinned ref was installed over it on every
run, including over an editable install made by hand. pip now fetches a Python
component only when the workspace has no checkout of it, and one that is there
is installed with pip install -e, so the DSL that runs is the DSL being edited
-- and `motion-spec examples` copies the models out of that tree.

component_installed compares the marker's origin against the route this run
would take rather than against --dev, or the adopted checkout would count as
not installed and be reinstalled every time. --editable defaults on, since it
only ever applies where a checkout is installed, and it is no longer a usage
error without --dev: that error's premise, no checkout to edit, is gone.
mj_kdl_wrapper's compiled bindings stay editable only under --dev.
The MuJoCo target checked one thing, the mj_kdl_wrapper package at its pin. Its
own CMake requires glfw3 and OpenGL, its video recorder is a stub without EGL,
and both the recorder and motion-spec's ROS camera recording pipe frames to
ffmpeg. None of the four was probed or in the apt line, so on a fresh machine
health named only mj_kdl_wrapper and `setup` then failed in its configure step.

Four rows now precede it under build[mujoco], each with its why and apt remedy.
The two libraries are found as mj_kdl_wrapper finds them, with find_library;
OpenGL goes that way too, since FindOpenGL exposes no config path. The README
table and the Setup page's MuJoCo row list the same.
The page had no way to learn that a log-less run had begun: live_state
returned before opening the control block, and the no-log verdict came from
the manifest the runner writes only after the process exits. A simulator
armed at --start-paused therefore waited for play behind a page waiting for
frames.

jobs.py keeps the log choice in the RUNNING entry and run_status reports it
before the manifest exists. live.py builds the session for such a run from the
generation's contract record, so the sampler and control block are reached
and `started` means the loop answers. resolve_archive raises ArchiveError for
a manifest without a frame log, and the page hands a finished no-log run to
its console view once the runner exits.
…cument, the run as the Execution, no runtime graph
`run` on a `gen code` output was refused with "generated/provenance/
motion-spec.ld.json: required generated artifact is missing". The validator
still expected the old nested path while generation writes
`generated/provenance.ld.json`. Both call sites now use GENERATION_DOCUMENT,
so the two cannot drift again.
Follows the KDL fork onto vereshchagin-driver-weighting, which collapses the
four solver variants into one. A motion with a Cartesian force no longer needs
a second `_Fext` solve: the single solver runs with setDriverWeights at weight
one, so the constraints let the modelled wrench through instead of
compensating it.

`beta` is now the true acceleration energy the model states. The solver
offsets it by the root acceleration internally and reports true accelerations
back, so `beta = 0` holds still and no caller shifts it by gravity any more.

On robif2b the constraint torque is blind to a wrench it lets through, so the
torque that exerts it -- `J^T f_ext` per forced segment, from
ChainJntToJacSolver -- is added per segment.
The generated CMakeLists demanded mj_kdl_wrapper::camera_ros, and main.cpp
included camera_ros.hpp, for any model that used ROS at all. A model with one
publisher and no camera therefore failed to configure against a wrapper built
without ROS -- MJ_KDL_WITH_ROS defaults to AUTO, so a wrapper built in an
unsourced shell simply has no such target.

The scene's cameras were already in the IR as composition.scene.cameras; the
build template just never received them. It and app-chain-includes now do.

The guards test `first(scene.cameras).id`, not the list and not `first(...)`
alone: with stst's JSON binding both of those are true for an empty list, so
only a property of a real element distinguishes. ir.py:189 records the same
trap and solves it with `x or None` where a dict is being built.
The release that carries the vereshchagin-driver-weighting build and the
Python examples' command fix. Both places that name the version move
together, as the suite asserts: the manifest setup clones from, and the
find_package the generated build emits.
rec dropped consolidation, so archive verification no longer calls
_verify_consolidated; _require_rec_provenance already covers the run
identity check and the SHACL check ran one line earlier. add_resource and
add_artefact take used_by/used_at/generated_by/generated_at/label,
dependencies() emits version, and record_software replaces the
log_repositories + log_dependencies pairs in the runner and the archive.
… dashboard

rec now records an OSLC state and verdict instead of a status: the lifecycle
reader returns both, the runner ends a run with a verdict, the dashboard's
runs and rows carry state and verdict and the badge shows the verdict of a
complete run. rec's record is <run-id>.ld.json and the run's own execution
document sits beside it; modelled robots are read from the generation
document the caller names, which the run directory does not hold at start.

The dashboard keeps one entry per run rather than per generation, lists a
generation's live runs with a stop each, and stops or controls a run by
its path. The runner names the frame and control blocks per run so two
runs of one generation share nothing; the real robot still runs one thing
at a time.
* Record occurrences in the runtime; project runtime.ttl while the run runs

The runtime already detects every semantic edge (state change, event, constraint and monitor
satisfied edges, watched members held or lost) and threw them away per tick. It now appends
each one to logs/occurrences.pb, with a whole frame sampled at the declared interval, and one
projector fed from that stream grows runtime/runtime.nt per triple and rewrites
runtime/runtime.ttl atomically during the run. The archive, the dashboard and
replay --recover-runtime-ttl read the same projector and file; the post-run recovery from the
frame log, --no-runtime-ttl and the dashboard's own projector are gone. The runtime document's
provenance names the projection activity.

* World model layer: port table, one kinematics, backends with read and apply only

Every measured and commanded physical variable is one port in a generated table, bound by
address at startup and sealed; scene objects are world frames read from the integrated state
through mj_kdl::SceneState, so nothing lags a step behind and the real backend accepts them.
Backends have two hooks per tick, read and apply; the solver, forwarding and perturbation
rules are backend-neutral and write command ports. WorldKinematics runs one outward pass per
cycle carrying poses and twists; the chain-root inverse is taken once per runtime, the tip
twist and the RNE wrench frame come off that pass, and no chain FK runs inside a motion.
Requires mj_kdl_wrapper 0.3.17.

* Tests: the proto parser skips enum bodies, gen takes a sample interval, a run archive carries its graph

* Tests follow the world pass: twists and the RNE frame are world reads, the IR section takes its ports

* Recovery reads the same design graphs the live projector did; units resolve order-independently; the observing agent is a sosa:Sensor

A generation run's manifest imports its design graphs in place and lists no model_imports, so a
reprojection found no signal map and dropped every sampled observation. One function now hands
both paths the graphs beside the manifest. A subject declared with two units stays unmapped and
each borrow pass reads only what the pass before settled, so the projection is a pure function
of the stream. sens:ObservationShape requires the madeBySensor object to be a sosa:Sensor.

* Tests: a root frame's segment is named after its body; a perturbation test places its body

* Resolve a world joint's segment by the joint's scene IRI, never by its local name

Two grippers on two arms declare the same local joint name; a suffix match put both measured
ports on the first gripper's segment and the world model refused the second binding.

* Test: the two-gripper port table places its free bodies

* Parity doc: the wrench law as 51da846 runs it

The page still described the pre-51da846 sim form, qdd = ACHD(f_ext = w)
then a wrench-free RNEA bridge, and listed RNEA(qdd, 0) + tau_w as
rejected. That rejection predates the fork fix that makes the _Fext
solver return J^T f_ext alone; with it the composition is sound and both
backends now run the same two passes, forking only in the completion.

States the doctrine (a wrench is exerted, not absorbed), why the default
solver cannot prioritise f_ext, the authoring consequence (redundancy
pinned in joint space, impedance gains sized as joint torques), moves the
old sim form into history, and names which models gate what.

* Tell a free body's tree by its having no joint, not by a flag

scene-dsl marked each tree free_root, but the trees already say it: a body
no joint holds comes out as a tree of its own with no joint in it, the
scene's tree articulates at least one. The port table reads that instead,
and the scene-dsl flag goes.

* Publish the world forest on /tf under the scene anchor: driven trees, scene-placed free roots, joints through the port table

* Runtime graph: firings and goal satisfactions are typed, a maintenance is informed by the observation it was judged on, instants stand at dateTimeStamps, the tick rate is the controller's, the projection names its projector

* Let a mobile platform be driven, placed and observed

A force controller routed to a force distribution never reached the loop:
_cartesian_force_nodes walked only serial chains, so the ops building its
wrench reached the IR and no call reached the tick. A distribution's drivers
hang off the solver, and are collected now.

A PoseToDirection taken off a distance pointed at a carrier the DSL declares
and never emits; it is retargeted to the distance's own relative pose, which
is the line the distance measures.

The platform itself was never placed in the world model: a tree only earned a
free-root port when no segment carried a joint, and a platform articulates
below its free root, so the world model held it at its spawn pose for the whole
run while MuJoCo moved it. Trees are maximal joint-connected sets, so the test
is whether the tree is the anchored one; an agent tree resolves its spawn body
through the ktree mapping.

A twist seen by a frame off this chain is answered the way a pose already was,
by composing two world reads -- without it a hand held in a table frame has no
correctly-framed derivative to damp against.

The wrench a distribution sums is carried into the platform's own axes; the
frames it needs are claimed as world reads, keyed by segment because two arms
carry one bracelet_link.

* Wire the platform through the loop's remaining seams

The pieces the mobile-base work still had open in the tree: a force
distribution carries the wrenches routed to it, the platform's twist and its
commanded wrench are answered by the solvers that write them rather than by a
chain, and the MuJoCo backend reads the pivots and hub rates and drains the hub
torques through the same dictionaries the KELO drive uses.

Committed as they stood: this is the tree that produced the verified
eddie_align runs, not a separate change on top of it.

* Carry an object's authored colour into the MuJoCo scene

read_scene dropped what the scenex states about how an object is drawn, so the
colour reached the graph and stopped there. It is read per asset now and
expanded for a path-backed object too, which the backend emits as rgba beside
the asset path.

* Drive any arm in simulation, and place a commanded wrench from its own frame and point

mj_kdl robot templates dispatch on the backend alone, so the simulator drives whatever arm the
scene holds; only robif2b dispatches on to the robot model and rejects one it has no driver for.

A commanded wrench is moved onto its segment from its as-seen-by frame and reference point,
all three read off the world model, for ACHD and RNE alike.

* Set up the workspace from the .repos manifests

- `motion_spec.repos` is the one source list: Python repos under `thirdparty/`, then the CMake packages in build order; `motion_spec.real.repos` layers the real-robot drivers behind `--real`
- `motion-spec setup` makes the venv, runs `vcs import`, pip-installs the Python repos (editable with `--dev`) and builds the CMake ones, with or without colcon
- CMake flags come from `colcon.meta` (shipped defaults, the workspace's own overrides)
- Sources always live in `WORKSPACE/src`; `thirdparty/` gets a COLCON_IGNORE under ROS
- Drop the `[setup]` config section, `.ms-sources`, the git pins in pyproject and the `dsl` install feature
- `setup --clean` prints each trashed item with where it landed in the trash, and each deleted marker
- health, provenance and CI read their sources from the manifest

* Follow mj_kdl_wrapper 0.4 and the numbered examples

- Generate against the 0.4 API: `mj_env.scene`/`mj_env.viewer`, status returns with their error, `set_control_mode`, `open_viewer`, owned path strings, per-object name prefix
- Name force-torque sensors by their local id and frame; the wrapper adds the runtime prefix
- Drop the scene colour override, which 0.4 no longer takes
- Leave out a chainless gripper's own assembly when a chain's assembly already mounts it
- Docs, fixtures and tests use the renamed examples (`01_pick_and_place`, ...), found through the installed DSL's `models`

* Apply the audit's fast fixes and bug fixes

- remove the mutation package, the diff/journal/archive commands and the ir/codegen commands; gen ir|code is the one entry point
- remove dead functions and module mains in introspection, generation and rdf_parser; drop nicegui
- forward kinematics: a chain whose solver has no dynamics family is read only and held in position mode
- fix dashboard -k/-r port detection: ask click whether --port was given
- fix the JupyterLab frame policy to use the dashboard's own port
- cmake_robif2b links hddc2b, LAPACK, BLAS, KELO and EtherCAT for a mobile base
- remove can_start_* and monitor_<motion>, never called, with when_terms and their *_needs_* fields
- key aggregate monitors' watched errors by constraint IRI: short ids collide across motions and made generation nondeterministic
- tutorials follow the reworked examples
- tests and fixtures follow the removed commands and the solver algorithm

* Apply the audit's section 4 simplifications

- drop the dashboard Reports sweep (analysis.py); the verdict table stays
- sampling: seed from [sampling] seed in the robot config, draw recorded in the frame log; no --seed, no sampling.json
- frame log: one shipped frame_log.proto compiled by protoc, format_version 2, repeated slot lists with sparse quantities, Controller/Monitor header slots and ControllerSample/MonitorSample frame samples
- setup: one --clean, HEAD-only install markers, no --prefix/--repos/--cmake-arg/--clear-cache/--all/-y, no stale stst migration
- config: one key table and resolver; workspace.shell removed
- archive: drop the flat layout
- dashboard: one raw_frames() walk, pidfile-based -k/-r, --lan on the click command, no argparse main, no unused RunStore
- templates: runtime.stg keeps the loop core; helpers move to their domain templates and are emitted only when the spec uses them
- templates: empty lists reach StringTemplate as null, replacing 11 presence flags; backend wrappers inlined at their call sites
- introspection controller slots built from the IR rows
- fix nondeterministic generation: sorted alignment and force walks, and a data id with several writers reads all their inputs

* Apply the audit's section 4.3 and provenance rework

- introspection splits into runs/ (runner, archive, replay, provenance, lifecycle events, ros video) and telemetry/ (frame log contract); .gitignore ignores only the top-level /runs/ so the package is tracked
- scene and FSM graphs, the KDL headers and the FSM header come from scene-dsl and coord-dsl in memory; per-tree KDL namespaces and headers; no intermediate ld.json beyond what a reader needs
- the scene and FSM graphs are kept as model/<source>.ld.json and listed in a run's files.graphs; the dashboard's model graph loads them
- generation provenance written once: DSL, coord-dsl and motion-spec graphs, generation-scoped activities and file entities, versioned package agents, sources copied into generated/source
- derivation document: every derived quantity the IR uses with its kind, unit and operands, typed derivation targets
- run provenance: run-scoped controller process, mj_kdl_wrapper as the simulated runtime, the built executable as the used entity, rec logs only itself and the run checkout
- the environment file is copied into the run as files/environment.<ext>
- derived ids registered lazily by SolverIdFactory; acceleration driver ids follow one rule; per-axis moment control signals
- QUDT kind and unit IRIs on quantities
- cli: shared run options, replay --verify, view command for a .scenex
- build with hatchling and hatch-vcs

* Follow the source API in the tests

- runs/ and telemetry/ replace introspection; the IR's telemetry key replaces introspection
- new signatures: generate_code, the record_* provenance writers, build_derivation_document, MotionDrivers.handler, ChainBinding.namespace, MjcfSceneRobot.agent
- archive tests use the generation layout; example and id renames followed
- tests removed whose subject is gone: proto field numbering and proto/descriptor parity, flat-layout archive, can_start/when preconditions, per-package clean
- tests/support.py load_model mirrors generate_model's in-memory load
- still failing: generation determinism, ROS interface packages, frame-log .zst expectations, two hand-built scene fixtures

* Expect one interface package per ROS shape

- a message package exports the interface packages its fields reach into, so the build finds only the one the model names
- subscription, standing publish and action server tests assert that package

* Pack an archived frame log with zstd again

- create_archive_manifest packs logs/frame_log.pb into frame_log.pb.zst once the run has stopped writing it (level 10; 01's log 20 MB -> 8.4 MB); manifest and rec name the packed file
- zstandard moves from the test extra to the runtime dependencies
- concepts.rst run layout: packed log, files/ environment copy, scene and FSM graphs in model/

* Cut the test suite to what example runs cannot show

- Apply the audit's KEEP/MERGE/REMOVE lists, then keep only numerics,
  conventions, safety rejections and archive/dashboard security
- Drop test_generation_determinism: regeneration need not be byte-identical
- Take over the DSL's IR checks, incl. the Table II geometric operators
  with the table_edge_reach fixture
- Hand-built graphs as Turtle text, no builder helpers or closure fixtures
- 12,604 -> 3,909 lines; 126 passed

* Remove dead code, simplify records and gate unused runtime code

- Dead code and stale plan references gone; numpy declared, empy/lark/pillow
  dropped from [test]; PROV from rdflib only
- health derives what setup provides from the shipped manifests
- One base record for controllers and for monitors; one JointQuantity record
  and reader; caches via @reader
- artifacts.py reads keys the IR always carries with [...]
- One cmake_project template for both backends; always-true FSM guards gone;
  admittance, velocity-profile and KDL chain code emitted only when used
- One ROS image subscription for the recorder and the dashboard camera
- pipeline.load_model shared by generate_model and the tests
- Unread manifest_version and provenance schema_version removed
- Not verified: no tests or example runs since these changes

* Split the parser by concern and model the algorithm in F/D/S blocks

- Split resources.py into agents, runtime, scene and deployment, quantities into quantities and views, and coordination into coordination, fsm and ros_messages
- Templates in folders by IR section, one root group per backend (backend/<name>/main.stg); shared groups call hooks by plain name, no backend dispatch, every runtime block gated on use
- Controlled expressions: the controller divides its error by the expression's gradient norm, and quotient is guarded for vectors
- Algorithm in Bruyninckx's terms (2026, sec. 2.3): computation.data holds the D-blocks, computation.functions the F-blocks (was closures), computation.data_access the data access constraints {write, read} with ports
- Generated C++: struct algorithm_data, instance data, header algorithm_data.hpp; functions.stg replaces closures.stg
- Data access is built from the records once every D-block exists; its analysis sets cadence and storage and removes D-blocks nothing writes; telemetry only taps it
- Controller state and gains, joint-space mirrors and goal status are created by the concern that owns them
- New provenance section (uris, derivations); a value read through a view derives from the value it views
- Provenance documents under generated/provenance/ and runs/<id>/provenance/; every prov:atLocation is a path from $MOTION_SPEC_GEN, stated as each document's @base; the run's files are recorded in execution.ld.json
- generations_root() is $MOTION_SPEC_GEN only, with no workspace fallback
- Frame-log format 3: ConstantReader {id, kind, port} replaces ConstantConsumer
- Remove the unread signal rows; replace getattr type sniffing with isinstance and tables; replace order-keeping dict dedupe with modelled order or membership checks; check_health takes a required progress callback
- Architecture docs moved into Sphinx (architecture.md, diagrams in _static/); ruff check and format clean
- Not verified: tests and examples not run since these changes

* Fix the data-access analysis, archive verification and config check order

- Skip a view whose subobject is its own superobject: a whole-component view of a pooled relation reads the superobject's sampling and would otherwise wait on itself
- Check rec's own record for the run's type and agent, and the run's file use and SHACL shapes against rec plus the execution document, so a tampered rec record no longer passes through the merge
- Check bound devices, config poses and unbound sections in IR and config-file order instead of set order, so the first error reported is stable
- Build real Wrench, Pose and Frame records in the ROS publishing tests instead of SimpleNamespace stand-ins

* Validate a run's provenance with the generation that describes what it used

- The SHACL check of the run also reads the generation's provenance, where the executable the run used is described as an entity
- Checksums and rec's own record are still checked against the run's documents alone

* Follow mjkdl, model solver families as one table, merge band constraints

- Follow the mjkdl rename: `find_package(mjkdl 0.5.0)`, `<mjkdl/mjkdl.hpp>`, `mjkdl::` in generated code; `motion_spec.repos` pins mjkdl `v0.5.0`; `colcon.meta`, health, setup, provenance, dashboard and docs use the new names
- Drop the Menagerie lookup from generated programs, `codegen.asset_candidates` and `view.py`; `view.py` reuses `asset_candidates` for the asset cache
- Solver families are one `SOLVER_FAMILIES` table of `SolverFamily` records keyed by algorithm IRI; FPK/FVK are read-only entries instead of `None`; the marker classes and `issubclass` dispatch are gone
- `BandConstraint` replaces the identical `BilateralConstraint` and `OutsideConstraint` records; the IR `type` is unchanged
- Raise `ConstraintViolation`, not `RuntimeError`, for an unsupported robif2b robot model, an output the simulator cannot report, a forwarded joint outside the handler's trees and a controller missing a required gain

* Drop sorts that manufactured an order

- mobile_base: platform wrench terms are summed, so their order is irrelevant
- artifacts: devices and header slots already come in index order

* One solver state per solver instead of one per motion

- solver state lives in robot_io as <solver>_state, initialized once in main
- sized by the new constraint_rows (widest motion); each motion zeroes the rows it leaves
- ACHD f_ext and driver weights set once; a motion without a wrench passes f_ext_zero
- per-motion solver members and init_<motion> removed; motion functions take robot_io non-const
- hold_gravity_<motion> takes only robot

* Target mj_kdl_wrapper 0.5.0

- `motion_spec.repos` pins `mj_kdl_wrapper` (vamsikalagaturu/mj_kdl_wrapper) at `v0.5.0`, checked out as `src/mj_kdl_wrapper`; `colcon.meta` passes `-DMJ_KDL_OROCOS_KDL_FROM_PACKAGE=ON`
- Generated code uses `find_package(mj_kdl_wrapper 0.5.0)`, `mj_kdl_wrapper::mj_kdl_wrapper` / `::camera_ros`, `<mj_kdl_wrapper/...>` headers, namespace `mj_kdl` and `MJ_KDL_WITH_ROS`
- Assets resolve from `src/mj_kdl_wrapper/assets` and the `~/.cache/mj_kdl_wrapper` cache, in generated code, `codegen` and `view.py` (`import mj_kdl_wrapper as mjk`)
- health, setup, provenance, dashboard, README and docs use the same names

* Read a joint's force instead of a gripper's motor current

- A `joint-force` solver output reads the chain joint's effort port (`joint-effort` per backend; robif2b negates it); a joint off the solver's chain is refused
- The gripper motor-current output, its world port and the simulator refusal for it are gone
- Tests build solvers through `SOLVER_FAMILIES`

* Pin motion-spec-dsl to v0.1.0

- `motion_spec.repos` checks out the DSL at its first release instead of dev

* Run the test workflow without ROS and with the DSL beside the checkout

- vcstool from apt: PyPI's imports pkg_resources, which current setuptools no longer ships
- The manifest is imported outside the checkout and pytest runs tests/ only, so other repositories' suites are not collected
- MOTION_SPEC_GEN is set, and motion-spec-dsl v0.1.0 is cloned next to the checkout, where the fixtures import its scenes from
- REQUIRES_ROS moves to support.py; the tests that build example 06's IR, which publishes a ROS topic, skip without rosidl

* Install protoc in the test workflow

- The frame-log codec is generated with protoc, which the runner does not have; the README already lists it as an apt dependency

* Show how to fetch the sources by hand before setup

- Importing the manifests with vcs first leaves setup only to build what is in src/

* Import the sources by hand; setup only builds what src/ holds

- The manifests move to the repository root for `vcs import src < src/motion-spec/motion_spec.repos`; the wheel ships copies through force-include, and an editable install reads them from the clone's root
- setup no longer runs vcs: it builds the checkouts it finds, skips a missing one with a hint to import it, and stops before touching anything when none is imported
- setup --dev accepts motion-spec cloned anywhere under the workspace's src/
- CI imports the root manifest; README, docs and the manifest header describe the import step and the colcon (--ros) variant

* Lay out CI as the manifest does instead of cloning the DSL twice

- motion-spec is checked out at thirdparty/motion-spec and the manifest is imported into the workspace, so thirdparty/motion-spec-dsl sits where the test fixtures import from
- Every step runs from the workspace; the separate motion-spec-dsl clone and its second copy of the pin are gone
@vamsikalagaturu
vamsikalagaturu merged commit 6c105d1 into main Oct 5, 2026
3 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.

2 participants