Skip to content

Support Multi-Output, Multi-Fidelity optimization#705

Merged
jduerholt merged 55 commits into
experimental-design:mainfrom
TobyBoyne:feature/mfkg
May 11, 2026
Merged

Support Multi-Output, Multi-Fidelity optimization#705
jduerholt merged 55 commits into
experimental-design:mainfrom
TobyBoyne:feature/mfkg

Conversation

@TobyBoyne

Copy link
Copy Markdown
Collaborator

Motivation

The current support for multi-fidelity (MF) in BoFire is limited to the MultiFidelityStrategy, which is limited to (1) discrete fidelities, and (2) single output. I would like to be able to optimize in a multi-output MF setting, with a continuous fidelity parameter (eg. "simulation resolution"). This is implemented in botorch with the qMultiFidelityHypervolumeKnowledgeGradient AF.

I had a few questions I wanted to ask before I dived too deep into this:

  1. Should this be a new Strategy? In my opinion, it should work directly with MoboStrategy, but this might require some changes.
  2. Should SingleTaskMultiFidelityGP be a new surrogate, based on this? Or should it just be a SingleTaskGP with the custom kernel?
  3. We currently use botorch.acquisition.get_acquisition_function to build the AF objects. qMFHVKG is not available through that function. What do you think about creating a get_acquisition_function_custom, which we can register with new AFs so that we can be more active in adding AFs to the list?
  4. How do you feel about splitting TaskInput into ContinuousTaskInput and CategoricalTaskInput? Will this break the API for existing users of MF models, and how big of an issue is that?

For (1) and (2) above, I am conscious about polluting BoFire with too many classes. But it might prove difficult to make the existing classes flexible enough.

There is an old PR, #533, which I will close, But maybe there is some good code in there that can be salvaged.

For reference, see https://botorch.org/docs/tutorials/Multi_objective_multi_fidelity_BO/

@jduerholt

Have you read the Contributing Guidelines on pull requests?

Yes

Test Plan

I will write tests to cover all new code, once the approach has been decided.

@TobyBoyne TobyBoyne requested a review from jduerholt January 28, 2026 15:22
@TobyBoyne

Copy link
Copy Markdown
Collaborator Author

One issue with just adding another acqf is that, when building the qMFHVKG acqf you must first optimize the current posterior (eg. in this tutorial). This then means you need to pass in some extra info. I still want to avoid creating many many new classes, but maybe it's required to make a qMultiFidelityHypervolumeKnowledgeGradientStrategy.

@jduerholt

Copy link
Copy Markdown
Contributor

Hi @TobyBoyne,

what is the timeframe from your side for this? Until when do you need answer(s)? I need to first have a look into this as I never did anything with knowledge gradient acqfs. I can try to look into this tmr.

@Jimbo994: would this PR also fix the issue that you mentioned recently?

Best,

Johannes

@TobyBoyne

Copy link
Copy Markdown
Collaborator Author

Regarding a timeframe, there is no rush. I will keep contributing to this PR and I'll likely be using the code for my project, but it doesn't need to land in BoFire particularly soon. But of course any feedback/advice for implementing this nicely would be appreciated :)

I haven't worked with KG much before either, but it seems like the only solution for MOMF problems. There is also the MOMF acqf that is also in botorch, but it seems like that is less mathematically justified, and it queries high fidelities too frequently (which I want to avoid).

@jduerholt

Copy link
Copy Markdown
Contributor

But of course any feedback/advice for implementing this nicely would be appreciated :)

I will try to dive into this a bit over the weekend and try to give useful feedback then ;)

@TobyBoyne

Copy link
Copy Markdown
Collaborator Author

I've run into another problem, this time with the DownsamplingKernel. This kernel is only defined for $x_1, x_2<1$, but when inputs are passed through the Normalize transform, they are sometimes scaled up to be slightly greater than 1, even if the bounds of the feature are (0.0, 1.0).

Is there any way to force the bounds of the input scaler to use the bounds of the feature? I see that this line has been commented out, why? I imagine this might also be an issue for the Spherical kernel, which assumes that inputs lie between min and max, which isn't the case since the value can be scaled outside of that range with Normalize.

I'm happy to open a separate issue to discuss/fix this, @jduerholt. Thanks :)

@Jimbo994

Jimbo994 commented Feb 2, 2026

Copy link
Copy Markdown
Collaborator

Hi guys,

Sorry for my late reply to the recent mention. I was playing around with a dataset for which a MultiTaskSurrogate on correlated outputs performed much better than using SingleTaskGPs. In addition, there was an output which could be modelled by a LinearDeterministicSurrogate. I then tried to set up a MoboStrategy using the MultiTaskSurrogate on one of the tasks and the LinearDeterministicSurrogate, but this resulted in the following ValidationError in version 0.2.2.

https://github.com/experimental-design/bofire/blob/v0.2.2/bofire/data_models/surrogates/botorch_surrogates.py#L144

I actually checked just now in 0.3.2, and here the ValidationError was removed, and the setup seems to work! So it turns out there was no issue @jduerholt.

TLDR;
I was interested in a Multi-output, Multi-objective optimization comprised of a Multi-Task GP acting on one task and a LinearDeterministicSurrogate on another.

I do think Multi-Output, Multi-Fidelity that @TobyBoyne describes is interesting and useful.

@bertiqwerty

Copy link
Copy Markdown
Contributor

cc @R-M-Lee

@jduerholt

jduerholt commented Feb 10, 2026

Copy link
Copy Markdown
Contributor

Hi @TobyBoyne: I overlooked your comment here, until a few weeks ago, we were enforcing that the bounds of the normalize transform were always the one applied to the feature, I released this and relied on the learning of the bounds when I was implementing the engineered features, as I do not know the bounds there (but it would be possible to calculate possible lower and upper bounds also there), back then I thought this is not necessary and this was simplifying the implementation.

It was changed in this PR: #678

So this means that we have to bring this behavior back? Should I file a PR for this? We can also setup a quick call for this.

@TobyBoyne

Copy link
Copy Markdown
Collaborator Author

Hi @jduerholt, I see why you made that change! I'm free to have a quick call now to discuss? Feel free to send me a Teams invite.

@TobyBoyne

Copy link
Copy Markdown
Collaborator Author

With #719, the surrogate now works properly, and the thing runs successfully :)

I've answered some of my own questions in implementing this strategy. My remaining questions for this PR before I take it out of draft are:

  1. Do you agree with splitting TaskInput into ContinuousTaskInput and CategoricalTaskInput? If so, we may also want to think about assigning a cost to each of the fidelities, so that the acqf can weight the improvement of evaluating a fidelity against how expensive it is.
  2. Would you like a quarto tutorial for this? I don't think it will be particularly interesting (it will look identical to most other BO tutorials), but still happy to make one to have a point of reference.

@jduerholt

Copy link
Copy Markdown
Contributor

Hi @TobyBoyne,

I will have a look and answer the questions!

Best,

Johannes

@R-M-Lee

R-M-Lee commented Feb 17, 2026

Copy link
Copy Markdown
Contributor

Associating a cost with each fidelity makes a lot of sense. Assuming that the costs are in [0.0, 1.0], maybe the default could be linspace(0.5, 1.0, N_fidelities). Or maybe logspace depending on what gives more plausible and realistic results (or maybe I didn't understand the question...). Until now we just have an ordering of the fidelities and I think that this is, in general, not enough.

And a notebook-style tutorial is also a very good idea in my opinion, even if you don't think it's very different from some existing tutorials. It can make it a lot easier for people to get started. Great work Toby

@bertiqwerty

Copy link
Copy Markdown
Contributor

Reading Toby's last message, this looks as if it was waiting for review. @jduerholt, since you are entered as reviewer, do you want to review this? Or should I do it?

@jduerholt

Copy link
Copy Markdown
Contributor

Ah, totally overlooked this. Yeah, I will handle it. @TobyBoyne: can you get main merged in to resolve the conflicts? I will then review! Sorry!

@TobyBoyne

Copy link
Copy Markdown
Collaborator Author

Merged @jduerholt :) I will check back here to make sure the tests are passing!

@TobyBoyne

Copy link
Copy Markdown
Collaborator Author

Tests are failing but I think those are related to this (meta-pytorch/botorch#3291) change to how fully bayesian models are exposed by BoTorch. The tests seem to be working on my machine at least!

@jduerholt

Copy link
Copy Markdown
Contributor

Tests are failing but I think those are related to this (meta-pytorch/botorch#3291) change to how fully bayesian models are exposed by BoTorch. The tests seem to be working on my machine at least!

Perfect, I will also ask Max for a new botorch release ;)

@jduerholt jduerholt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hi @TobyBoyne,

thank you very much, this looks really impressive and I think is almost done.

I let a lot of small comments mostly focusing on small stuff. Main things, that I see:

  • Code duplication from generate_surrogate_specs, we should further modularize it to not repeat always the boilerplate code.
  • Code duplication from MOBO, we should either have a mixin for the mobo functionality or solve in a different way, duplicating this can lead to a lot of errors ...

Just go over it, in case that you do not have time etc. for the code duplication stuff, just tell me. Then I try to fix it ;)

Best,

Johannes

return float(base_prices[base] * mmol_base)


class MOMFBraninCurrin(Benchmark):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I recently build a generic interaface for botorch test functions to not have so much code duplication, have a look here:

class SyntheticBoTorch(Benchmark):

I would prefer if we extend it to MOMF optimization, because then we can use all kind of botorch MOMF test functions. Should also not be complicated. At some point we also should tidy up the whole thing.

What do you think?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes I like that, I will have a look into this!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why are we getting the WedgeKernel stuff in here? This is already merged in another PR, or?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ah, we did not had it in the aggregations? Then we maybe need to update the register functions ...

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

It was causing an issue because I was combining the WedgeKernel with the DownsamplingKernel, and getting some issues: as you say, I think it was missing from some of the aggregations. I'm happy to remove it from this PR for now if you want to move the change to another PR?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No, this is fine.

RBFKernel,
SphericalLinearKernel,
)
from bofire.data_models.kernels.fidelity import DownsamplingKernel, FidelityKernel

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

FidelityKernek is imported but not used?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

There used to be something like AbstractKernel here that included the FidelityKernel type. You can also see that AggregationKernel, FeatureSpecificKernel, and MolecularKernel are all imported but not used. Happy to remove any/all of the above?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think we also have to properly register the stuff here, with AnyPrior etc. Maybe we have to build an automatic machine for this. Need some discussion with Claude on this.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Okay - I'll leave this one with you because I'm less familiar with the registration+Pydantic side of things. test_(de)serialization.py is passing so I'm not sure what is currently broken that needs to be fixed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fine for me, before final merge I will make a roundtrip regarding this.

return self

@staticmethod
def _generate_fidelity_cost_model_spec(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We could also simplify it and let the user generate the fidelity cost model and do not provide a default, what do you think?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I like having the default for a few reasons:

  1. makes it clearer to the user what a fidelity cost model needs to look like.
  2. reduces the time to "first-working-prototype" for a user, which they can then refine with their own model
  3. since the default is encapsulated in its own function, it doesn't really increase the complexity of the class in my opinion.

Do those reasons make sense?

return project


def get_acquisition_function_qMFHVKG(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes I think that's a good idea. Should I raise an issue, or just go straight in with a PR?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

As you want, my latest changes to this function were all accepted, so I think the chances are quite high. But we can also keep it here first and make the change to botorch in parallel.


X_train, X_pending = self.get_acqf_input_tensors()

# We must subset the model here, to remove the deterministic cost model from the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ok, now I also see the point of why not to have it in the real surrogates, this would be getting really complicated here at the end, on how botorch is composing the stuff.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Ah yes that comment is outdated and needs to be removed, but it is a perfect example of some of the subtle issues that came up when including it in the real surrgoates!

)
).tolist()

def get_current_value(self, target_fidelities: dict[int, float]):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can you explain to me what this optimization is doing? What are we acutally optimizing?

@TobyBoyne TobyBoyne Apr 28, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

(I will write this in the docstring to better explain this).

The thing we are trying to find in get_current_value is $\psi^{*}$ in eq (5) in https://proceedings.mlr.press/v202/daulton23a/daulton23a.pdf.

My understanding is this:
In standard, single-output Knowledge Gradient (KG), the acquisition function is

$$\alpha_{t+1} (x) = E[ \mu^{*}_{t+1} - \mu^{*}_{t} ]$$

where $\mu^{*}_t$ is the maximum of the posterior mean under the model at time $t$. Note that this isn't the same as the $f^{*}$ that is used in NoisyEI, which is only the maximum posterior mean evaluated at the observed points. Given a model trained on data up to time $t$, one must first compute $\mu^{*}_{t}$ via an optimization of the posterior mean of the model, then optimize $\alpha_{t+1}$.

In the multi-output setting, this is exactly the same, except instead of optimizing the posterior mean under the model, we optimize the hypervolume of the posterior mean.

It's also worth noting that that $\mu^{*}_{t}$ is constant wrt $x$, so it can be omitted entirely.


def _get_domain_with_fixed_task_inputs(
domain: Domain,
target_fidelities: dict[int, float],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why not pass in the target_fidelitis as dict[str, float], would be cleaner or?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

But it is also okay like this.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Let me have a think. I wanted to match the BoTorch convention but actually maybe your suggestion is cleaner - I might make this change

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Decided to keep it as-is.

X_pending_evaluation_mask=None,
current_value=current_value,
cost_aware_utility=cost_aware_utility,
project=get_project(target_fidelities, X_observed.shape[-1]),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why this additonal get_project? we could just hand it in as a callable via functools.partial, or? No need for the wrapper above.

@TobyBoyne

Copy link
Copy Markdown
Collaborator Author

@jduerholt, thanks for the review! I have made the requested changes. All that is left is a change to the inheritance structure such that the new strategy inherits from MOBO - are you still happy to draft that?

I haven't made the change to the benchmark to inherit from SyntheticBoTorch. I think that's best left to a separate PR, since the class doesn't support quite a few features for now.

TobyBoyne and others added 2 commits May 5, 2026 16:17
- Mark `TaskInput` and `FidelityKernel` as abstract via `type: Any`
  with docstrings, so they no longer pretend to be concrete classes
  outside the `AnyFeature`/`AnyKernel` unions.
- Add a valid spec for `ContinuousTaskInput` so the serialization
  roundtrip test actually exercises it.
- Wire `MultiFidelityVarianceBasedStrategy` and
  `MultiFidelityHVKGStrategy` into the `AnyPredictive` union.
- Add a `validate_ref_point` validator to `MultiFidelityHVKGStrategy`
  matching `MoboStrategy`, so `dict[str, float]` and `None` reference
  points are normalized to `ExplicitReferencePoint` before the
  functional class consumes them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jduerholt

jduerholt commented May 6, 2026

Copy link
Copy Markdown
Contributor

Hi @TobyBoyne — I just pushed a small follow-up commit (b23bb5d) to tighten a few registration / roundtrip loose ends I spotted while reviewing:

  • TaskInput and FidelityKernel are now properly abstract (type: Any + docstring). They were carrying a Literal type but weren't in any AnyFeature/AnyKernel union, which made them look instantiable when they aren't.
  • Added a valid ContinuousTaskInput spec so the strict feature-serialization roundtrip actually exercises the new class.
  • Added MultiFidelityVarianceBasedStrategy and MultiFidelityHVKGStrategy to the AnyPredictive union — MoboStrategy was there but the MF strategies weren't.
  • Added a validate_ref_point model validator to MultiFidelityHVKGStrategy mirroring MoboStrategy.validate_ref_point, so dict[str, float] / None ref points are normalized into an ExplicitReferencePoint before the functional class consumes them. The assert not isinstance(data_model.ref_point, dict) in the functional ctor would have tripped without this.

The bigger thing I wanted to discuss is the duplication between MultiFidelityHVKGStrategy and MoboStrategy. The current MFHVKG strategy reimplements _get_objective, get_adjusted_refpoint, the ref-point bookkeeping in __init__, the is_feature_implemented / is_objective_implemented matrices, and (until this commit) the ref-point normalization. A few directions I'd like your take on:

Option A — inherit from MoboStrategy at both layers, override only what differs.

  • Data model: subclass MoboStrategy, narrow acquisition_function to qMFHVKG, add fidelity_cost_model_spec, replace validate_surrogate_specs, add the continuous-task-input domain validator. Inherits validate_ref_point, is_feature_implemented, is_objective_implemented, ref_point field for free.
  • Functional: subclass functional MoboStrategy, override _get_acqfs. Inherits ref-point bookkeeping in __init__, _get_objective, get_adjusted_refpoint, ref_point_mask.
  • Pros: smallest diff, biggest dedup win. Cons: claims an "is-a" relationship between MFHVKG and Mobo, which couples future Mobo changes into MFHVKG.

Option B — extract a _HypervolumeRefPointMixin.

  • Mixin holds ref-point fields/init, _get_objective, get_adjusted_refpoint. Both strategies mix it in.
  • Pros: avoids the LSP-style coupling. Cons: stateful mixins with model_validators can be fiddly with Pydantic MRO.

Option C — composition: pull the ref-point logic into ExplicitReferencePoint itself (or a thin resolver).

  • e.g., ExplicitReferencePoint.normalize(domain, candidate) and to_array(domain). Validators become one-liners and the helper is unit-testable in isolation.
  • Pros: cleanest separation, mirrors how acquisition_optimizer already works as a swappable component. Cons: biggest refactor — touches MoboStrategy too.

My current preference: Option A for the functional class (the duplication there is genuinely line-for-line) combined with Option C-lite for the data model (a free helper _normalize_ref_point(domain, ref_point) reused by both validate_ref_point validators). That avoids forcing MFHVKG to inherit from MoboStrategy at the data-model layer (where the acquisition_function type really does diverge) while still killing the duplicated normalization.

Tangential thought: is_feature_implemented / is_objective_implemented could be class attributes on every strategy plus a single shared classmethod that does the lookup — independent of MFHVKG, and would DRY up several other strategies too. Probably out of scope for this PR.

What's your preference?

This was written by my agent ;)

@TobyBoyne

Copy link
Copy Markdown
Collaborator Author

Thanks for committing those change @jduerholt :)

All the options make sense. I definitely agree with using A for the functional class. I would personally like to use A for the data model as well, if possible. It would be nice if the data model structure reflects the functional model structure as well. For example, isinstance(MFHVKGStrategy(), MoboStrategy) should be True whether those are data model or functional. I've given an example below of how this can be done without violating LSP.

The option you've given for data models does also make sense. If this seems like the best way forward to you, then I can get behind that. Let me know if you would like me to take this on, or if you'd like to do it.

And for your tangential comment, I think that can definitely be refactored in line with what you've suggested. I actually had some more ideas about that - I will speak to you separately. But yes, outside the scope of this PR.


My solution here would be to use Generics (which I think I have said for any problem I have with Python type hinting - I may be over-relying on them...). I've given a toy example below. The key change here is that you can narrow the type of acqf with Generics, since MFHVKGStrategy is now a subclass of MoboStrategy[MFHVKGAcqf], which is already narrowed!

(To be clear, all of the redefinitions of Acqf are just for demonstration below, so that the script is self-contained. The only important change is the AcqfT type variable, and the Generic subclassing.)

from pydantic import BaseModel
from typing import Generic, TypeVar

class Acqf(BaseModel): pass
class LogEIAcqf(Acqf): pass
class UCBAcqf(Acqf): pass
class MFHVKGAcqf(Acqf): pass

AnyAcqf = LogEIAcqf | UCBAcqf | MFHVKGAcqf

AcqfT = TypeVar("AcqfT", bound=AnyAcqf)

class MoboStrategy(BaseModel, Generic[AcqfT]):
    acqf: AcqfT

class MFHVKGStrategy(MoboStrategy[MFHVKGAcqf]):
    pass


strategy1 = MFHVKGStrategy(acqf=MFHVKGAcqf())  # this is fine
strategy2 = MoboStrategy(acqf=MFHVKGAcqf())    # this is also fine
strategy3 = MFHVKGStrategy(acqf=LogEIAcqf())   # this raises an error!

@jduerholt

Copy link
Copy Markdown
Contributor

Oh, this is really nice with the Generics. For me it is fine, if you would go behind it and unify MOBO and MFHVKGStrategy, I would love it. Let us start with the functional model and then do it with the generics maybe on the data model. But I think, we should merge this is in now, and do it in a seperate, one or?

@jduerholt

Copy link
Copy Markdown
Contributor

So, would this be fine for you?

@TobyBoyne

Copy link
Copy Markdown
Collaborator Author

I have changed the inheritance so that we now inherit from Mobo. I ended up not doing any of the Generic things because my type checker is already complaining about "Variable not allowed in type expression" in MoboStrategy.acquisition_function. So the type checker is just inferring all the acquisition functions as unknown, which isn't very helpful anyway.

I have tried to create an instance of the MFHVKG strategy with qLogEI acquisition function, and Pydantic correctly raises an error. So:

  1. Pydantic is fine with the current state, and works correctly
  2. Pyright is already unhappy with the variable annotations introduced with the @register, so the generics won't make it any better.

Maybe we can come back and add the generics at some point, but for now I think this should be good to merge?

@jduerholt

Copy link
Copy Markdown
Contributor

Looks good to me, can you have a look to the failing test, there is something with the make method.

@TobyBoyne

Copy link
Copy Markdown
Collaborator Author

This should fix it. There was a mismatch between dict and typing.Dict.

@jduerholt jduerholt merged commit 53880c9 into experimental-design:main May 11, 2026
12 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.

5 participants