@@ -25,6 +25,7 @@ const MLIR = Reactant.MLIR
2525const Compiler = Reactant. Compiler
2626
2727export IOSpec, write_bundle, export_bundle, collect_provenance
28+ export bundle_entry_arity, bundle_arity_report, assert_bundle_arity
2829
2930# ============================================================================
3031# Provenance
@@ -348,6 +349,186 @@ function write_bundle(
348349 return dir
349350end
350351
352+ # ── The entry-arity gate ─────────────────────────────────────────────────────────────
353+ #
354+ # A bundle is served as `executable(inputs..., weights...)`. BOTH halves of that contract are
355+ # declared in artifacts a human can read: `manifest.yaml` lists the inputs and `weights.safetensors`
356+ # holds the weights. The number the EXECUTABLE actually wants is declared in neither. It lives inside
357+ # `model*.mlir`, which is a serialized vhlo artifact rather than text.
358+ #
359+ # So an export could emit a graph whose arity disagreed with its own bundle and report success. It
360+ # has happened twice, on two unrelated models, and both times the diagnosis started from a serving
361+ # error that names two numbers and no cause:
362+ #
363+ # 2026-08-11 INVALID_ARGUMENT: Execution supplied 216 arguments but compiled program expected 217
364+ # 2026-08-19 INVALID_ARGUMENT: Execution supplied 164 arguments but compiled program expected 167
365+ #
366+ # Both surpluses were values Reactant LIFTED out of the traced closure rather than baked: a
367+ # device-resident RNG seed in layer state, and device-resident configuration fields. Reactant lifts
368+ # every device-resident value reachable from the traced callable into an MLIR argument whether the
369+ # program reads it or not, and the `:lux` frontend below captures `st` and whatever `model` closes
370+ # over. Nothing here was comparing the result against the bundle it was about to write.
371+ #
372+ # The check is cheap and total: `compile_mlir` already returns the traced function's result, whose
373+ # `in_tys` ARE the emitted entry signature's input types (allocated from `linear_args`, which has
374+ # `skipped_args` removed before it is built), so the arity is in hand at no cost. Refusing here means
375+ # an unservable bundle is never written, rather than written, checked, registered, deployed and then
376+ # diagnosed from the far end.
377+
378+ # The compiled entry point's argument count, or `nothing` if this Reactant does not expose it. The
379+ # guard is deliberate: this reads a field of Reactant's internal trace result, and a gate that
380+ # silently stops gating is worse than one that admits it cannot look.
381+ function _entry_arity (fn_res)
382+ for f in (:in_tys , :linear_args )
383+ hasproperty (fn_res, f) && return length (getproperty (fn_res, f))
384+ end
385+ return nothing
386+ end
387+
388+ function _check_entry_arity (fn_res, n_inputs:: Integer , n_weights:: Integer , label:: AbstractString )
389+ got = _entry_arity (fn_res)
390+ got === nothing && return nothing
391+ expected = Int (n_inputs) + Int (n_weights)
392+ got == expected && return nothing
393+ surplus = got - expected
394+ cause = if surplus > 0
395+ " A surplus argument is a value Reactant LIFTED out of the traced closure rather than baking \
396+ into the graph: it lifts every DEVICE-RESIDENT value reachable from the traced callable, \
397+ whether the program reads it or not. The known cases are an RNG in layer state \
398+ (`st.<layer>.rng.seed`, which appears as a leading `tensor<2xui64>`) and device-resident \
399+ configuration captured by the model. Read those back to the host before tracing and they \
400+ bake as constants instead of becoming arguments."
401+ else
402+ " A shortfall means the weights being serialized outnumber the graph's arguments, so the \
403+ parameter tree walked for serialization is not the one the graph was traced against."
404+ end
405+ return error (
406+ """
407+ ReactantServerExport: the compiled program is UNSERVABLE and has not been written ($label ).
408+ compiled entry arguments : $got
409+ declared inputs : $(Int (n_inputs))
410+ weights to serialize : $(Int (n_weights))
411+ expected : $expected ($(Int (n_inputs)) + $(Int (n_weights)) )
412+ $(surplus > 0 ? " surplus" : " shortfall" ) of : $(abs (surplus))
413+
414+ A bundle is served as `executable(inputs..., weights...)`, so every argument must be a
415+ declared input or a serialized weight. $cause
416+
417+ Serving this would fail every inference with `Execution supplied $expected arguments but
418+ compiled program expected $got `, reported by a process that can give those two numbers and
419+ nothing about which value or which model is responsible."""
420+ )
421+ end
422+
423+ # ── Auditing a bundle that has already been written ──────────────────────────────────
424+ #
425+ # The gate above refuses an unservable bundle at the moment of writing, which is the right place for
426+ # every export from now on. It says nothing about the bundles already on disk, and after two
427+ # incidents "which of the existing artifacts are affected" is a question somebody has to be able to
428+ # answer without deploying each one and watching it fail.
429+ #
430+ # This is that answer, and it reads its three numbers from the artifact rather than from the process
431+ # that produced it, so it is also the check a bundle from ANY writer can be held to.
432+
433+ """
434+ bundle_entry_arity(mlir_path) -> Int
435+
436+ The number of arguments the compiled program in `mlir_path` takes, read out of the StableHLO
437+ portable artifact.
438+
439+ `model*.mlir` is a serialized `vhlo` artifact, so `parse(MLIR.IR.Module, ...)` fails with
440+ "dialect 'vhlo' does not implement the bytecode interface": it has to be deserialized against a
441+ Reactant context first. That is the whole reason this is a function rather than a regex at a call
442+ site.
443+ """
444+ function bundle_entry_arity (mlir_path:: AbstractString )
445+ artifact = read (String (mlir_path), String)
446+ ctx = Reactant. ReactantContext ()
447+ mref = Reactant. MLIR. API. stablehloDeserializePortableArtifactNoError (artifact, ctx)
448+ m = Reactant. MLIR. IR. Module (mref)
449+ txt = string (Reactant. MLIR. IR. Operation (m))
450+ i = findfirst (" func.func" , txt)
451+ i === nothing && error (" ReactantServerExport: no `func.func` in the module at `$mlir_path `." )
452+ seg = txt[first (i): min (lastindex (txt), first (i) + 200_000 )]
453+ k = findfirst (" ) -> " , seg)
454+ sig = k === nothing ? seg : seg[1 : first (k)]
455+ return length (collect (eachmatch (r" %arg\d +\s *:" , sig)))
456+ end
457+
458+ """
459+ bundle_arity_report(dir) -> NamedTuple
460+
461+ Read a written bundle's three numbers and say whether they agree: the inputs its `manifest.yaml`
462+ declares, the tensors its `weights.safetensors` holds, and the arity of each compiled module.
463+
464+ Returns `(; name, n_inputs, n_weights, expected, modules, servable)`, where `modules` is one
465+ `(; module_file, entry_args, servable)` per `*.mlir`. Nothing is thrown for a mismatch, so this can
466+ be run across a directory of bundles to triage them; [`assert_bundle_arity`](@ref) is the same check
467+ as a refusal.
468+ """
469+ function bundle_arity_report (dir:: AbstractString )
470+ d = String (dir)
471+ manifest = YAML. load_file (joinpath (d, " manifest.yaml" ))
472+ ni = length (get (manifest, " executable_inputs" , []))
473+ nw = _safetensors_tensor_count (joinpath (d, " weights.safetensors" ))
474+ expected = ni + nw
475+ mlirs = sort (filter (f -> endswith (f, " .mlir" ), readdir (d; join = true )))
476+ isempty (mlirs) && error (" ReactantServerExport: no `*.mlir` under `$d `." )
477+ mods = map (mlirs) do p
478+ got = bundle_entry_arity (p)
479+ return (; module_file = basename (p), entry_args = got, servable = got == expected)
480+ end
481+ return (;
482+ name = get (manifest, " name" , basename (normpath (d))),
483+ n_inputs = ni, n_weights = nw, expected,
484+ modules = mods, servable = all (m -> m. servable, mods),
485+ )
486+ end
487+
488+ """
489+ assert_bundle_arity(dir) -> NamedTuple
490+
491+ [`bundle_arity_report`](@ref) as a refusal: raise unless every compiled module in `dir` takes exactly
492+ `declared inputs + serialized weights` arguments. Returns the report when it passes.
493+ """
494+ function assert_bundle_arity (dir:: AbstractString )
495+ r = bundle_arity_report (dir)
496+ r. servable && return r
497+ rows = join (
498+ [
499+ " $(m. module_file) : $(m. entry_args)$(m. servable ? " " : " <- disagrees" ) "
500+ for m in r. modules
501+ ], " \n "
502+ )
503+ return error (
504+ """
505+ ReactantServerExport: bundle `$(r. name) ` is UNSERVABLE. Its compiled program's arity does not
506+ match the bundle around it, and both readable halves of a bundle can be correct while that is
507+ true, which is why this is read from the graph.
508+ declared inputs : $(r. n_inputs)
509+ tensors in weights.safetensors : $(r. n_weights)
510+ expected entry arguments : $(r. expected)
511+ compiled modules:
512+ $rows
513+
514+ A surplus argument is usually a value Reactant lifted out of the traced closure rather than
515+ baking: a device-resident RNG seed in layer state appears as a leading `tensor<2xui64>`, and
516+ device-resident configuration captured by the model appears in its own shape. Serving this
517+ fails every inference with an argument-count mismatch."""
518+ )
519+ end
520+
521+ # safetensors: little-endian UInt64 header length, then that many bytes of JSON. `ltoh` is a no-op on
522+ # x86 and the difference between correct and accidentally correct anywhere else.
523+ function _safetensors_tensor_count (path:: AbstractString )
524+ isfile (path) || error (" ReactantServerExport: no `weights.safetensors` at `$path `." )
525+ return open (path, " r" ) do io
526+ n = ltoh (read (io, UInt64))
527+ hdr = JSON3. read (String (read (io, Int (n))))
528+ return count (k -> String (k) != " __metadata__" , keys (hdr))
529+ end
530+ end
531+
351532# ============================================================================
352533# Reactant tracing frontend (the former LuxExport; needs Reactant, not Lux)
353534# ============================================================================
@@ -446,7 +627,8 @@ function export_bundle(
446627 ctx = Reactant. ReactantContext ()
447628 push! (ctxs, ctx)
448629 args = (Reactant. to_rarray (x), map (Reactant. to_rarray, warrays)... )
449- mod, _ = Compiler. compile_mlir (ctx, g, args; drop_unsupported_attributes = true )
630+ mod, fn_res = Compiler. compile_mlir (ctx, g, args; drop_unsupported_attributes = true )
631+ _check_entry_arity (fn_res, 1 , length (warrays), " batch size $s " )
450632 modules[Int (s)] = mod
451633 in_shape_julia = collect (Int, size (x))
452634 end
@@ -555,7 +737,8 @@ function export_bundle(
555737 ctx = Reactant. ReactantContext ()
556738 push! (ctxs, ctx)
557739 args = (_modelarg (map (Reactant. to_rarray, xs)), map (Reactant. to_rarray, warrays)... )
558- mod, _ = Compiler. compile_mlir (ctx, g, args; drop_unsupported_attributes = true )
740+ mod, fn_res = Compiler. compile_mlir (ctx, g, args; drop_unsupported_attributes = true )
741+ _check_entry_arity (fn_res, nin, length (warrays), " batch size $s " )
559742 modules[Int (s)] = mod
560743 for i in 1 : nin
561744 in_shapes[i] = collect (Int, size (xs[i]))
@@ -609,7 +792,8 @@ function export_bundle(
609792
610793 ctx = Reactant. ReactantContext ()
611794 args = (map (Reactant. to_rarray, inputs)... , map (Reactant. to_rarray, warrays)... )
612- mod, _ = Compiler. compile_mlir (ctx, f, args; drop_unsupported_attributes = true )
795+ mod, fn_res = Compiler. compile_mlir (ctx, f, args; drop_unsupported_attributes = true )
796+ _check_entry_arity (fn_res, length (inputs), length (warrays), " unbatched" )
613797
614798 in_specs = [
615799 IOSpec (innames[i], eltype (inputs[i]), collect (Int, size (inputs[i])))
0 commit comments