Inference
Calling an ML model from a handler usually means one of three very different things: invoking an artifact loaded in-process, POSTing to a model server, or hitting a cloud endpoint. The inference port makes all three the same call. A spec names one logical task with typed input and output models; the wiring config binds it to a physical model. Handlers pass typed instances and get typed predictions back — no model URIs, artifact formats, or wire protocols cross the port, so swapping a pickled sklearn model for a SageMaker endpoint is a wiring change with zero handler edits.
The shape in code¶
One spec = one model. The spec names the task, never the artifact:
from forze.application.contracts.inference import InferenceSpec
from pydantic import BaseModel
class FraudFeatures(BaseModel):
amount: float
country: str
velocity_24h: int
class FraudScore(BaseModel):
risk: float
FRAUD_SCORER = InferenceSpec(
name="fraud_scorer",
input=FraudFeatures,
output=FraudScore,
)
Handlers resolve it off the context and call it like any other port. Both type parameters propagate, so predictions are fully typed with zero annotations:
port = ctx.inference.model(FRAUD_SCORER)
score = await port.predict(FraudFeatures(amount=120.0, country="NL", velocity_24h=3))
scores = await port.predict_many(batch) # vectorized, order-preserving
The port is read-plane: invoking a model is a pure read of it, so a QUERY
operation can call it — a query handler computing a recommendation must be able
to score. A scalar prediction wraps in a one-field output model; tensor-shaped
payloads are plain lists of floats inside the models.
Three calls, three shapes:
| Method | Shape | Semantics |
|---|---|---|
predict |
one instance → one prediction | the request-path call |
predict_many |
batch → batch | vectorized; all-or-nothing, order-preserving |
predict_stream |
chunk stream → chunk stream | bounded-memory scoring over large sets |
predict_many either returns a prediction for every instance or raises for the
whole batch — never a silent partial result. predict_stream streams instance
chunks, not tokens; every chunk boundary is a deadline check and a cancellation
point.
In-process models¶
The local adapter's contract is a callable, not an artifact format. You
supply a loader returning an object with a sync predict_batch; the framework
only schedules it — loading and every prediction run off the event loop under
the CPU-offload seam (bounded pool, deadline enforcement, cancellation):
from forze.application.integrations.inference import (
LocalInferenceConfig,
LocalInferenceDepsModule,
local_inference_lifecycle_step,
)
def load_fraud_model():
import joblib # your dependency, not the framework's
return joblib.load("fraud-v3.bin") # exposes predict_batch(instances)
inference_module = LocalInferenceDepsModule(
models={"fraud_scorer": LocalInferenceConfig(loader=load_fraud_model)},
)
steps = [local_inference_lifecycle_step(inference_module)]
The framework never deserializes artifacts itself — unpickling is arbitrary code execution, and that trust decision stays in your loader. Two behaviors worth knowing:
- Warm by default.
warm_on_startup=Trueloads the model at boot through the lifecycle step and fails startup closed on a loader error: a service that would fail its first prediction should not come up. Set itFalseto load lazily on first call. - Thread safety is your model's contract. Predictions run on a shared
worker pool, so concurrent calls hit the same model object from multiple
threads. sklearn and ONNX Runtime sessions generally tolerate this; for a
model that does not, set
serialize_calls=Trueto route every call through a per-route lock — correctness over throughput.
The mock¶
MockDepsModule answers inference routes from a pure sync function you
register — deterministic by contract, so simulation replays stay exact:
from forze_mock import MockDepsModule, MockInferenceRegistry
registry = MockInferenceRegistry().on(
"fraud_scorer",
lambda instances: [{"risk": min(1.0, i.amount / 1000)} for i in instances],
)
module = MockDepsModule(inference=registry)
An unprogrammed route fails closed (code="mock.inference.unprogrammed").
Outputs pass through the same boundary shaping as every real adapter, so a
mis-shaped stub fails under the mock exactly where a mis-shaped backend would
fail in production.
Capabilities and errors¶
Backends diverge, and the port says so declaratively instead of pretending
uniformity: each adapter publishes inference_capabilities (native batching, a
hard batch cap, chunked streaming, offline jobs, a determinism promise), and a
request that strays is refused up front with a clean precondition
(inference_feature_unsupported) naming the feature and backend — never a
silent degradation. The failure taxonomy at the boundary:
| Condition | Kind | Code |
|---|---|---|
| Instance is not the spec's input model | validation |
core.validation |
| Backend response doesn't fit the output model | validation |
inference_output_mismatch |
| Feature the backend lacks | precondition |
inference_feature_unsupported |
| Per-call timeout / invocation deadline expired | timeout |
cpu_offload_deadline (local) |
Per-call options tighten, never extend: options={"timeout": timedelta(...)}
binds a deadline that is the earlier of the per-call budget and the ambient
invocation deadline.
Features in traces¶
Simulation value capture masks every input field by default:
FRAUD_SCORER = InferenceSpec(
name="fraud_scorer", input=FraudFeatures, output=FraudScore,
capture_inputs=True, # opt in to verbatim features on captured traces
)
Features cannot be field-encrypted — the model needs real values, which is why
external routes must acknowledge data egress — and they are usually PII-dense,
so a captured trace shows "<redacted>" in their place unless you set
capture_inputs=True. This only affects runtime tracing and simulation;
production traces are id-only regardless. It is worth knowing because a DST
bundle is an artifact that gets stored and shared.
Resilience¶
Inference has an unusually good fit with the framework's resilience policies, because a prediction is a pure read: it mutates nothing, so the strategies that are normally unsafe to apply are safe here. Bind a policy to the inference dep key and every resolved port runs under it:
from forze.application.contracts.inference import InferenceDepKey
from forze.application.contracts.resilience import PortPolicy
ResilienceDepsModule(
spec=my_policies, # defines "model_calls"
port_policies=(PortPolicy(key=InferenceDepKey, policy="model_calls"),),
)
Three things are worth tuning deliberately:
- Hedging. Models have a long tail — a cold endpoint, an unlucky queue, a
slow batch. A hedge races
a second attempt and takes whichever returns first. It is normally restricted
to idempotent reads, which every
predictis. The real cost is money rather than correctness: a hedged call to a paid endpoint is billed twice, so setbudgetto cap the extra load, and preferadaptive_delay_quantileso the hedge tracks the model's actual p95 instead of a guess that ages badly. - Criticality. One policy serves every tier, and the tier rides the call
context — so a
BEST_EFFORTrecommendation sheds ahead of aCRITICALfraud check under load without duplicating any wiring. Set it withbind_criticalityon the caller; see load shedding. - Retries. The taxonomy above already says what is worth retrying:
throttledandinfrastructureare,validationandpreconditionare not. A retried call re-runs the wholepredict— including the CPU offload for a local model — so keepmax_batch_sizein mind when retrying big batches.
One caveat specific to this port: predict_stream is an async generator, so it
gets the circuit breaker only. Retry, hedging, timeout, bulkhead, and rate
limiting never apply to it — a partially consumed stream cannot be replayed. Put
the policy pressure on predict / predict_many, and treat a long scoring
stream as something you bound with deadlines instead.
What this seam is not¶
No training, no experiment tracking, no model registry, no agent loops. The
contract is deliberately invocation only — an opaque, versioned, possibly
non-deterministic function artifact with typed IO. Anything that produces or
manages artifacts lives outside; the seam just calls them. The
remote adapters (forze_inference) bind the
same spec to served (KServe V2 / MLflow) and cloud (SageMaker) models — and for
text-embedding models specifically, use the dedicated embeddings provider port
(ctx.embeddings.provider(spec)) that vector search already
consumes.