Java client

Run a prepared workbook inside your own JVM process. The model is a data file; the engine is a shared WebAssembly module. No server call, no subprocess, no Excel.

This page is the Local Runtime Client. It runs the model in your process, on a bundle you downloaded: no network, no token, no per-run metering. The other family — the Hosted HTTP SDKs — calls xplo's servers over HTTP. There is no Java Hosted HTTP SDK (Go, TypeScript, Python and Rust have one); from the JVM, hosted execution means plain HTTP against the Runtime API — fetch the cell interface, POST a run.

It is the same evaluator that powers hosted execution and the vmrun CLI, so the same model and inputs give the same values in every WebAssembly host. See How closely the engines agree, below, for the one place that is not the whole truth.

It runs on Chicory, a pure-Java WebAssembly runtime: no JNI, no native library, no platform classifiers. One jar runs on every JVM — including environments where loading native code is not permitted.

Run it in one command

Every downloaded bundle carries a ready-to-run Java program and a vendored copy of this client, so the fastest path to your own number needs no artifact resolution from us at all.

  1. On your workbook page, open Ways to run it → Local Runtime Clients → Download the bundle, and unzip it.
  2. From the bundle folder:
cd examples/java && mvn -q compile exec:java

It prints one line per output — real names, real numbers, from this model:

lookup_total = 27
total = 4

examples/java/src/main/java/Run.java was generated from the bundle's own manifest.json and interface.json, so its inputs and outputs are already your cells. The generated pom.xml depends only on Chicory and Jackson from Maven Central — the xplo client itself is vendored in examples/java/src/main/java/software/pythia/xplo/. Edit the values at the top and re-run. examples/README.md lists the equivalent one-liner for the CLI, Node, Python, and Go.

Downloading a bundle or the client source is part of the Local Runtime tier (Team plan and up).

When to use it

You want to Use
Evaluate a model inside a JVM service or batch job This client
Run a model without managing any artifacts Runtime API over plain HTTP
Explore a model from a terminal The bundle's bin/vmrun CLI

The client is versioned against the engine contract, not against any workbook. Depend on it once and load as many models as you like — a new workbook is a new data file, not a new artifact.

Install

Maven:

<dependency>
  <groupId>software.pythia</groupId>
  <artifactId>xplo-runtime</artifactId>
  <version>0.1.0</version>
</dependency>

Gradle (Kotlin DSL):

implementation("software.pythia:xplo-runtime:0.1.0")

Not on Maven Central yet. software.pythia:xplo-runtime has not been published, so the coordinate above resolves to nothing today. Until it is released, build the artifact into your local repository from one of the three sources below; the coordinate above is what the dependency will be, and it is what you declare in every case.

1. Use the copy inside your bundle. examples/java/src/main/java/software/pythia/xplo/ is the complete library — copy that package into your own source tree and depend on Chicory + Jackson directly (the versions the generated pom.xml pins).

2. Download the client source from the API — the path for a team without Maven Central access:

curl -fsSL -H "Authorization: Bearer $XPLO_TOKEN" \
  "https://xplo.pythia.software/api/v2/clients/java" -o xplo-client-java.zip
unzip -q xplo-client-java.zip           # unpacks ./java
(cd java && mvn install -DskipTests)    # installs software.pythia:xplo-runtime:0.1.0

Then declare the dependency above. Gradle users also need repositories { mavenLocal() }. The token is an xplo_ API token, the same one the Runtime API uses. Create or manage it in API Tokens.

3. Build from a checkout of the xplo repository, if you have one: (cd /path/to/explo/clients/java && mvn install -DskipTests).

Requires JDK 17+ (not just a JRE). On macOS, /usr/bin/java may be a stub that errors with "Unable to locate a Java Runtime" — install a JDK (e.g. brew install openjdk) and set JAVA_HOME so java/mvn/Gradle resolve it. Chicory + Jackson are pulled transitively; no native library or classifier.

Quickstart

Point the client at the unzipped bundle folder:

import java.nio.file.Path;
import java.util.List;
import java.util.Map;
// Import the types explicitly: a wildcard `import software.pythia.xplo.*` makes
// `Runtime` ambiguous with `java.lang.Runtime` and will not compile.
import software.pythia.xplo.Runtime;
import software.pythia.xplo.Model;
import software.pythia.xplo.CellResult;
import software.pythia.xplo.RunOptions;
import software.pythia.xplo.Sweep;

Model model = Runtime.loadBundleFrom(Path.of("./model-bundle"));

System.out.println(model.manifest());       // the typed input/output contract
System.out.println(model.namedInputs());    // names you can set
System.out.println(model.namedOutputs());   // names you can read

Map<String, CellResult> out =
    model.run(Map.of("loan_amount", 200_000), List.of("monthly_payment"));
System.out.println(out.get("monthly_payment").asDouble());

Names exist only once labels are confirmed on the workbook, and Naming + Labelling is optional. If model.namedInputs() is empty, this bundle has no names yet — use runRaw with addresses (model.runRaw(Map.of("Sheet1!B1", 5.0), List.of("Sheet1!B4"))). You can keep using cell references indefinitely, or add labels in the app and download the bundle again.

What is in a bundle, and slim bundles

A full bundle contains plan.xvm (your model), model.wasm (the shared engine), interface.json (your names), manifest.json (the typed contract), an examples/ directory, and a bin/vmrun binary for the command line.

A slim bundle carries only the model and its metadata — no model.wasm, no bin/vmrun (≈20 KB instead of ≈5.7 MB). It still runs, because the interpreter is identical for every model and the client caches it once per machine. Runtime.loadBundleFrom resolves the engine in this order:

  1. XPLO_RUNTIME_WASM — an explicit path to a vmruntime.wasm.
  2. The machine cache — XPLO_RUNTIME_CACHE, or the OS user-cache directory plus /xplo/runtime, keyed by the runtime_hash recorded in bundle.json.
  3. The bundle's own model.wasm, which is then copied into the cache for next time.

The cache is shared across languages — the file names are identical — so loading one full bundle from any language warms it for all of them. A slim bundle with a cold cache fails loudly rather than confusingly:

XploException: bundle at ./model-bundle ships no interpreter (slim bundle) and
none is cached: set XPLO_RUNTIME_WASM to a vmruntime.wasm, or load a full bundle
once (it caches the shared runtime automatically)

If the resolved engine is not the one the bundle was built against, the load fails with an interpreter hash mismatch rather than running a mismatched pair.

One more environment variable is Java-specific: XPLO_RUNTIME_COMPILE=0 makes Chicory walk the WebAssembly instead of compiling it to JVM bytecode. It is a diagnostic escape hatch, not a normal setting: it leaves the hot opcode and helper dispatch paths interpreted. The shared engine splits those dispatchers into JVM-sized functions so normal compiler mode covers them; after loading, warm the exact model and workload before measuring steady-state latency because HotSpot JIT warm-up still applies.

Names and cell addresses

Names come from the labels you set on the workbook, and are the stable way to call a model — a cell can move without breaking your code.

Aliases work, and name matching is case-insensitive. Cell addresses are always available as an escape hatch, and any key containing ! is treated as one — addresses are matched exactly, including the sheet name, so sheet1!b1 is not Sheet1!B1:

model.run(Map.of("Loan!B1", 200_000), List.of("Loan!B8"));
model.runRaw(Map.of("Loan!B1", 200_000), List.of("Loan!B8"));

RunOptions carries every escape hatch. Start a chain with a static factory — none(), rawInputs(map) or rawOutputs(list) — and extend it with the with… methods:

model.run(
    Map.of("loan_amount", 200_000),
    List.of("monthly_payment"),
    RunOptions.rawInputs(Map.of("Loan!C7", 0.02))
        .withRawOutputs(List.of("Loan!D9")));

Passing null outputs returns every terminal output; an empty list selects nothing. withAllNamedOutputs() and withAllNamedInputs() are instance methods, so they need a factory first — RunOptions.none() is the one that adds nothing else:

model.run(inputs, null, RunOptions.none().withAllNamedOutputs());

namedInputs() and namedOutputs() list canonical names only. To see aliases, review status, and long labels, read model.interfaceLabels().

Values

Inputs accept Double, Boolean, String, and null. Integral types are widened for you, so Map.of("loan_amount", 200_000) is fine — but a value beyond exact floating-point range (2^53) is refused rather than silently rounded, because Excel numbers are 64-bit floats. The accepted numeric types are the primitive wrappers; BigInteger and BigDecimal are refused by name rather than rounded.

A null value means leave that cell at its compiled baseline — it is not a blank-cell override, and it is indistinguishable from omitting the key. Pass "" if you want an empty string in the cell.

Text works, which is what a text-keyed VLOOKUP or MATCH needs:

model.runRaw(Map.of("Sheet1!B1", "Yes"), List.of("Sheet1!D1"));

Each result is a CellResult with value() and error(). error() is an Excel error string such as "#DIV/0!", or null. asDouble() throws rather than coercing a non-number:

CellResult cell = out.get("monthly_payment");
if (cell.isError()) {
    System.out.println("Excel error: " + cell.error());
} else {
    System.out.println(cell.asDouble());
}

Monte-Carlo sweeps

To vary inputs across many trials, prepare a sweep once and run it repeatedly. The engine recomputes only the cells between the inputs you vary and the outputs you watch, so cost scales with that path rather than with the workbook:

Sweep sweep = model.prepare(
    List.of("loan_amount", "rate"),
    List.of("monthly_payment"));

Sweep.BatchResult out = sweep.run(new double[][] {
    {200_000, 0.05},
    {250_000, 0.06},
    {300_000, 0.07},
});

double[] payments = out.column("monthly_payment");   // one value per trial
double value = out.at(0, "monthly_payment");         // one trial, one output

For large sweeps, runFlat takes a pre-flattened scenario-major array and skips building rows:

int trials = 100_000;
double[] values = new double[trials * 2];
// fill: values[i * 2] = loanAmount, values[i * 2 + 1] = rate
Sweep.BatchResult result = sweep.runFlat(values, trials);

Sweeps are numeric — every value is a double in both directions. Text overrides are not yet supported here; use run for those.

The batch wire format carries doubles and nothing else, so a trial whose watched output is an Excel error comes back as Double.NaN from column() and at(). Excel has no NaN of its own, so a NaN is always an error and never an answer — check for it before averaging a column (Double.isNaN), and re-run that scenario through model.run to get the Excel error string.

Which cells a sweep may touch is narrower than the manifest: model.simInfo() reports the varyable inputs, watchable outputs and intermediates with their blob cell indices; non-numeric and VM-uncovered cells are absent, so a sweep can never select one.

Sweeps are also deterministic. Workbooks whose results depend on RAND or RANDBETWEEN resampling per trial are not supported by this path.

Loading several models

Each model gets its own WebAssembly instance, so models are independent and safe to hold at the same time. Parse the engine once to avoid repeating that work:

Runtime rt = Runtime.fromFile(Path.of("./model-bundle/model.wasm"));
Model pricing = rt.loadBundle(Path.of("./pricing-bundle"));
Model risk    = rt.loadBundle(Path.of("./risk-bundle"));

A fresh instance per model is required, not incidental — the program and the string interner are module-global, so every load/loadBundle builds one. There is no way, and no need, to rebind an existing instance to another model.

When things fail

Every failure throws XploException. The client prefers a clear error over a plausible number:

Situation What happens
Unknown name Throws, naming the key and suggesting rawInputs
Retired or unreviewed name Throws, saying which
Two names pointing at the same cell Throws rather than letting one silently win
A cell the engine cannot evaluate Throws — never a zero that looks like an answer
interface.json that does not match the model Fails to load, rather than mislabelling cells
A slim bundle with no cached engine Throws, naming XPLO_RUNTIME_WASM
A number beyond exact float range Throws rather than rounding

On a bundle with no confirmed labels, a name is not "unknown" — it is treated as a cell address, and you get no input/overridable cell at address loan_amount from the engine instead. Check model.namedInputs() first.

Running natively (the native target)

By default this client runs your model in process on vmruntime.wasm. A full bundle also ships bin/vmrun, and you can run the model on that native binary instead — the same VM engine, a different execution target:

Model model = Runtime.loadBundleFrom(dir, BundleLoadOptions.nativeTarget());
// run, runRaw, prepare and every result reader work exactly as before.

WASM stays the default, and the two targets never fall back to one another — you choose explicitly, because they can differ in the last bits (see the next section). Chain .withNativeBinary("/path/to/vmrun") onto nativeTarget() to point at a specific binary (handy in development, or to run a slim bundle, which ships no binary and otherwise fails the native target with an actionable message). Two things to know before you switch:

  • It is a downloaded executable. The native target runs bin/vmrun from the bundle, which is not sandboxed the way WebAssembly is — use only bundles from a source you trust.
  • It spawns a process per call. Each scalar run re-reads plan.xvm, so a loop of single runs can be slower end-to-end than in-process WASM even though native arithmetic is faster; a Monte-Carlo sweep pays that cost once for the whole batch.

How closely the engines agree

Every WebAssembly host runs the identical vmruntime.wasm: this client, the other three Local Runtime Clients, the browser calculator, and the bundle's own loader.mjs return bit-identical f64 values for the same model and inputs. That part of "the same everywhere" is exact, and it is checked mechanically for every client on every bundle.

The native vmrun CLI (and the hosted service, which runs the same binary) is a separate compilation of the same source, so it can differ in the last bits. On a 722-output finance workbook, 687 outputs matched the native oracle bit-for-bit, 34 differed by 1–2 ULP — the documented libm transcendental gap — and one cell differed materially: a near-total cancellation where native keeps a residue of 1.8189894035458565e-12 and WebAssembly returns exactly 0. That last one is an open engine bug, not a client bug, and it is the same in all four languages.

So: reconcile a client number against another WebAssembly surface and expect the bits to match; reconcile it against the native CLI or the hosted API and expect agreement to a few ULP, with cancellation-heavy cells as the known exception.

Dependencies

Chicory (the WebAssembly runtime) and Jackson (JSON). Both are pure Java.

API reference

Member Description
Runtime.loadBundleFrom(dir) One-shot: resolve the engine, parse it, load the model
Runtime.loadBundleFrom(dir, BundleLoadOptions.nativeTarget()) Load on the wasm (default) or native vmrun target
Runtime.fromFile(path) / new Runtime(bytes) Parse the engine once
rt.load(blob, iface) / rt.loadBundle(dir) Instantiate a model (a null SemanticInterface = addresses only)
model.run(inputs, outputs[, options]) Run using names, with address fallbacks
model.runRaw(inputs, outputs) Run using cell addresses only
RunOptions.none() / .rawInputs(map) / .rawOutputs(list) Start an options chain
.withRawInputs(...) / .withRawOutputs(...) / .withAllNamedOutputs() / .withAllNamedInputs() Extend it
model.manifest() Typed input/output contract
model.namedInputs() / model.namedOutputs() Canonical names callable today
model.interfaceLabels() / model.interfaceRevision() Aliases, labels, review status, revision
model.simInfo() Cells a sweep may vary or watch
model.prepare(inputs, outputs) Build a reusable sweep
sweep.run(scenarios) / sweep.runFlat(values, n) Run trials
result.column(output) / result.at(trial, output) Read sweep results (Double.NaN = errored trial)

See also