Skip to content

Define observation/control alignment for discrete-time models - #331

Merged
mattlevine22 merged 28 commits into
mainfrom
md-control-alignment
Sep 24, 2026
Merged

mattlevine22 merged 28 commits into
mainfrom
md-control-alignment

Conversation

@MatthieuDarcy

@MatthieuDarcy MatthieuDarcy commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

Add observation_control_alignment for discrete-time Simulator (#312).

Mathematical summary

Implements the same_time vs previous time distinction. same_time (default, preserves the existing behavior) implements:

$$ \begin{aligned} x_0 &\sim p_0 \\ y_0 &\sim p(\cdot |x_0, u_0) \end{aligned} $$

and subsequently

$$ \begin{aligned} x_{k+1} &\sim p(\cdot | x_k, u_k)\\ y_k &\sim p(\cdot| x_k, u_k) \end{aligned} $$

previous_time implements

$$ \begin{aligned} x_0 &\sim p_{0}\\ x_{k+1} &\sim p(\cdot | x_k, u_k) \\ y_{k+1} &\sim p(\cdot| x_{k+1}, u_k) \end{aligned} $$

Summary of changes

previous_transition contract

Adds an explicit property of DynamicalModel defined at initialization called observation_control_alignment: Literal[ "same_time", "previous_transition" ] = "same_time"

When defined as previous_transition, this results in the following behavior

  1. $y_0$ cannot be sampled. Hence observations $y$ is of size $T-1$ while the states $x$ are of size $T$.
  2. I added the controls $u$ in the returnedSimulatedResults. This is very practical when doing MPC. This leads to a similar behavior as observations: when using "same_time" it is of size $T$, when using "previous_time" it is of size $T-1$. Only available when running DiscreteTimeSimulator.

This means that states and times are of different sizes to controls and observations (specifically size $T$ vs $T-1$) when using previous_transition.

  1. MPPI is updated to use this convention in Fixed MPPI #348

Updated DiscreteControlLoopSimulator

DiscreteControlLoopSimulator now deliberately always uses the previous-transition convention, independent of dynamics.observation_control_alignment:

$$ \begin{aligned} x_0 &\sim p_0, \qquad \hat p_0 = p_0, \\ (u_k, s_{k+1}) &= \pi(\hat p_k, t_k, t_{k+1}, s_k), \\ x_{k+1} &\sim p(\cdot \mid x_k, u_k, t_k, t_{k+1}), \\ y_{k+1} &\sim p(\cdot \mid x_{k+1}, u_k, t_{k+1}). \end{aligned} $$

Previously, it was assumed that the observation function could support u=None. A closed-loop result therefore has $T$ states, times, and beliefs, and $T-1$ aligned observations and controls. This matches the previous_transition convention and avoids imposing an implicit requirement that the observation model accept u=None or be control-independent, but is change in the behavior.

Future work

  1. previous_transition only works for simulation, no conditioning and not filtering. Worth delegating to a seperate PR?
  2. Adding controls in SimulatedResults for ODE/SDE/Continuous time. It might also be worthwile to expand support for continuous control beyond zero-order hold.
  3. LTI_discrete does not accept observation_control_alignment as a keyword, I did not touch it but worth looking at it once we are satisfied with the contract.

@MatthieuDarcy
MatthieuDarcy marked this pull request as ready for review August 31, 2026 19:09
Add observation_control_alignment for discrete-time Simulator (#312)

Add an explicit observation_control_alignment: Literal["same_time",
"previous_transition"] field to DynamicalModel, defaulting to "same_time"
(today's behavior, unchanged). "previous_transition" pairs y_{k+1} with u_k
(the control that produced x_{k+1}) instead of pairing y_k with u_k, matching
DiscreteControlLoopSimulator's existing closed-loop convention and avoiding
the acausal y_0-depends-on-u_0 coupling.

For "previous_transition", DiscreteTimeSimulator/dsx.simulate never samples
y_0 and excludes x_0/t_0 from the returned SimulatedResult -- states,
observations, times, and the caller's ctrl_values all end up the same length,
with no padding or off-by-one bookkeeping required.

Scope: the plain Simulator/DiscreteTimeSimulator/dsx.simulate generation path
only. mppi.py and discrete_controller_simulators.py are unchanged, deferred
to a follow-up.
Include x_0 in all results; add controls to SimulatedResult

For observation_control_alignment="previous_transition", the result now keeps
x_0 and the full times/states path (length T), matching "same_time". Only
observations stay one shorter (y_1..y_{T-1}, length T-1) since y_0 is never
sampled -- so states[k+1] pairs with observations[k].

Add a controls field to SimulatedResult carrying the aligned ctrl_values used
(length T for same_time, T-1 for previous_transition; None when uncontrolled).

Also drop the bespoke _sample_discrete_observation_path in favor of calling
_emit_observations directly with sliced states/times, and fix
_sample_observation_path to vmap over arrays rather than indexing by a scanned
integer, which crashed on zero-length observation paths.
Simplified docstring
MatthieuDarcy and others added 4 commits September 15, 2026 10:42
Trim the observation_control_alignment docstrings in DynamicalModel,
_validate_observation_control_alignment, SimulatedResult and
_sample_observation_path, and rewrite the DiscreteTimeSimulator docstring
with explicit transition and per-convention observation equations.

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

Copy link
Copy Markdown
Collaborator

"DiscreteControlLoopSimulator now deliberately always uses the previous-transition convention, independent of dynamics.observation_control_alignment:"

  • Hmm, seems better to keep this aligned with reality somehow. I suppose if observation_control_alignment is not specified by a user, then we are free to choose the "right" default; if a user does specify it, we should error if they made an incompatible choice.

@mattlevine22

Copy link
Copy Markdown
Collaborator

"previous_transition only works for simulation, no conditioning and not filtering. Worth delegating to a seperate PR?"

@mattlevine22

Copy link
Copy Markdown
Collaborator

"Adding controls in SimulatedResults for ODE/SDE/Continuous time. It might also be worthwile to expand support for continuous control beyond zero-order hold."

@mattlevine22 mattlevine22 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, seems solid to me.

Main asks are:

  1. Clean up the latex math and make sure it renders in markdown (I didn't read all of it because of that) and is fully specified including all zero cases (e.g. s_0 (even with a note that pi auto-initializes s0 in practice))

  2. Try to return useful time-indexes that offer some redundancy to users in SimulatedResult (rather than needing to write things like t[1:])

  3. We should always at least issue a warning if we override a user's choice; here w.r.t. dynamics.observation_control_alignment.

  4. Look to see if it is easy for the user to simply supply t0 instead of appending it to predict_times in previous-mode; my guess is not, which is fine. May just be better dealt all at once with #272. Would be great to get your thoughts on this though.

  5. Add some is_finite checks to the tests (esp hierarchical)...we've gotten fooled in the past and had passing tests that were producing NaNs.

NB: there are a lot of new tests, which we've been resistant of. However, I think they seem reasonable, and in particular, when messing with indexing I feel better about more guardrails and edge case tests, so I'm happy with these.

Comment thread docs/api_reference/public/control.md Outdated
@@ -6,22 +6,22 @@ single discrete-time trajectory. At each step it performs
\[

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't rendering on the GitHub or cursor markdown viewers. Mind massaging this somehow to get it to appear? sometimes you need to wrap long latex lines in ..., not really sure though.

Comment thread docs/api_reference/public/control.md Outdated
belief; no synthetic initial observation is generated. Every observation at
`t[k + 1]` receives `u[k]`, the control that produced its state. Closed-loop
simulation therefore always follows the `"previous_transition"` convention,
independently of `dynamics.observation_control_alignment`. For `T` prediction

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would rather us give an error if the user selects the wrong dynamics.observation_control_alignment. We should allow them to omit it and then default to the appropriate choice at the right time...hopefully not easier said than done?

Comment thread docs/tutorials/control/mpc_demo.ipynb Outdated
" t = result.times[0]\n",
" filtered_mean = result.filtered_states_mean[0]\n",
" axes[0].plot(t, result.observations[0, :, 0], \".\", color=color, alpha=0.4, label=f\"{label} (observed)\")\n",
" axes[0].plot(t[1:], result.observations[0, :, 0], \".\", color=color, alpha=0.4, label=f\"{label} (observed)\")\n",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love that the user has to know to do t[1:]. I've messed stuff like that up so many times over the years.

What do you think about having explicit fields for the observation times, state times, and control times?

@DanWaxman I know this may be a bit wasteful, but (a) maybe it can be optional to return them and (b) I really think ease of use here may be worth it. At least times are scalar so it really just scales with length of the series (not dimensionality of the system).

"\\begin{aligned}\n",
"&x_0 \\sim p(x_0)\\\\\n",
"&y_0 | x_0 \\sim p(y_0 | x_0, t_0) \\\\\n",
"&\\hat{x}_{0|0} = \\text{FilterUpdate}(y_0, t_0) \\\\\n",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should still define all the 0 variables so that this system of relations is fully specified. I'll try to flag that where I see it, but maybe ask uncle AI to find those spots.

Comment thread dynestyx/models/core.py Outdated
observation_dim: int | None = None,
categorical_state: bool | None = None,
continuous_time: bool | None = None,
observation_control_alignment: Literal[

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After thinking on it, perhaps for now leave this as is with same_time default; When doing control stuff, if it is same_time, issue a warning that you are changing it to previous.

Comment thread dynestyx/simulation/discrete.py
Comment thread dynestyx/simulation/discrete.py
assert tr["f_times"]["value"].shape == (2, 1, 4)
assert tr["f_states"]["value"].shape == (2, 1, 4, 2)
assert tr["f_observations"]["value"].shape == (2, 1, 3, 1)
assert tr["f_controls"]["value"].shape == (2, 1, 3, 1)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would assert that things are not NaNs. That has bitten us before.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a finiteness check

Comment thread tests/test_discrete_control.py Outdated
filter_config=EKFConfig(),
)

assert result.states is not None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd assert more clearly what these should be, e.g. finite arrays

Comment thread tests/test_discrete_control.py Outdated
filter_config=KFConfig(filter_source="cuthbert"),
)

assert result.states is not None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again, maybe assert these are finite arrays

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might even be worth a helper that is assert_finite(array, shape=Optional)

@MatthieuDarcy

MatthieuDarcy commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor Author

"DiscreteControlLoopSimulator now deliberately always uses the previous-transition convention, independent of dynamics.observation_control_alignment:"

* Hmm, seems better to keep this aligned with reality somehow. I suppose if `observation_control_alignment` is not specified by a user, then we are free to choose the "right" default; if a user does specify it, we should error if they made an incompatible choice.

After thinking about this, I think that we can support both conventions for online control (and keep the default same_time) at the cost of deviating from the existing convention we introduced. Here is my summary:

same_time convention

Notation:

  • $\tilde{p}_k \approx p(x_k \mid y_0, \dots, y_{k-1},\ u_0, \dots, u_{k-1})$is the predicted distribution.
  • $\hat{p}_k \approx p(x_k \mid y_0, \dots, y_k,\ u_0, \dots, u_k)$ is the filtered distribution.

In this convention, the control $u_k$ drives the transition into the next state $x_{k+1}$ and generates the observation $y_k$ (they are aligned).

$$ \begin{aligned} &amp;x_0 \sim p_0, \quad \tilde{p}_0 = p_0 \\ &amp;u_k = \pi(\tilde{p}_k) \\ &amp;y_k \sim p(y_k \mid x_k, u_k) \\ &amp;\hat{p}_k = \text{FilterUpdate}(y_k, \tilde{p}_k, u_k) \\ &amp;x_{k+1} \sim p(x_{k+1} \mid x_k, u_k) \\ &amp;\tilde{p}_{k+1} = \text{PredictionUpdate}(\hat{p}_k, u_k) \end{aligned} $$

In this case, the number of observations matches the number of states.

Under previous_transition convention

This is the closest to what we decided originally, but doesn't align with Dynestyx default for offline control.

  • $\tilde{p}_k \approx p(x_k \mid y_1, \dots, y_{k-1},\ u_0, \dots, u_{k-1})$ is the predicted distribution.
  • $\hat{p}_k \approx p(x_k \mid y_1, \dots, y_k,\ u_0, \dots, u_{k-1})$ is the filtered distribution.

In this convention, the control $u_k$ drives the transition into the next state $x_{k+1}$ and generates the observation $y_{k+1}$. Hence, $y_0$ never exists.

$$ \begin{aligned} &amp;x_0 \sim p_0, \quad \tilde{p}_0 = \hat{p}_0 = p_0 \\ &amp;u_k = \pi(\hat{p}_k) \\ &amp;x_{k+1} \sim p(x_{k+1} \mid x_k, u_k) \\ &amp;\tilde{p}_{k+1} = \text{PredictionUpdate}(\hat{p}_k, u_k) \\ &amp;y_{k+1} \sim p(y_{k+1} \mid x_{k+1}, u_k) \\ &amp;\hat{p}_{k+1} = \text{FilterUpdate}(y_{k+1}, \tilde{p}_{k+1}, u_k) \end{aligned} $$

Here, the number of observations does not match the number of states, but it does match the number of controls. The number of controls is exactly the number of transitions (i.e., one less than the number of states).

I made a drawing because this stuff can be confusing.

Online control convention excalidraw

Thoughts?

@mattlevine22

Copy link
Copy Markdown
Collaborator

What you wrote above looks right to me and sounds consistent with this PR.

Sounds like the only update is that you are proposing to be more accommodating to time-alignment in the closed loop controller?

Seems good to me.

Comment thread docs/api_reference/public/control.md Outdated
y_{k+1} \mid x_{k+1},u_k &\sim p(y_{k+1}\mid x_{k+1},u_k,t_{k+1}), \\
\hat{x}_{k+1\mid k+1} &= \operatorname{FilterUpdate}
(\hat{x}_{k\mid k},u_k,y_{k+1},t_k,t_{k+1}).
\hat p_{k+1} &= \operatorname{FilterUpdate}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RIP now it gets mad: "The following macros are not allowed: operatorname"

MatthieuDarcy and others added 8 commits September 21, 2026 10:26
Add observation_control_alignment for discrete-time Simulator (#312)

Add an explicit observation_control_alignment: Literal["same_time",
"previous_transition"] field to DynamicalModel, defaulting to "same_time"
(today's behavior, unchanged). "previous_transition" pairs y_{k+1} with u_k
(the control that produced x_{k+1}) instead of pairing y_k with u_k, matching
DiscreteControlLoopSimulator's existing closed-loop convention and avoiding
the acausal y_0-depends-on-u_0 coupling.

For "previous_transition", DiscreteTimeSimulator/dsx.simulate never samples
y_0 and excludes x_0/t_0 from the returned SimulatedResult -- states,
observations, times, and the caller's ctrl_values all end up the same length,
with no padding or off-by-one bookkeeping required.

Scope: the plain Simulator/DiscreteTimeSimulator/dsx.simulate generation path
only. mppi.py and discrete_controller_simulators.py are unchanged, deferred
to a follow-up.
Include x_0 in all results; add controls to SimulatedResult

For observation_control_alignment="previous_transition", the result now keeps
x_0 and the full times/states path (length T), matching "same_time". Only
observations stay one shorter (y_1..y_{T-1}, length T-1) since y_0 is never
sampled -- so states[k+1] pairs with observations[k].

Add a controls field to SimulatedResult carrying the aligned ctrl_values used
(length T for same_time, T-1 for previous_transition; None when uncontrolled).

Also drop the bespoke _sample_discrete_observation_path in favor of calling
_emit_observations directly with sliced states/times, and fix
_sample_observation_path to vmap over arrays rather than indexing by a scanned
integer, which crashed on zero-length observation paths.
Simplified docstring
Trim the observation_control_alignment docstrings in DynamicalModel,
_validate_observation_control_alignment, SimulatedResult and
_sample_observation_path, and rewrite the DiscreteTimeSimulator docstring
with explicit transition and per-convention observation equations.

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

MatthieuDarcy commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor Author

Summary of changes

Open-loop control now supports both the same_time and previous_transition conventions (see below for a definition of both). DiscreteControlLoopSimulator (closed-loop control) now supports the previous_transition convention and uses it by default, warning the user if they do not specify the model's observation_control_alignment and raising an error if they specify same_time. There are some difficulties in supporting same_time.

SimulatedResult now has obs_times and ctrl_times fields, which are populated when relevant (at the suggestion of @mattlevine22). This has a small cost (we have to store these) but makes the results more useful and eases some of the technicalities (see below).

A unified principle is that states and times should align: no giving $N$ times and getting $N-1$ states (but overvations/controls might differ).

I have simplified and reworked some of the tests to properly verify the correctness of the different conventions.

In open-loop control

same_time

$$ \begin{aligned} & \text{Times} = [t_0, \dots, t_{N}]\\ &\text{Controls} = [u_0, \dots, u_N]\\ &x_0 \sim p_0\\ &\text{for $k = 0,\dots N-1$}:\\ &y_{k} \sim p(y_k \mid x_k, u_k, t_k) \quad \text{emit observation}\\ &x_{k+1} \sim p(x_{k+1} \mid x_k, u_k, t_k, t_{k+1}) \quad \text{State transition}\\ &y_{N} \sim p(y_N \mid x_{N}, u_{N}, t_N) \quad \text{Final observation} \end{aligned} $$

Here there are $N+1$ states, observations and controls, all living on the grid $[t_0, \dots, t_N]$. The final control $u_N$ is only ever used for the observation.

previous_transition

$$ \begin{aligned} & \text{Times} = [t_0, \dots, t_{N}]\\ &\text{Controls} = [u_0, \dots, u_{N-1}]\\ &x_0 \sim p_0\\ &\text{for $k = 0,\dots N-1$}:\\ &x_{k+1} \sim p(x_{k+1} \mid x_k, u_k, t_k, t_{k+1}) \quad \text{State transition}\\ &y_{k+1} \sim p(y_{k+1} \mid x_{k+1}, u_k, t_{k+1}) \quad \text{emit observation} \end{aligned} $$

We have $N+1$ states (on the time grid $[t_0, \dots, t_N]$).
We have $N$ controls (on the time grid $[t_0, \dots, t_{N-1}]$).
We have $N$ observations (on the time grid $[t_1, \dots, t_N]$).

In closed-loop control

same_time convention

  • $\tilde{p}_k \approx p(x_k \mid y_0, \dots, y_{k-1},\ u_0, \dots, u_{k-1})$ is the predicted distribution.
  • $\hat{p}_k \approx p(x_k \mid y_0, \dots, y_k,\ u_0, \dots, u_k)$ is the filtered distribution.

The control $u_k$ drives the transition into the next state $x_{k+1}$ and generates the observation $y_k$ (they are aligned).

$$ \begin{aligned} &amp; \text{Times} = [t_0, \dots, t_{N}]\\ &amp;x_0 \sim p_0, \quad \tilde{p}_0 = p_0 \quad \text{Initialization step} \\ &amp;\text{for $k = 0,\dots N-1$}:\\ &amp;u_{k}, s_{k+1} =\pi(\tilde{p}_{k}, t_{k}, t_{k+1}, s_k) \quad \text{Select the control}\\ &amp;y_{k} \sim p(y_k \mid x_k, u_k, t_k) \quad \text{emit observation}\\ &amp;\hat{p}_k = \text{FilterAnalysis}(y_k, \tilde{p}_k, u_k) \quad \text{Update the filtering distribution using the observation} \\ &amp;x_{k+1} \sim p(x_{k+1} \mid x_k, u_k, t_k, t_{k+1}) \quad \text{State transition} \\ &amp;\tilde{p}_{k+1} = \text{PredictionUpdate}(\hat{p}_k, u_k) \quad \text{Predict the filtering distribution} \end{aligned} $$

Here the policy uses the predicted distribution.
We have $N+1$ states (on the time grid $[t_0, \dots, t_N]$).
We have $N$ controls (on the time grid $[t_0, \dots, t_{N-1}]$); we can never produce a control at time $t_N$.
We have $N$ observations (on the time grid $[t_0, \dots, t_{N-1}]$); we can never produce an observation at time $t_N$, because $u_N$ would need $t_{N+1}$.

This is not implemented because Cuthbert's Filter does not expose separate predict and filter_update steps. This seems possible, but I did not want to overload this PR. I added dummy functions that show how this could be done, but this will raise an error if a user tries to do this.

previous_transition (the default, with a warning)

  • $\hat{p}_k \approx p(x_k \mid y_1, \dots, y_k,\ u_0, \dots, u_{k-1})$ is the filtered distribution.

$$ \begin{aligned} &amp; \text{Times} = [t_0, \dots, t_{N}]\\ &amp;x_0 \sim p_0, \quad \hat{p}_0 = p_0 \quad \text{Initialization step} \\ &amp;\text{for $k = 0,\dots N-1$}:\\ &amp;u_{k}, s_{k+1} =\pi(\hat{p}_{k}, t_{k}, t_{k+1}, s_k) \quad \text{Select the control}\\ &amp;x_{k+1} \sim p(x_{k+1} \mid x_k, u_k, t_k, t_{k+1}) \quad \text{State transition} \\ &amp;y_{k+1} \sim p(y_{k+1} \mid x_{k+1}, u_k, t_{k+1}) \quad \text{emit observation}\\ &amp;\hat{p}_{k+1} = \text{FilterUpdate}(y_{k+1}, \hat{p}_k, u_k) \quad \text{Update the filtering distribution using the observation} \end{aligned} $$

Here the policy uses the filtering distribution. In this convention, $y_0$ never exists.
We have $N+1$ states (on the time grid $[t_0, \dots, t_N]$).
We have $N$ controls (on the time grid $[t_0, \dots, t_{N-1}]$); we can never produce a control at time $t_N$.
We have $N$ observations (on the time grid $[t_1, \dots, t_N]$). We do not produce an observation at time $t_0$, because $u_0$ is used for $x_1$ and $y_1$.

image

Sharp edges

The new previous_transition convention removes the artificial first observation $y_0$ (which used no control). However as a result, the following simple model becomes somewhat tricky to implement

image

I think one way of doing this could be to add an explicit keyword observe_y0 and then implement the following loop:

image

@mattlevine22 can you let me know if you think this makes sense? (also I tried fixing all the rendering issues).

@mattlevine22

Copy link
Copy Markdown
Collaborator

Is same_time the default for open-loop?

@MatthieuDarcy

Copy link
Copy Markdown
Contributor Author

Yes, to preserve the old behavior.

@mattlevine22 mattlevine22 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, my recommendation is to accept this PR as is and move on.

  • I don't think it breaks any existing stuff...and if we discover that it does, we will just fix it.
  • observe_y0 boolean property of DynamicalModel is a good idea, but better as a new PR. I think this PR is about as big as I can generally handle reviewing haha.

Notes:

  • I suppose that IF you set observe_y0=True, then there should be a warning that says: "y0 will be generated with a 0 control value (ignore this warning if your observation model does not depend on control inputs".
  • Doing observe_y0 will also require dealing with more branches of the code (e.g., asserting it is True in all the Filters/Smoothers/OpenLoopSimulators unless you want to start supporting it True...which is even more code).
  • DynamicalModel has a t0 property...is this being considered at all? It is a bit dicey, and needs to become a more powerful grounding property soon (perhaps not in this PR other than maintaining current internal consistency).

@DanWaxman could you please look at this briefly and decide whether you'd like us to (a) do some more careful reviewing or (b) go for it?

I read the code in detail in my last iteration, but now it is a bit tough for me to follow what has changed (@MatthieuDarcy this is an example where fewer targeted commits helps a lot in the review process)

@MatthieuDarcy

MatthieuDarcy commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Yes my apologies for this, I know this is too much but it turned out to be a bit of a rabbit hole.

@mattlevine22 @DanWaxman IF you want a more careful review, I can break it up into smaller PRs, doing one for open loop and one for closed loop (and removing some of the superfluous elements), but this will take me a bit of time.

@mattlevine22

Copy link
Copy Markdown
Collaborator

My gut is that this is good enough but want to get Dan's take

@DanWaxman DanWaxman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems reasonable to me overall. I left comments, mostly about docs. But also:

  • does it make sense to use an enum instead of a string?
  • I think the belief stuff can be implemented for the EnKF already, it gives its predicted states (as it is necessary for the EnRTS algorithm)

Comment thread docs/tutorials/control/observation_control_alignment.ipynb Outdated
Comment on lines +97 to +112
def _validate_policy_control(u: Any, control_dim: int) -> Real[Array, " control_dim"]:
"""Normalize one control returned by a policy, rejecting bad shapes."""
if isinstance(u, Distribution):
raise ValueError(
"Returning a distribution is not yet supported, instead "
"sample from this distribution inside your policy."
)
u = jnp.asarray(u)
expected_control_shape = (control_dim,)
if u.shape != expected_control_shape:
raise ValueError(
"control_policy must return one control vector with shape "
f"{expected_control_shape}; got {u.shape}."
)
return u

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel okay with just having this still be in type checking instead of a check

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I like it because it gives an explicit error message for when a user gives a bad policy

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I don't feel super strongly about it (but would remove the jnp.asarray call, that should happen way earlier... also lets u be typed better in this function)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes that part I completely agree with and is removed

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you put in an array-like type for u then?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I had to add Distribution to allow for the Distribution check

Comment thread dynestyx/control/discrete_controller_simulators.py Outdated
Comment on lines +123 to +124
- under `same_time`, $\tilde x_k$ is the predicted state
- under `previous_transition`, $\tilde x_k$ is the filtered state

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you write what the "predicted state" and "filtered state" mean here? Like $x_{k|k}$ etc.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me know if you find this sufficiently clear. I'm not as familiar as you are regarding the accepted terminology/notation

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup looks good!

Comment thread dynestyx/control/discrete_controller_simulators.py Outdated
Comment thread dynestyx/control/discrete_controller_simulators.py Outdated
Comment thread dynestyx/control/discrete_controller_simulators.py Outdated
Comment thread dynestyx/control/discrete_controller_simulators.py
Comment thread dynestyx/inference/integrations/cuthbert/discrete_filter.py
Comment thread dynestyx/models/checkers.py Outdated

@DanWaxman DanWaxman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made one more reply, commenting about a missing type for control in the validation function. Otherwise, this looks good to me. Thank you Matthieu!!

@mattlevine22 mattlevine22 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great, thank you for the hard work on this one!

@mattlevine22
mattlevine22 merged commit e6dbc85 into main Sep 24, 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.

3 participants