compprov (Computational Provenance) is a Java framework that automatically builds a Calculation Provenance Graph (CPG) — a DAG that records every variable and every operation in a computation as it runs. The result is a complete, machine-readable audit trail of how each output was derived from its inputs.
Most provenance tooling — workflow managers, data lineage graphs, PROV-style trackers — operates one level too high: it can show that dataset B was produced from dataset A by process P, but it records nothing about what happened inside P. The arithmetic itself — every intermediate value, every coefficient, every rounding decision — goes unrecorded. compprov captures lineage at the arithmetic level instead: wrapping a value is what makes it trackable, so nothing entering a computation can bypass the audit trail by omission, and the result is a CPG that persists as a self-contained artifact rather than a discarded byproduct.
- Why compprov
- Core concepts
- Getting started
- Usage example
- Snapshot: export, replay, and diff
- Subgraph folding: scaling cyclic computations
- Extending with custom type wrappers
- Built-in types
- Thread safety
- Visualization
- Examples
- License
In finance and other regulated domains, "trust the output" is not an acceptable audit posture,
but "hand over the source code" is usually not on the table either — the calculation logic is
the intellectual property. compprov is built around a middle path: wrap the domain types
(Amount, Rate, position sizes, whatever the business model already uses) without modifying
them, and let the CPG accumulate as a byproduct of running the real computation.
The NAV example in this repo demonstrates the pattern end-to-end: a
multi-asset crypto-portfolio valuation (BTC, ETH, USDC held across Binance, staking, and Morpho)
is wrapped through custom WrappedAmount / WrappedRate types, producing a CPG that can be
serialized, handed to an auditor, and replayed in a fresh environment — reproducing the exact
total to the cent — without shipping the proprietary valuation code itself. The auditor gets a
convert/addBulk operation trail with every exchange rate, timestamp, and intermediate USD
value attached; they don't get (and don't need) the pricing engine.
The same CPG also supports sensitivity analysis via input substitution: copyWith() swaps
one or more INPUT-kind variables (e.g. the BTC/USD, ETH/USD, USDC/USD rates) and replays the
graph to see how the total propagates, without touching the source that produced the original
snapshot. This turns "what would this valuation have been under different market conditions" from
a request to re-run internal systems into a query an external party can run against an exported
artifact.
Application logs and workflow-level lineage record that a step ran and roughly what it touched;
they rarely record the specific coefficients, intermediate roundings, or substitutions that
produced a number. Because every execute() call in compprov is what creates a tracked variable
and operation node — not an optional side-effect a developer has to remember to add — a CPG
cannot have a silent gap where a value was used but never logged.
This matters most when the gap is discovered after the fact. Reconstructing the interferometric
gauge block calibration example in this repo from its source
publication surfaced exactly this: 7 of the 13 required inputs (air temperature, pressure,
humidity, CO₂ concentration, the fringe orders, part temperature) were never published alongside
the reported result. A file- or pipeline-level provenance system has no way to expose that kind
of gap, because it never looks inside the calculation to begin with. compprov's structure forces
the question to be answered explicitly for every input, which is what makes a reconstruction like
this auditable rather than just plausible — see the code and Descriptor metadata in
GaugeBlockCalibration for how each assumed or back-calculated input is documented inline.
Regulated and scientific computations are routinely re-audited years after they ran, by which
point the original library versions, runtime, or even the source repository may no longer build.
Re-establishing "what actually happened" by reconstructing that legacy environment is expensive
and often impossible. A Snapshot sidesteps this: every variable value, its full metadata, and
the chronologically ordered operation sequence are embedded in one serializable JSON artifact —
so interpreting what a computation did never depends on resurrecting the environment that ran
it.
Replaying that snapshot to reproduce the computed values is a narrower guarantee than reading
it: env.compute() needs the compprov runtime and, for any custom types involved, the
corresponding wrapper classes registered in the target environment (see
Extending with custom type wrappers). What deterministic
replay eliminates is the dependency on the original proprietary orchestration code — not on
compprov itself. For computations built entirely on the built-in BigDecimal/BigInteger
wrappers, the snapshot is fully self-contained and replayable with nothing beyond
compprov-core on the classpath.
| Good fit | Poor fit |
|---|---|
| Financial/NAV, valuation, and pricing calculations that need third-party audit without source disclosure | Millions-of-iterations numerical simulations (climate, CFD, molecular dynamics) — CPG size and tracking overhead scale with recorded operations; use subgraph folding and expect it to still be the wrong tool at that scale |
| Regulated measurement / metrology pipelines where every input's provenance (measured, assumed, back-calculated) must be explicit | Hot-path / latency-sensitive numeric code where even the folded overhead (~2×–6× in this repo's benchmarks) isn't acceptable |
| Scientific reproductions where the original inputs or derivation steps were incompletely published, and documenting what was assumed is itself the deliverable | Non-deterministic external state you need re-verified on replay (a re-fetched market price, a live sensor reading) — a snapshot captures such values as immutable inputs at the moment they were wrapped, it does not re-query them |
| Long-horizon audit trails that must outlive the software/runtime that produced them | Codebases not yet on Java 17+, or where introducing wrapper types throughout the calculation path isn't feasible |
| Concept | Description |
|---|---|
| CPG | Directed Acyclic Graph where nodes are variables and edges are data-flow dependencies. Produced automatically during execution. |
| Snapshot | Immutable, point-in-time capture of all variables and operations recorded in a context. Can be serialized to JSON, replayed, or compared. |
| Descriptor | Name + optional metadata (Meta) attached to a variable or operation. Used in logs, diff reports, and audit trails. |
| VariableWrapper | Factory that converts a plain value into a provenance-tracked WrappedVariable and registers it in the active context. |
| ComputationEnvironment | Shared, thread-safe configuration: registered wrappers, clock, Jackson mapper, descriptor enforcement rules. |
| ComputationContext | Per-computation scope that accumulates the CPG. Not safe to snapshot while mutating. |
Add the dependency to your pom.xml (latest version: ):
<dependency>
<groupId>io.compprov</groupId>
<artifactId>compprov-core</artifactId>
<version>VERSION</version>
</dependency>Requires Java 17+.
The entry point is DefaultComputationEnvironment (preconfigured with all built-in wrappers
and Jackson serializers) and DefaultComputationContext (typed convenience wrappers on top
of the base context).
import io.compprov.core.*;
import io.compprov.core.meta.Descriptor;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
// --- 1. Create the environment (thread-safe; reuse across computations) ---
var env = DefaultComputationEnvironment.create();
// --- 2. Create a context for this computation run ---
var ctx = new DefaultComputationContext(
env,
new DataContext(Descriptor.descriptor("invoice-calculation")));
// --- 3. Wrap all inputs ---
// Every wrapped value gets a unique ID and is recorded in the CPG as an INPUT node.
var mc = ctx.wrapMathContext(new MathContext(10, RoundingMode.HALF_UP),
Descriptor.descriptor("mc"));
var price = ctx.wrapBigDecimal(new BigDecimal("100.00"),
Descriptor.descriptor("price"));
var taxRate = ctx.wrapBigDecimal(new BigDecimal("0.08"),
Descriptor.descriptor("tax-rate"));
// --- 4. Perform operations ---
// Each call records an operation node in the CPG and returns a wrapped result.
// Pass null as the last argument to let the framework auto-name the result.
var tax = price.multiply(taxRate, mc, Descriptor.descriptor("tax"));
var total = price.add(tax, mc, Descriptor.descriptor("total"));
// --- 5. Read the result like any other value ---
System.out.println(total.getValue()); // 108.0000000
// --- 6. Export the full Calculation Provenance Graph ---
Snapshot snapshot = ctx.snapshot();
System.out.println(env.toJson(snapshot));
System.out.println(env.toHumanReadableLog(snapshot));{
"descriptor" : { "name" : "invoice-calculation", "meta" : { } },
"variables" : [
{ "track" : { "id" : "i_1", "descriptor" : { "name" : "mc" }, ... }, "value" : ... },
{ "track" : { "id" : "i_2", "descriptor" : { "name" : "price" }, ... }, "value" : "100.00" },
{ "track" : { "id" : "i_3", "descriptor" : { "name" : "tax-rate" }, ... }, "value" : "0.08" },
{ "track" : { "id" : "o_4", "descriptor" : { "name" : "tax" }, ... }, "value" : "8.000000000" },
{ "track" : { "id" : "o_5", "descriptor" : { "name" : "total" }, ... }, "value" : "108.0000000" }
],
"operations" : [
{ "track" : { "id" : "op_1", "descriptor" : { "name" : "multiply" }, ... },
"arguments" : [ { "key" : "a", "value" : "i_2" }, { "key" : "b", "value" : "i_3" }, { "key" : "mc", "value" : "i_1" } ],
"resultId" : "o_4" },
{ "track" : { "id" : "op_2", "descriptor" : { "name" : "add" }, ... },
"arguments" : [ { "key" : "a", "value" : "i_2" }, { "key" : "b", "value" : "o_4" }, { "key" : "mc", "value" : "i_1" } ],
"resultId" : "o_5" }
]
}Each entry in arguments is a {key, value} pair (argument name → referenced variable id),
kept as an ordered list rather than a map so argument order is preserved verbatim in the JSON.
Variable IDs use the prefix i_ for inputs and o_ for outputs, followed by a sequential
numeric counter that is stable within a single context run.
String json = env.toJson(ctx.snapshot());
// Deserialize back to a Snapshot
Snapshot restored = env.fromJson(json);env.compute() replays all recorded operations against the given snapshot,
producing a new context with freshly computed outputs:
var replayed = env.compute(restored);
BigDecimal replayedTotal = (BigDecimal) replayed.getVariable("o_5").getValue();Use copyWith to substitute one or more input values, then replay:
Snapshot modified = env.copyWith(
restored,
Descriptor.descriptor("invoice-calculation-v2"),
Map.of("i_3", new ValueWithDescriptor(
Descriptor.descriptor("tax-rate"),
new BigDecimal("0.10")))); // 10% tax instead of 8%
var updated = env.compute(modified);
// updated.getVariable("o_5") now reflects the new totalA CPG that faithfully records every operation is what makes compprov useful for auditing — but for cyclic algorithms (Monte Carlo simulations, iterative solvers, time-series walks) that repeat the same computation shape thousands or millions of times, that fidelity has a cost: every iteration adds its own operations and intermediate variables to the graph, so both live heap usage and exported snapshot size grow with iteration count times the number of tracked steps per iteration.
Subgraph folding (implemented as Subgraph / WrappedSubgraph:
define the repeated step once as a reusable template, then replay it per cycle as a single
operation instead of re-recording its internal steps every time.
io.compprov.examples.pi.PiCalculationStress benchmarks a Monte Carlo Pi estimator (5
tracked operations per sampled point: pow, pow, add, setScale, subtract) with and
without folding. At 250,000 points, the exported JSON snapshot for the un-folded run was
1.6 GB; the folded run produced an identical calculation in 628 MB — about 2.5x
smaller — because each iteration contributes one execute operation and one result
variable to the graph instead of five of each. The saving scales with the number of steps
captured in the template: a repeated step with 20 internal operations would save roughly
20x per iteration instead of 5x.
The same reduction applies to memory while the computation is running: ComputationContext
keeps every recorded variable and operation in memory until snapshot() is called, so fewer
recorded nodes per cycle means a smaller live heap footprint, not just a smaller export.
- Build the template once, normally, in its own disposable
ComputationContext. This produces a small, self-contained CPG for a single execution of the repeated step. - Capture it as a
Subgraph:new Subgraph(templateCtx, argumentIds, resultId)records which of the template's variable ids are inputs and which one is the output. - Wrap it once in the context that drives the cycle:
ctx.wrapSubgraph(subgraph, descriptor). This adds exactly one variable to the outer CPG, no matter how many times it's later invoked. - Execute it per cycle:
subgraphVar.execute(List.of(arg1, arg2, ...), resultDescriptor). Internally this replays the template's function chain against the new argument values entirely in memory — no new tracking metadata is created for the internal steps. Only the outerexecutecall is recorded inctx, as one operation producing one result variable.
var env = DefaultComputationEnvironment.create();
// 1. Build the repeated step ONCE, in its own throwaway context.
var templateCtx = new DefaultComputationContext(env,
new DataContext(Descriptor.descriptor("Pi calculation step with x,y points")));
var x = templateCtx.wrapBigDecimal(BigDecimal.ZERO, Descriptor.descriptor("x"));
var y = templateCtx.wrapBigDecimal(BigDecimal.ZERO, Descriptor.descriptor("y"));
var mc = templateCtx.wrapMathContext(MathContext.DECIMAL128, Descriptor.descriptor("computation precision"));
var rmc = templateCtx.wrapMathContext(new MathContext(0, RoundingMode.DOWN), Descriptor.descriptor("0/1 math-context"));
var one = templateCtx.wrapBigDecimal(BigDecimal.ONE, Descriptor.descriptor("constant 1"));
var two = templateCtx.wrapInteger(2, Descriptor.descriptor("constant 2 integer"));
var xSquared = x.pow(two, mc, Descriptor.descriptor("x^2"));
var ySquared = y.pow(two, mc, Descriptor.descriptor("y^2"));
var dist = xSquared.add(ySquared, mc, Descriptor.descriptor("x^2 + y^2"));
var distRound = dist.setScale(rmc, Descriptor.descriptor("0/1 distance"));
var inCircle = one.subtract(distRound, mc, Descriptor.descriptor("inCircle"));
// 2. Capture it as a reusable Subgraph: declare which variables are inputs (by id)
// and which one is the result.
var ctx = new DefaultComputationContext(env,
new DataContext(Descriptor.descriptor("Pi calculation with 100000 points")));
var piStep = ctx.wrapSubgraph(
new Subgraph(
templateCtx,
List.of(x.getVariableTrack().getId(), y.getVariableTrack().getId()),
inCircle.getVariableTrack().getId()),
Descriptor.descriptor("Pi calculation step"));
// 3. Drive the cycle. Each call replays the 5-step template in memory and records
// exactly ONE "execute" operation + ONE result variable in `ctx` — not the five
// operations and five intermediates the un-folded version would add per iteration.
var counter = ctx.wrapBigDecimal(BigDecimal.ZERO, Descriptor.descriptor("initial counter"));
for (long i = 0; i < totalPoints; i++) {
var xi = ctx.wrapBigDecimal(BigDecimal.valueOf(random.nextDouble()), Descriptor.descriptor("x_" + i));
var yi = ctx.wrapBigDecimal(BigDecimal.valueOf(random.nextDouble()), Descriptor.descriptor("y_" + i));
var inCircleI = (WrappedBigDecimal) piStep.execute(List.of(xi, yi), Descriptor.descriptor("inCircle_" + i));
counter = counter.add(inCircleI, mc, Descriptor.descriptor("counter_" + i));
}See the full runnable version in io.compprov.examples.pi.PiCalculator (calculate()), and
the side-by-side benchmark in io.compprov.examples.pi.PiCalculationStress.
The same folding pattern also handles integrands where rejection sampling doesn't apply — see
io.compprov.examples.pi.MonteCarloArcsineIntegrationStress, which estimates pi via the
average-value method on f(x) = 1/sqrt(1-x^2) (unbounded as x -> 1, so no finite bounding box
exists for rejection sampling).
A Subgraph already accepts any number of inputs natively — argumentIds is a list, and
execute(List.of(arg1, arg2, ...), resultDescriptor) takes as many argument values as the
template declares. What it can't do natively is return more than one value: one execute()
call records exactly one execute operation producing exactly one result variable, because
resultId is singular.
When a step's output is inherently multi-valued (an integrator that advances both position and velocity in one step, for example), pack the values into a single tracked array on the way out, and unpack them wherever an individual component is needed:
WrappedBigDecimal.array(values, resultDescriptor)(orWrappedBigInteger.array(...)) packs the receiver plus a list of other scalars into oneWrappedBigDecimals/WrappedBigIntegers— a single tracked variable wrapping aBigDecimal[]/BigInteger[].WrappedBigDecimals.extract(index, resultDescriptor)(orWrappedBigIntegers.extract(...)) reads a single element back out, by aWrappedIntegerindex, as its own tracked scalar.
// Inside the template: run the step's math to compute yNext/vNext, then pack both outputs
// into the single array the Subgraph's resultId points to -- this part is required, since
// a Subgraph can only return one result variable.
var yvNext = yNext.array(List.of(vNext), Descriptor.descriptor("y_v_next"));Packing the input the same way is optional — the template could just as well declare y and
v as two separate argument ids. It's often still worth doing when the subgraph drives a loop:
since each call's output is already a packed array, feeding that same packed value straight
back in as the next call's single argument avoids an unpack/repack round-trip between
iterations:
var yv = ctx.wrapBigDecimals(new BigDecimal[]{initialY, initialV}, Descriptor.descriptor("y_v_0"));
for (long i = 0; i < steps; i++) {
// yv is already shaped as a single packed argument, so it feeds directly into the next call.
yv = (WrappedBigDecimals) stepSubgraph.execute(List.of(yv), Descriptor.descriptor("step_" + i));
}See the full runnable version — an atmospheric drag-descent simulation carrying altitude and
velocity across every folded integration step — in
io.compprov.examples.physics.AtmosphericDragDescentStress.
WrappedSubgraph exposes two ways to invoke the folded template, trading memory for
parallelism:
execute(args, resultDescriptor)replays the template against a singleMutableStatethat's allocated once, when theSubgraphis built, and reused for every call. Concurrent calls are safe — they synchronize on that shared state — but fully serialized, so calling it from multiple threads gives no speedup. This is the cheapest option and the right default for a single-threaded driving loop, like the Pi example above.executeConcurrent(args, resultDescriptor)allocates a freshMutableState— a full copy of the template's intermediate variables — for every call, so independent invocations never contend on shared state and can run truly in parallel. The tradeoff is one extra allocation-and-copy per call. Seeio.compprov.examples.pi.MonteCarloArcsineIntegrationStressParallelfor a driving loop that submits samples to anExecutorServiceand callsexecuteConcurrent()from multiple threads.
Folding means execute() / executeConcurrent() deliberately don't record the template's
internal steps in the outer CPG — that's the point. When you need to inspect one specific call
in full detail (debugging, audit, a spot-check on a suspicious result), reconstruct it as its
own independent CPG using extractSubgraph(), copyWith, and compute:
// 1. Export the template as a standalone snapshot: its full operation chain (xSquared,
// ySquared, dist, distRound, inCircle) with the placeholder values it was built with.
Snapshot templateSnapshot = piStep.extractSubgraph();
// 2. Substitute the template's INPUT variables with the actual arguments from the call you
// want to inspect — here, the x_42/y_42 point from the loop above.
Snapshot callSnapshot = env.copyWith(
templateSnapshot,
Descriptor.descriptor("Pi step replay for point 42"),
Map.of(
x.getVariableTrack().getId(), new ValueWithDescriptor(
Descriptor.descriptor("x"), xi.getValue()),
y.getVariableTrack().getId(), new ValueWithDescriptor(
Descriptor.descriptor("y"), yi.getValue())));
// 3. Replay it. This produces a fresh ComputationContext with every intermediate variable
// tracked, exactly as if folding had never happened for this one call.
ComputationContext replay = env.compute(callSnapshot);
System.out.println(env.toHumanReadableLog(replay.snapshot()));x and y here are the same template-context variables used to build the Subgraph's
argumentIds — copyWith only accepts substitutions for INPUT-kind variables, which is
exactly what those are.
// Multiple threads calling the SAME WrappedSubgraph instance concurrently:
IntStream.range(0, totalPoints).parallel().forEach(i -> {
var xi = ctx.wrapBigDecimal(BigDecimal.valueOf(random.nextDouble()), Descriptor.descriptor("x_" + i));
var yi = ctx.wrapBigDecimal(BigDecimal.valueOf(random.nextDouble()), Descriptor.descriptor("y_" + i));
piStep.executeConcurrent(List.of(xi, yi), Descriptor.descriptor("inCircle_" + i));
});ctx.wrapBigDecimal(...) and ctx.wrapSubgraph(...) are themselves thread-safe (see
Thread safety), so the outer driving context needs no extra synchronization
either way — the execute vs. executeConcurrent choice only affects the subgraph replay itself.
Adding support for a type not built into the framework requires three things:
- A
Wrapped<Type>class that defines the tracked operations for your type. - A
VariableWrapper<Type>factory that instantiates it. - Registering the factory with the environment.
Below, Amount is a currency-aware value type (a Currency plus a BigDecimal) from a
net-asset-value example: add() requires both amounts to share a currency, and convert()
applies an FX Rate. Only add and convert are shown here — a real wrapper can expose as
many operations as the underlying type needs (see WrappedAmount for the full version,
including a variadic addBulk).
import io.compprov.core.ComputationContext;
import io.compprov.core.meta.Descriptor;
import io.compprov.core.variable.AbstractWrappedVariable;
import io.compprov.core.variable.VariableTrack;
import io.compprov.examples.nav.model.Amount;
import io.compprov.examples.nav.model.Rate;
import java.util.*;
import java.util.function.Function;
import static io.compprov.core.meta.Meta.formula;
public class WrappedAmount extends AbstractWrappedVariable<Amount> {
// Define one Descriptor constant per operation; a formula makes the audit trail readable.
private static final Descriptor OP_ADD = Descriptor.descriptor("add", formula("a+b"));
private static final Descriptor OP_CONVERT = Descriptor.descriptor("convert", formula("convert(a,r)"));
// Map each Descriptor to a lambda that performs the actual computation.
private static final Map<Descriptor, Function<List<Object>, Object>> FUNCTIONS;
static {
Map<Descriptor, Function<List<Object>, Object>> m = new HashMap<>();
m.put(OP_ADD, args -> {
Amount a = (Amount) args.get(0);
Amount b = (Amount) args.get(1);
return a.add(b);
});
m.put(OP_CONVERT, args -> {
Amount a = (Amount) args.get(0);
Rate r = (Rate) args.get(1);
return a.convert(r);
});
FUNCTIONS = Collections.unmodifiableMap(m);
}
public WrappedAmount(ComputationContext context, VariableTrack track, Amount value) {
super(context, track, value);
}
@Override
public Function<List<Object>, Object> getFunction(Descriptor operationDescriptor) {
return FUNCTIONS.get(operationDescriptor);
}
// --- Public API ---
// Each operation comes in two overloads: with and without a result Descriptor.
public WrappedAmount add(WrappedAmount augend, Descriptor resultDescriptor) {
Objects.requireNonNull(augend, "augend");
return (WrappedAmount) execute(OP_ADD, "a", this, "b", augend, resultDescriptor);
}
public WrappedAmount add(WrappedAmount augend) {
return add(augend, null);
}
// Note the mixed argument type: an Amount converted by a Rate — the framework tracks
// both as separate input variables, regardless of their concrete wrapped type.
public WrappedAmount convert(WrappedRate rate, Descriptor resultDescriptor) {
Objects.requireNonNull(rate, "rate");
return (WrappedAmount) execute(OP_CONVERT, "a", this, "r", rate, resultDescriptor);
}
public WrappedAmount convert(WrappedRate rate) {
return convert(rate, null);
}
}import io.compprov.core.ComputationContext;
import io.compprov.core.variable.VariableTrack;
import io.compprov.core.variable.VariableWrapper;
import io.compprov.core.variable.WrappedVariable;
import io.compprov.examples.nav.model.Amount;
public class AmountWrapper implements VariableWrapper<Amount> {
@Override
public WrappedVariable wrap(ComputationContext context, VariableTrack track, Amount value) {
return new WrappedAmount(context, track, value);
}
}Rate is wrapped the same way (see WrappedRate / RateWrapper), so both sides of the
conversion end up as tracked variables in the CPG.
var env = DefaultComputationEnvironment.create();
env.registerWrapper(Amount.class, new AmountWrapper());
env.registerWrapper(Rate.class, new RateWrapper());
var ctx = new DefaultComputationContext(env,
new DataContext(Descriptor.descriptor("fx-conversion")));
// Use the base wrap() method — cast to your concrete type after wrapping.
var btcBalance = (WrappedAmount) ctx.wrap(
new Amount(Currency.BTC, new BigDecimal("1.5")), Descriptor.descriptor("BTC balance"));
var btcUsdRate = (WrappedRate) ctx.wrap(
new Rate(Currency.BTC, Currency.USD, new BigDecimal("65000.00")), Descriptor.descriptor("BTC/USD rate"));
var usdBalance = btcBalance.convert(btcUsdRate, Descriptor.descriptor("BTC->USD"));If you use a custom type frequently, extend DefaultComputationContext to add a typed
wrap(Amount, Descriptor) overload, the same way NavComputationContext does — or the way
DefaultComputationContext does for wrapBigDecimal, wrapBigInteger, etc.
If custom type requires custom JSON serialization or deserialization, register a
Jackson serializer/deserializer with the ObjectMapper inside your custom ComputationEnvironment.
compprov-core runs on Jackson 3.x (tools.jackson.*), so custom deserializers implement
tools.jackson.databind.deser.std.StdDeserializer / ValueDeserializer, not the Jackson 2.x
com.fasterxml.jackson.databind.JsonDeserializer.
See DefaultComputationEnvironment for examples using ZonedDateTimeSerializer and
MathContextDeserializer, and AmountDeserializer for a custom-type example registered via
environment.registerWrapper(Amount.class, new AmountWrapper(), new AmountDeserializer()).
DefaultComputationEnvironment registers the following wrappers out of the box:
| Java type | Wrapped class | Notes |
|---|---|---|
BigDecimal |
WrappedBigDecimal |
Full arithmetic: add, subtract, multiply, divide, pow, sqrt, abs, negate, remainder, max, min, and more |
BigInteger |
WrappedBigInteger |
Full arithmetic including modPow (ternary) |
BigDecimal[] |
WrappedBigDecimals |
Tracked array; extract(index, ...) reads one element out. Built via WrappedBigDecimal.array(...) — see Returning multiple values from a subgraph |
BigInteger[] |
WrappedBigIntegers |
Tracked array; extract(index, ...) reads one element out. Built via WrappedBigInteger.array(...) |
Integer |
WrappedInteger |
Parameter-only type; used as an argument to pow, scaleByPowerOfTen, etc. |
Long |
WrappedLong |
Parameter-only type |
MathContext |
WrappedMathContext |
Carries precision and rounding mode; passed to most BigDecimal / BigInteger ops |
ComputationEnvironment and its wrappers map are fully thread-safe — a single instance can
be shared across threads and computations.
ComputationContext is thread-safe for all wrap and executeOperation calls. The snapshot()
method is not safe to call while other threads are still recording operations into the same
context.
WrappedSubgraph.execute() and executeConcurrent() are both safe to call concurrently on the
same instance, but with different tradeoffs — see Concurrency under Subgraph
folding.
To visualize your CPG data use compprov-render — a set of HTML pages that run locally in your web browser, no server required.
Simply export a snapshot to JSON and open the page:
String json = env.toJson(ctx.snapshot());
// save to a file, then open graph.html or plot.html in your browserRenders the full CPG as an interactive node-edge graph. Variables are shown as typed nodes (input / output), operations as diamond nodes with labeled argument edges.
Plots numeric variable values across one or more datasets side-by-side. Supports points, line, and table views with configurable X-axis labels.
The io.compprov.examples package contains three self-contained examples that each demonstrate
a different aspect of the framework.
| Example | Package | Domain | Key technique |
|---|---|---|---|
| Net Asset Value (NAV) | io.compprov.examples.nav |
Crypto-portfolio accounting | Custom domain type wrappers |
| Gauge Block Calibration | io.compprov.examples.gaugeblock |
Precision length metrology | Pure BigDecimal scalar formula chain |
| Hydrological Model Evaluation | io.compprov.examples.hydrology |
River discharge modelling | List-based tracked operations |
io.compprov.examples.nav · NetAssetValueCalculator.calculate()
Computes the total USD value of a multi-asset crypto portfolio (BTC, ETH, USDC positions held across Binance, staking, and Morpho DeFi) by converting each position to USD at a spot rate and summing the results.
The primary focus is showing how to wrap custom domain types. The domain model uses Amount
and Rate objects rather than raw BigDecimal, and the example integrates them with the
framework without modifying them — using the three-step pattern:
WrappedAmount/WrappedRateextendAbstractWrappedVariable<T>and declare their operations (add,convert,addBulk) asDescriptorconstants mapped to computation lambdas.AmountWrapper/RateWrapperimplementVariableWrapper<T>— the one-method factory the framework calls to instantiate tracked variables.NavComputationContextextendsDefaultComputationContext, registers both wrappers with the sharedComputationEnvironment, and exposes typedwrap(Amount, ...)/wrap(Rate, ...)convenience overloads.
After the calculation the snapshot is serialized to JSON, then deserialized and replayed via
NavComputationContext.environment.compute() — verifying that the CPG is round-trip stable and the
replayed output matches the original result.
io.compprov.examples.gaugeblock · GaugeBlockCalibration.calibrate()
Reproduces the interferometric calibration of a 7 mm tungsten carbide gauge block (NRC 91A) from the following paper, which uses this measurement as a demonstration of metrological provenance management:
Ryan M. White, Provenance in the Context of Metrological Traceability, Metrology 2025, 5(3), 52. DOI: 10.3390/metrology5030052
The computation chain has three stages, all tracked in the CPG:
- Refractive index — the Birch–Downs modified Ciddor equation (8 tracked steps) converts air temperature, pressure, relative humidity, CO₂ concentration, and saturation vapor pressure into the refractive index n of the measurement medium.
- Interferometric length — the HeNe laser vacuum wavelength (632.99 nm) divided by n
gives the air wavelength; the observed fringe order
m + fgives the raw lengthL_raw = (m + f) × λ_air / 2. - Thermal correction — the raw length is corrected to the ISO 1 reference temperature
(20 °C) using the tungsten carbide expansion coefficient α = 4.23 × 10⁻⁶ K⁻¹ from
the paper:
L_cal = L_raw / (1 + α × ΔT).
The deviation from the 7 mm nominal length is asserted to round to +2 nm, matching the paper's reported result (expanded uncertainty U = 31 nm, k = 2).
This example uses only built-in WrappedBigDecimal arithmetic — no custom wrappers needed —
showing that the framework handles complex pure-scalar formula chains out of the box.
io.compprov.examples.hydrology · MhmDischargeEvaluation.evaluateParameterSetP1()
Evaluates the mesoscale Hydrologic Model (mHM) output against observed river discharge at the Moselle River basin upstream of Perl (~11 500 km², Luxembourg/Germany), as described in:
Villamar et al., Archivist: a metadata management tool for facilitating FAIR research, Scientific Data, 2025. DOI: 10.1038/s41597-025-04521-6
The metric is the Kling-Gupta Efficiency (KGE) (Gupta et al. 2009):
KGE = 1 − √[ (r−1)² + (α−1)² + (β−1)² ]
r = Pearson correlation = Σ(devObs · devSim) / √(Σ devObs² · Σ devSim²)
α = variability ratio = σ_sim / σ_obs
β = bias ratio = μ_sim / μ_obs
KGE = 1 is perfect; values below 0 indicate the model is worse than the observed mean as a predictor. The paper reports that parameter set P₁ outperforms P₂ with scores mostly above 0.5.
The computation uses ArrayList<WrappedBigDecimal> with loops and addBulk, demonstrating the
pattern for list-based tracked operations where the number of time steps is dynamic.
The 8-step chain (means → deviations → squared deviations and cross products → sums → r → α
→ β → KGE) is fully recorded in the CPG, with every intermediate quantity named and traceable.
The synthetic dataset is engineered so that r = 1, β = 1, α = 0.9, giving KGE = 0.9 exactly,
verified by exact BigDecimal equality.
Apache License 2.0 — see LICENSE.

