Skip to content

ExaModelsC: emit several models into one library - #308

Merged
sshin23 merged 16 commits into
mainfrom
abi/multi-model-producer
Aug 13, 2026
Merged

ExaModelsC: emit several models into one library#308
sshin23 merged 16 commits into
mainfrom
abi/multi-model-producer

Conversation

@sshin23

@sshin23 sshin23 commented Aug 13, 2026

Copy link
Copy Markdown
Member

compile_library now takes any number of :name => core / :name => (core, args...) pairs and compiles them into one shared library, each model under its own symbol prefix — and, per model, compiles any argument shape ExaModel takes, emitting the matching instantiation surface:

compile_library("@grid",
    :acopf  => (ac_core, 100),                 # one integer → acopf_new(n)
    :struct => (s_core, 4,                     # several values → the builder ABI
                (v0 = fill(0.5, 4), lo = fill(-10.0, 4)),
                [(i = 1, w = 2.0, s = 1.0)]),
    :opf    => (opf_core, ac_opf_args, "case14.m"),  # argument function → opf_new_str
    :fixed  => small_core)                     # no instantiation data

Multi-model: the generated module suffixes all per-model state (CORE_$p, MODELS_$p, ...), so models keep separate cores, instance tables, and ids; the library FILE is named after out, the model names supply the prefixes.

Structured instantiation: recipes whose examples are not a single integer emit the ABI v2 schema + builder surface both consumers already implement — P_schema publishes the flattened field list, typed setters take values by name, P_new_from_data reassembles the ExaModel arguments. Builder storage is generated concretely from the example types (Int64/Float64 exactly; anything looser is refused with the reason).

Argument functions: argfun = f (or a Function in the pair's second position — nothing callable can be a model argument, so the spelling is unambiguous) carries f into the library and calls it at run time: only f's own argument crosses the boundary — one string (P_new_str) or one integer (P_new) — and the work that turns it into instantiation data happens inside. f must be a named function owned by a package proper: functions from scripts/REPL (unreachable from the generated app) and from package extensions (their module is not a name the app imports) are refused in the first second with the constraint named, rather than dying minutes into juliac.

Every model exports P_argkind() -> 0|1|2|3 (fixed, P_new(n), P_new_str, builder), so a consumer routes on declared shape instead of probing symbols; kind 1 covers n-is-the-size and n-goes-to-the-function indistinguishably by design. P_nargs (how many) and P_argkind (what shape) are the two halves a consumer handed only a library path needs.

Subexpressions work in recipes: @add_expr now stores its body as a node (substituted via a static _reindex walk) rather than a closure — a closure captures the Variables it mentions, putting a placeholder's ArgSource into the closure's type, where instantiate cannot reach it. Verified against a measured main baseline (1617/0/2, identical), plus empirical probes of sum(...) inside bodies, including index-dependent summands (exact).

Developed packages are pinned: every package the caller tracks by path becomes a [deps] + [sources] entry of the generated app (not an import — resolution needs the pin; only the cores' own types need imports), so the app compiles the code the caller runs rather than the registry copy. Also in ExaModels proper: instantiate's varargs are specialization-forced (Vararg{Any,N}) and the container walk is unrolled — both are needed; large cores (AC OPF scale) leave map closures unresolved under --trim=safe with the annotation alone.

Suite: 210/210 on Linux against current consumer masters — a three-model round-trip, the four-surface library (builder + one-knob + argfun:int + argfun:str in one file), Ipopt solves through both surfaces, refusal tests (strings, loose types, colliding names, script-, Main-, and extension-owned functions). Scale datum from independent review: an 18-model library assembles in 160 s with 0 verifier errors, exact readback at an unseen size. GPU datum from downstream validation: a real modelling library's CUDA test slice (ExaModelsPower#57, 322 tests) runs green against this branch's instantiation path — the leg that was dead before the collect fix.

Note for path-dev consumers: this adds Pkg to ExaModelsC's dependencies — a stale manifest that path-devs ExaModelsC reports Package ExaModelsC does not have Pkg in its dependencies until Pkg.resolve(); that is the manifest, not the branch.

Consumer-side name selection (CNLPModel(lib, :acopf, ...)) is already on CNLPModels master; the _new_str/_argkind routing follows as separate consumer work.

🤖 Generated with Claude Code

A shared library may carry any number of models, one symbol prefix each, and
the consumers already select among them. The producer emitted exactly one.

    compile_library("@grid",
        :acopf => (ac_core, 100),   # acopf_* inside libgrid.so
        :dcopf => (dc_core, 100),   # dcopf_* in the same library
        :fixed => small_core,       # no instantiation data
    )

Each model is a :name => core pair, or :name => (core, args...) to give the
example values ExaModel takes. The names become the prefixes, so prefix= has
no meaning in this form and is not accepted.

Internally the two entry points now share ModelSpec: _model_spec does the
per-model validation both need, and _compile the back half. Every piece of
per-model state in the generated module carries its prefix (CORE_p, ARG0_p,
MODELS_p, _valid_p), which is what lets the models coexist — separate cores,
separate instance tables, so each model's first instance is id 1 rather than
their sharing a counter.

The library FILE is named from out rather than from a prefix, since with
several models no one prefix can name it; that is also what a consumer
resolves @grid against. The generated MODULE name is sanitized instead of the
file name being restricted, so a dashed out still works here.

Models share the library's one privatized ~80 MB runtime, which is the reason
to co-package a family rather than emit a library each.

Also drops a false claim from the docstring: a NamedTuple holding one integer
field was documented as an accepted example value, and _field rejects it
outright. (_arg_expr(::Symbol) is dead code as a result; left alone.)

Per-model instantiation is unchanged — one integer, or none for a fixed
model. The schema + builder surface is still not emitted, for any model.
@github-actions

Copy link
Copy Markdown
Contributor

Your PR requires formatting changes to meet the project's style guidelines.

Please run:

julia --project=@runic -e 'using Pkg; Pkg.add("Runic")'
julia --project=@runic -e "using Runic; exit(Runic.main(ARGS))" -- --fix <files>

(or git runic main if you have the git wrapper installed)

Note: the full diff is omitted because it can exceed GitHub Actions input limits.

sshin23 and others added 2 commits August 12, 2026 23:15
Every instantiate method takes its arguments as a bare vararg that is
only splatted through to child calls. Julia's passthrough heuristic
leaves such varargs unspecialized — harmless under the JIT, but the
map over a core's block tuples then carries a dynamic call that
juliac --trim=safe reports as unresolved (57 verifier errors on the
first multi-argument recipe ever compiled). Annotating every signature
with Vararg{Any,N} forces specialization on the concrete argument
types; behavior is unchanged. ArgumentTest: 158/158.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Recipes whose examples are not a single integer — several values,
floats, arrays, tables, or NamedTuples of these, exactly as ExaModel
takes them — now compile to the ABI v2 builder surface both consumers
already implement: P_schema publishes the flattened field list,
P_data_begin / P_set_* / P_data_ready take the values by name, and
P_new_from_data reassembles the ExaModel arguments. A NamedTuple
example flattens into one schema field per key; bare values are named
arg1, arg2, ... by position. Builder models export no P_new — the
consumers rely on that disjointness to route a lone integer — and a
one-key integer NamedTuple keeps the P_new fast path.

Builder storage is generated concretely from the example types (one
slot per scalar/array field and per table column), so --trim=safe sees
no dynamic containers; examples must be Int64/Float64 exactly, refused
otherwise with the reason. Probe failures now surface as ArgumentError
naming the model instead of a raw MethodError.

Suite: 192/192, including a compiled two-surface library (builder +
one-knob in one file), consumption from CNLPModels.jl and cnlpmodels
(Python driving the builder with a columnar table), and an Ipopt solve
through a builder-instantiated model.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Benchmark Results


Relative timing: current / main  (values < 1.0 are improvements)

================================================================================
  backend-instance-param          |      obj     cons     grad      jac     hess
================================================================================
  CUDA-OPF-case1354               |    0.882    0.931    0.881    0.916    0.913
  CUDA-OPF-case14                 |    0.996    1.024    1.006    0.909    0.921
  CUDA-OPF-case30000              |    0.922    0.940    0.880    0.919    0.925
  CUDA-chain-10                   |    0.891    0.882    0.883    0.893    0.899
  CUDA-chain-100                  |    0.882    0.881    0.885    0.890    0.901
  CUDA-chain-1000                 |    0.912    0.882    0.889    0.889    0.900
  CUDA-elec-10                    |    0.889    0.893    0.874    0.872    1.080
  CUDA-elec-100                   |    1.008    1.186    1.065    1.000    1.007
  CUDA-elec-1000                  |    0.979    1.010   29.159    1.000   25.351
  CUDA-rosenrock-1000             |    0.995    0.927    1.017    0.989    0.998
  CUDA-rosenrock-10000            |    0.991    0.993    1.013    0.991    1.004
  CUDA-rosenrock-100000           |    0.990    0.996    1.010    0.991    0.985
--------------------------------------------------------------------------------
  AMDGPU-OPF-case1354             |    0.995    0.940    0.926    0.937    0.944
  AMDGPU-OPF-case14               |    1.002    0.941    0.929    0.937    0.938
  AMDGPU-OPF-case30000            |    1.001    0.935    0.936    0.959    0.968
  AMDGPU-chain-10                 |    0.987    0.953    0.919    0.949    0.995
  AMDGPU-chain-100                |    1.016    0.974    0.979    0.992    0.992
  AMDGPU-chain-1000               |    0.999    0.980    0.972    0.983    0.998
  AMDGPU-elec-10                  |    1.019    1.006    0.982    0.987    1.024
  AMDGPU-elec-100                 |    1.022    0.995    0.967    0.978    1.008
  AMDGPU-elec-1000                |    1.003    1.009    0.930    0.982    1.125
  AMDGPU-rosenrock-1000           |    0.946    0.947    0.921    0.933    0.928
  AMDGPU-rosenrock-10000          |    1.006    0.930    0.913    0.919    0.930
  AMDGPU-rosenrock-100000         |    0.990    0.945    0.926    0.988    0.947
--------------------------------------------------------------------------------
  oneAPI-OPF-case1354             |    1.345    0.143    0.599    1.203    0.846
  oneAPI-OPF-case14               |    1.017    1.027    0.649    0.666    0.740
  oneAPI-OPF-case30000            |    1.046    1.169    0.994    1.657    1.023
  oneAPI-chain-10                 |    1.020    0.859    0.913    0.968    0.988
  oneAPI-chain-100                |    1.035    1.049    0.882    1.176    0.882
  oneAPI-chain-1000               |    1.176    0.763    1.063    0.891    1.238
  oneAPI-elec-10                  |    2.256    0.877    0.847    1.235    0.738
  oneAPI-elec-100                 |    1.081    1.140    1.018    1.101    1.223
  oneAPI-elec-1000                |    1.041    0.967    1.204    1.279    1.292
  oneAPI-rosenrock-1000           |    0.344    3.307    0.057    0.208    0.761
  oneAPI-rosenrock-10000          |    3.852    0.964    0.816    0.558    1.695
  oneAPI-rosenrock-100000         |    1.120    0.488    0.663    0.914    1.112
--------------------------------------------------------------------------------
  nothing-OPF-case1354            |    0.990    0.998    0.852    1.236    1.023
  nothing-OPF-case14              |    1.071    0.996    0.989    0.997    1.051
  nothing-OPF-case30000           |    1.002    0.982    0.950    1.002    0.731
  nothing-chain-10                |    0.500    0.999    0.743    1.016    0.720
  nothing-chain-100               |    0.930    1.002    1.243    1.003    1.462
  nothing-chain-1000              |    1.802    1.002    1.123    0.999    1.134
  nothing-elec-10                 |    1.009    1.121    0.986    1.302    0.963
  nothing-elec-100                |    1.001    1.010    1.446    1.119    0.999
  nothing-elec-1000               |    0.992    0.858    1.003    1.001    0.988
  nothing-rosenrock-1000          |    0.999    1.000    0.966    0.929    0.906
  nothing-rosenrock-10000         |    0.998    1.016    0.993    0.994    1.107
  nothing-rosenrock-100000        |    1.010    1.000    1.039    0.988    3.682
================================================================================

sshin23 and others added 5 commits August 12, 2026 23:53
The generated app declared path sources only for the packages that own
a type in a core. Every other dependency — including transitive ones —
resolved from the registry, so the app could compile different code
than the process that produced the core, and silently: the build
succeeds, and the only trace is a path inside a stack frame. Measured:
a one-line fix to a Pkg.develop-ed ExaPowerIO changed nothing across
two builds because the app kept compiling the registry copy; with the
path carried through, the same fix took the compile from four
unresolved calls to none.

Every package tracked by path in the caller's environment now becomes
a [deps] + [sources] entry of the generated project — but not an
import: a deps+sources entry pins the path on its own (verified),
imports are only needed for the packages whose types the serialized
cores carry, and importing everything a caller happens to be
developing would drag unrelated packages into every app's compile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rarg

#308's Vararg{Any,N} is necessary and I had missed it entirely — a vararg
only splatted through is left unspecialized, so the map closure carries a
dynamic call. It is not sufficient on a large core.

Measured on an AC OPF recipe (6 variable blocks, 10 constraint blocks)
compiled through ExaModelsC with an argument function, same library, same
everything else:

  Vararg{Any,N} alone          10 verifier errors, map(f::Function, ...)
  Vararg{Any,N} + unrolling     0 errors, compiles in 110.1s

So the two are complementary rather than alternatives. The recursion
unrolls for a concrete tuple, giving each element a direct instantiate
call instead of one through a closure. Semantics are unchanged, including
that an element with no argument dependency is passed through untouched.

#308's test models are small enough that its suite does not reach this;
any recipe of OPF size instantiated at run time does.
compile_library(out, core, example...; argfun = f), or
:name => (core, f, example) in the multi-model form — a function can never
be a model argument, so second position is unambiguous. The library
carries f and calls it at run time, so only f's own argument crosses the
boundary: one string or one integer, with the work that turns it into
instantiation data happening inside. It composes with the builder rather
than competing — the builder passes structured data across, this passes a
path and keeps the data on the far side.

UserArgs is a ModelSpec.field variant emitted through
_instantiation_source like the others, so surfaces coexist per model in
one library. _argkind is emitted for EVERY model with the builder in the
enum: 0 fixed, 1 _new(n), 2 _new_str, 3 builder.

f is emitted BY NAME (Pkg.fun) — what juliac resolves statically — and its
package must therefore be IMPORTED, not merely pinned by
_developed_packages: pinning leaves ExaModelsPower.opf_args a global of
unknown type and trimming refuses the call (measured: 18 verifier errors,
all that one statement). The example is f's RETURN tuple and so splats
like a builder's.

Verified on this tip: one library, four models — AC polar, AC rect and DC
each with an argument function, plus one fixed core — compiles in 110.6s
with 0 verifier errors, and every model matches its in-Julia counterpart
on dims, obj, grad, cons, jac and hess at case14 and at case118, which
none was compiled against. The argument-function and fixed surfaces give
bit-identical answers for the same model.
…urface

The docstring documents the third surface and the P_argkind enum; a
three-argument _model_spec keeps the no-argfun spelling every existing
caller uses. The fixture package gains one argument function of each
kind, and the four-surface library — builder, one-knob, argfun:int,
argfun:str, one file — is compiled and exercised in the suite: argkind
asserted for every model, the integer kind driven through P_new via the
consumer, the string kind through P_new_str with P_new refusing.

Two prior assertions matched runtime names as strings and broke on
_argkind's comment merely NAMING them; they now match definitions
("function bs_new("), not mentions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_argfun_call accepted any function whose module is its own moduleroot —
and Main satisfies that, so a closure or function defined in a script
passed the guard and failed as unresolved getglobal(Main, ...) calls after
minutes of juliac. Requiring the module to be package-owned turns that
into a first-second ArgumentError naming the actual constraint: the
generated app calls the function by name from another process, so only a
package-owned function can be reached.
Named at Main's top level, such a function is its own moduleroot and
used to pass the guard, dying minutes later inside juliac; only the
package-ownership check refuses it. Defined at the test file's top
level — the world must have advanced before the probe call that
precedes the guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sshin23

sshin23 commented Aug 13, 2026

Copy link
Copy Markdown
Member Author

Pushed one commit: @add_expr now works inside a recipe.

The problem. @add_expr stored its body as the generator's closure and
re-invoked it at each point of use. Inside a model-building function that
closure captures the Variables it mentions, so when a size is a placeholder
the ArgSource lands in the closure's type — where instantiate cannot reach
it, and where it cannot be rebuilt field-wise either, since Julia emits no
constructor for a closure type. ExaModels then refuses the core with
"instantiating this core left argument placeholders in it". (At top level the
same model appears to work — a global is captured by reference — which makes
the failure look intermittent when it is really about scope.)

The change. The body is stored as a node, built once against a
DataSource, and indexing an Expression substitutes into it (_reindex).
Nodes have constructors and dispatch, the operation lives in the type
parameter, so the walk is static: no reflection, no @generated. Once the
source is replaced by a concrete element — an integer, or the tuple a
multi-dimensional generator destructures — the lookups above it are performed
rather than rebuilt; that last point is what the multi-dim/product-iterator
subexpression tests exercise.

Evidence. Full suite on this branch: matches the pre-change baseline
exactly. Downstream, COPSBenchmark's robot and glider (eight chained
subexpressions) build as recipes with their original @add_expr formulations
and remain bit-identical to main's constructors, including COO order; both
compile through compile_library and match their in-Julia models at sizes the
library was never compiled at.

Corroboration for 97fc878 from a second package: on COPSBenchmark the
map-with-closure path failed for every model — its cores carry named refs, a
heterogeneous NamedTuple, so the closure cannot specialize regardless of core
size. With the unroll, all seventeen models compile into one shared library
(16.2 MB, 0 verifier errors) and read back exact. Same fix, independent
trigger: heterogeneity, not scale.

🤖 Generated with Claude Code


The second commit is a small simplification to _owning_package: an
extension's pkgdir is already its parent package's directory, so reading
the name and uuid from the project file there covers the package and the
extension in one path, and drops the locate_package probe (a Base internal)
that existed only to tell the two apart. The extension case stays covered by
the fixture's RecipeKernelsExaModels leg.

sshin23 and others added 5 commits August 13, 2026 07:45
`@add_expr` stored its body as the generator's closure and re-invoked it at
each point of use.  Inside a model-building function that closure captures the
`Variable`s the expression mentions, so when a size is a placeholder the
`ArgSource` ends up in the closure's *type* — where `instantiate` cannot reach
it, and where it cannot be rebuilt field-wise either, since Julia emits no
constructor for a closure type.  ExaModels then refused the core.

(At top level the same model appears to work: a global is captured by
reference and nothing lands in the closure's type.  Every real builder is a
function, so that difference is a trap rather than a reprieve.)

The body is now a node, built once against a `DataSource`, and indexing an
`Expression` substitutes into it.  Nodes have constructors and dispatch, and
the operation lives in the type parameter, so `_reindex` is static: no
reflection, no `@generated`.  Once the source is replaced by a concrete element
— an integer, or the tuple a multi-dimensional generator destructures — the
lookups above it are performed rather than rebuilt.

Suite matches main exactly: 1617 passed, 0 errored, 2 broken, 1619 total,
against a measured main baseline of the same.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_owning_package` distinguished a package from an extension so it could take
the name and uuid from the module's own `PkgId` in one case and read the
`Project.toml` in the other.  The distinction earns nothing: an extension's
`pkgdir` is already its parent package's directory, so reading the project
there gives the right name and uuid either way.  One code path, and one fewer
Base internal (`locate_package`).

The extension case is still covered by the fixture's `RecipeKernelsExaModels`
leg; the testset is unchanged at 12 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review notes from the consumer seat: kind 1 covers both n-is-the-size
and n-goes-to-an-argument-function, identical call shape on purpose —
say so, so no consumer goes probing for a difference. And _nargs (how
many) with _argkind (what shape) are the two halves a consumer handed
only a library path needs; tie them together where they are documented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An extension passes every predicate the guard had — an extension is its own
moduleroot, and `_owning_package` deliberately maps it to its parent — but
the generated app imports only packages, so a call spelled `SomePkgExt.f`
names a module the app never has, and the failure surfaced as an
UndefVarError inside generated code, minutes into juliac.  The owning
package's name matching the module's own is what separates "defined by a
package" from "defined by its extension"; the error now says to move the
function to the parent package, which is the same relocation the start
generators needed for serialization.

The fixture's extension gains a tuple-returning function so the test reaches
the guard rather than the tuple-return check ahead of it; the Main-guard
test's expected substring tightens to the part of the message both cases
share.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_adapt_gen's fallback wrapped a placeholder iterable in a deferred
collect. At instantiation that runs collect on whatever the argument
resolved to — and collect of a device array is a host Vector, so a
converted argument was silently pulled back to the CPU and the kernel met
a non-bitstype:

  Argument 5 ... Vector{ExaPowerIO.GenData{Float32}}, which is not a bitstype

Invisible on the CPU, where collect of a Vector is just a copy — which is
why every CPU gate stayed green while the CUDA leg failed at the first
recipe-built model.

The deferred collect now goes through _maybe_collect, the identity on
AbstractArray and AbstractRange and collect otherwise, so an exotic
iterable (a dict, a generator) still materializes while an array of either
residence passes through as itself.

Measured: an AC OPF recipe instantiated with CUDA-converted arguments
built a model whose objective iterable was a host Vector before, a
CuArray after; rect Float32 and polar Float64 both build and evaluate on
the GPU after the fix.
A standalone scalar term in an @add_expr body (i - 1) plus a CONCRETE
index into the expression hands the stored node two Real children —
and register.jl's one-sided Real evaluation methods tie on that shape,
so the docs' distillation example died ambiguous at the adjoint call.
The closure the node storage replaced computed such terms eagerly;
_reindex now folds them (F.instance exists because ops are named
functions), so a node with only Real children is never constructed.
Regression test at the subexpr suite, green on nothing/CPU/CUDA;
ArgumentTest 160/160 with CUDA; ExaModelsC 210/210.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sshin23 added a commit to madsuite-org/ExaModelsPower.jl that referenced this pull request Aug 13, 2026
…needs

Two faults, both mine and both caught by CI doing its job.

The NLPModels entry in [extras] carried a fabricated UUID — the first
group was right and the tail was written from memory, so every test leg
failed at resolution with 'expected package to be registered'. Corrected
to the registered UUID and verified against the registry.

The registered ExaModels 0.11.2 predates the recipe API: the repo carries
that version number but was last registered before ExaCore(nargs = ...)
existed, so no leg can resolve a usable ExaModels from the registry, and
the CUDA leg additionally needs the deferred-collect residence fix that
is part of madsuite-org/ExaModels.jl#308. Pinned by [sources] to the #308
tip SHA in both the package and docs projects — verified to resolve and
carry the API. The pin is temporary scaffolding: it comes out when
ExaModels next registers, and this package cannot itself be registered
until then, which the PR notes.
…rted

The unroll's two parameters have load-bearing roles, both of them: the
ELEMENTS must stay in a structural Tuple (recursed via first/Base.tail)
and the ARGUMENTS must ride the specialized Vararg{Any,N}. The previous
shape splatted the elements into a bare pass-through vararg with the
arguments as a plain tuple — Julia leaves a pass-through vararg
unspecialized, so heterogeneous element types are lost, and a core
whose objectives project fields out of a deferred data call widens at
the instantiate boundary: 6 verifier errors on real COPS gasoil,
invisible to the ExaModelsC suite (models too small and homogeneous)
and to four increasingly faithful reconstructions of the shape.
Bisected to this hunk alone: 6 -> 0 on the swap with gasoil as the
instrument. The COPS CI pin on this branch is the standing regression
gate for the class; the implementation is the reviewer's, adopted
byte-for-byte from the tree that was green all along.

Gates: real gasoil compiles (0 verifier errors); ExaModelsC 210/210;
ArgumentTest 160/160 with CUDA on hardware.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sshin23
sshin23 merged commit bd8a37a into main Aug 13, 2026
23 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.

1 participant