Skip to content

Composition with Chelis

An emitted forward is an ordinary Chelis function. You import it, compose it with hand-written pre- and post-processing, attach it to properties, and hand it to other code: everything Chelis lets you do with a function works on forward. This is the payoff for the type discipline. The emitted module's sig makes its inputs and outputs first-class types the rest of your code can speak in.

This document walks through the composition pattern using the worked example's Hydronnx.Predict module (src/predict.ch) as the concrete case, then notes what does not compose.

The pattern: pre-process, forward, post-process

Section titled “The pattern: pre-process, forward, post-process”

The classic shape. Real-world inference is almost never "feed raw data to the model, ship the raw output". There is a normalize step on the input and a decode step on the output. In Chelis you write each as its own function and chain them.

The example does it like this:

module Hydronnx.Predict
import Hydronnx.IrisClassifier (forward)
export (feature_scale, normalize, top_class, classify)
-- A fixed per-feature scale vector matching the model's input shape.
-- Real pipelines would use learned mean/std; this is a stand-in.
def feature_scale() = reshape(
to_tensor([cast(0.1, f32), cast(0.1, f32), cast(0.1, f32), cast(0.1, f32)]),
[cast(1, int64), cast(4, int64)],
)
-- Pre-process: scale raw input element-wise. The output type
-- (`tensor[1, 4, f32]`) matches `forward`'s declared input by construction,
-- so feeding it in type-checks.
sig normalize: tensor[1, 4, f32] -> tensor[1, 4, f32]
def normalize(raw) = mul(raw, feature_scale())
-- Post-process: collapse the probability row to the predicted class index.
sig top_class: tensor[1, 3, f32] -> tensor[1, int64]
def top_class(probs) = argmax_reduce(probs, cast(1, int32))
-- The composed pipeline: a loaded ONNX model threaded through hand-written
-- Chelis code, type-checked end to end.
sig classify: tensor[1, 4, f32] -> tensor[1, int64]
def classify(raw) = top_class(forward(normalize(raw)))

Five things to note:

  1. forward is imported and called like any other function. The line import Hydronnx.IrisClassifier (forward) is conventional Chelis; there is no special import Hydronnx (load_model) runtime surface. The model load is a build-time concern: Chelis is ahead-of-time compiled, and the model load is implemented as the chelis-hydronnx-emit CLI.
  2. Every intermediate value's shape is in its sig. normalize's output type is exactly forward's input type. forward's output type is exactly top_class's input type. The composition top_class(forward(normalize(raw))) is therefore well-typed by the chain of sigs. If any step's shape did not match, chelis check would reject this line at compile time.
  3. Nothing in the pipeline is Hydronnx-specific. mul, reshape, to_tensor, argmax_reduce, cast: all chelis builtins. The emitter writes nothing that the rest of Chelis cannot also write.
  4. The emitted weights stay encapsulated. forward's body calls w1(), b1(), and so on, but those are private to Hydronnx.IrisClassifier (only forward is exported). The caller does not see the weights.
  5. Composition works in both directions. You can wrap forward in other functions you write (as classify does), and forward is callable from inside any other Chelis code you write. There is no "model" boundary; forward is just a function.

How the example wires this together for chelis test

Section titled “How the example wires this together for chelis test”

tests/example.ch in the worked example calls classify and checks the predicted class:

import Hydronnx.Predict (classify, normalize)
import Hydronnx.IrisClassifier (forward)
def sample_input() = reshape(
to_tensor([cast(5.1, f32), cast(3.5, f32), cast(1.4, f32), cast(0.2, f32)]),
[cast(1, int64), cast(4, int64)],
)
test test_classify_predicts_top_class = {
result = classify(sample_input())
-- assert result == [0] (the argmax of the softmax output)
}

This is fully ordinary Chelis. The acceptance test (tests/h5_example.rs) runs chelis test in the example dir and verifies both example tests pass.

The list of things that just work because forward is a function:

  • Function calls. Pre-process, post-process, and pipelines as above.
  • @property attachment. Quantify over forward's input type, reduce the output to a Boolean, hand the file to chelis prove. See Properties and verification. The single-file constraint applies: the @property must live in the same .ch as forward.
  • AD on gated differentiable surfaces. grad(forward) works for the weight-free fixture and for Hydronnx's weighted tabular Gemm fixture, both checked against finite-difference references. The small CNN image-classifier surface is a documented chelis AD blocker, so broader weighted models need their exact emitted operator surface gated before relying on AD.
  • Multiple emitted modules in one program. Nothing stops you from emitting two different .onnx files into two Hydronnx.<Name> modules and composing both: import both and call them in sequence. The module-name override (--module-name) is there in case two emitted modules collide.
  • Standard chelis tooling. chelis check, chelis test, chelis prove, chelis fmt: all of these operate on the emitted file as on any other.
  • Ungated weighted grad. The worked Gemm classifier is AD-gated, but that does not automatically extend to every weighted model. Composing grad(forward) into a hand-written loss should be paired with a model-specific finite-difference or parity gate. See Limitations and scope.
  • Cross-module chelis prove. A property in module A that calls into module B does not verify; chelis prove works on a single file. The worked example sidesteps this by appending the property to the emitted module.
  • A composed pipeline ending in a non-differentiable op is not differentiable. The example's classify ends in argmax_reduce (mathematically piecewise-constant, zero gradient almost everywhere), so even if forward were AD-ready, grad(classify) would not be meaningful. The Hydronnx inspect and emit detector reports ArgMax as non-differentiable; composition inherits that property. This is not a Hydronnx limitation; it is a property of the post-process the example picked.
  • Runtime reshape with symbolic dims. Rank-2 MatMul and default Gemm can preserve a symbolic graph-input batch dim, and Hydronnx can emit Reshape 0 dims copied from symbolic graph inputs. -1 inference with symbolic dims rejects. See Limitations and scope.
  • Large emitted modules. Weights are baked into the source as literals, so the .ch grows with the parameter count. The example is well within scope; a multi-megabyte model is not. Hydronnx has an internal sidecar archive core, but there is no inline-replacing sidecar-backed emit path. See Weights and large models.

A heuristic for "should I compose this in Chelis or in the exporter"

Section titled “A heuristic for "should I compose this in Chelis or in the exporter"”

Two equally valid paths exist for a pre- or post-process: build it into the ONNX model itself (so the emitted forward does it), or write it as hand-written Chelis around the emitted forward. The trade-offs:

ConcernIn the ONNX modelIn hand-written Chelis
PerformanceA single emitted function; chelis sees the whole graphTwo function calls; chelis sees them separately
Type clarityImplicit; types are an internal detailExplicit sigs; every shape is named
VerificationProperty quantifies over the model's original inputProperty can quantify over the raw input
Hydronnx coverageNeeds every op to be in the emit setPure Chelis; no Hydronnx scope question
ProvenanceLost in the ONNX fileVisible in source

Often the second path is better. A pre-process that uses a non-emittable op (for example Round for quantization or ScatterElements for index updates) is impossible in-model but may be expressible as hand-written Chelis if the primitive exists there. Cos, Tan, Floor, and Ceil emit, but Floor and Ceil still make AD warnings relevant. Pick the path where everything you need is available.