The examples on this page use Northpine, a fictional outdoor-gear shop whose
refund-exception decision grants, escalates or denies refund requests outside its standard return policy. How Moonbounce works introduces it.version carries two hashes: structure and weights. This page explains what each one covers, what changes it, how a structure gets registered, and how one decision file in your repository keeps the features, the weights and the cut points in step.
Two hashes, not a version number
A decision has no “v3”. Its version is the pair of hashes of what ran: the structure hash covers the features and how they combine, and the weights hash covers the numbers that combine them. People shorten them to their first hex digits, so Northpine’s current version is “structure c219, weights d134”. Hashes come from content, so nobody has to hand out numbers, and the same spec gets the same hash on your laptop, in CI and in production. Two evaluations with the same pair asked the same features and combined the answers the same way. What sits outside the pair, the cut points and the engine that answered, is recorded beside it on every evaluation.The structure hash
The structure hash covers what the engine is asked and how the answers become a score: each feature’sprompt, guidance, criteria and inputs, the spec’s guidance, each aggregator’s definition (its terms, their effect, prior, fixed numbers, a calibrate’s node), every node’s name, output and target.
It leaves out meta, which is display only, and everything outside the spec: the cut points, the weights, the engine settings and the decision_id. The same spec under two decision ids has the same hash, registered separately under each.
Before hashing, the spec is put in a canonical form: key order and formatting are dropped, combine terms and a feature’s inputs are sorted (their order means nothing), defaults written out are removed, whitespace around text is trimmed and text is Unicode-normalized (NFC).
What changes which hash
Each row below starts from Northpine’srefund-exception spec (structure c219…, default weights d134…). The weights column is the hash "weights": "default" resolves to; a pinned weights hash never changes by itself.
Three things never touch either hash: your cut points (
outcomes), the engine settings, and the decision_id.
The default weights hash changes only when the default numbers do: they are a mean over the combine’s terms, keyed by term name and signed by effect, so renaming, adding or removing a term, flipping its direction or changing the prior makes new defaults, while rewording a prompt does not.
Node hashes and renames
Each node also has its own hash, and the structure hash is built from them. A feature’s hash covers its definition and the spec’sguidance, but not its name. An aggregator’s hash covers its definition and the hashes of the nodes it reads, by name. The structure hash covers output, target, and every node’s name and hash.
So renaming outside_window to late_request keeps that feature’s own hash, and corrections people gave about that feature still apply to it under the new name (Feedback). The combine that reads it and the structure as a whole get new hashes, because they name it. Rewording the prompt is the opposite: the feature gets a new hash, so earlier corrections about the old wording don’t carry over. Editing the spec’s guidance changes every feature’s hash at once, since every prompt is asked with it.
Hash a spec without a key
POST /v1/specs/hash returns a spec’s hashes. It needs no API key and stores nothing, so CI can check a file before anything ships:
nodes maps each node to its own hash and default_weights is the hash "weights": "default" resolves to. An invalid spec is a 400 that names the field at fault.
Registering a structure
Moonbounce has to know a structure before you can call it by hash. There are two ways to register one, and both are safe to repeat.- Inline on evaluate. Send
specinPOST /v1/evaluate. The first time the API sees that structure under the decision, it registers it. Northpine sends the spec on every call, so a merged change goes live with the next call and nothing else has to happen. - Ahead of traffic.
POST /v1/decisions/{decision_id}/structureswith{"spec": {...}}(needsdecisions:develop). It answers201when this call registered the structure and200when it already existed; re-registering changes nothing except the storedmeta. The body says the same thing increated:
nodes is shortened here; it lists every node. The registration response is StructureRecord, without spec.) Once registered, evaluate can send "structure": "sha256:c219…" instead of the spec, which keeps request bodies small. A hash that was never registered for this decision is 404 VERSION_UNKNOWN. A registered structure belongs to your organization, so one registered with a test key can be called by hash with a production key.
GET /v1/decisions/{decision_id}/structures/{structure} returns StructureRead (the record with created: false and spec), or 404 VERSION_UNKNOWN. It accepts decisions:evaluate or decisions:develop. The spec is the first registered spec in the current vocabulary and authored node order. Older rows without authored-order storage fall back to the stored spec, whose key order may differ. Your repository still owns the version you deploy.
The weights hash
What weights are
Weights are the numbers inside the aggregators: each combine’s per-term weights and bias, and each calibrate node’s map from score to probability. The spec says which answers count and in which direction; the weights say how much. With the default weights, Northpine’sgrant_score weighs each of its five terms 0.2 (signed by effect) with a bias of 0.8, and grant has no map yet, so it passes the score through (From answers to an outcome).
"default" or a pinned hash
"weights": "default" means “the default numbers for whatever structure this is”. Change the spec and the defaults follow it, which is why the default weights hash moves in some rows of the table above.
A pinned weights hash names one exact weights document and keeps it fixed while the spec changes. Registering a structure stores its default weights, so you can pin d134… as soon as the spec is registered; a hash Moonbounce doesn’t hold for this decision is 404 WEIGHTS_UNKNOWN. The response says which you used:
version.weights_sourceis"default"or"pinned"today. Treat any other value as unknown rather than failing: more may be added.version.weights_fittedistrueonly when the weights were fit on feedback.
Weights don’t fit themselves
Moonbounce doesn’t fit weights to your feedback automatically today, so every evaluation reportsweights_fitted: false. What changes over time is the features: a suggestion rewrites or adds a feature. A suggestion keeps a "default" pin as "default". Under a pinned hash, a rewrite keeps the pin (weights are keyed by feature name, so they still fit) and a new feature comes with a new pin whose weights carry the old ones over and give the new term a weight.
When a pin and the spec disagree
A pin is fixed, so editing the spec under it can leave the two out of step. Here is what a caller meets, each case computed on ticket C withd134… pinned:
- Add a term (
carrier_delay): the call succeeds with warningWEIGHTS_PARTIAL. The new feature is asked and its answer appears innodes, but it has no weight, so it is observed and not scored until a pin covers it. Ticket C still scores 0.54. - Remove a term (
high_value): warningWEIGHTS_STALE_TERM. The pin’s weight for it scores nothing; the bias stays, so ticket C scores 0.546. - Rename a term: both warnings, since the old name is stale and the new one unweighted. Ticket C loses
outside_window’s −0.19 and jumps to 0.73, which isgrant. Watch for these warnings in CI. - Flip a term’s
effect, or rename the combine: 409WEIGHTS_INCOMPATIBLE, before any engine runs. A weight whose sign contradicts the term’s direction, or a combine the weights hold nothing for, is refused rather than guessed.
WEIGHTS_INCOMPATIBLE. With "weights": "default" none of this applies: the defaults always match the spec.
The decision file
Keep each decision in your repository asmoonbounce/<decision_id>.json, holding four keys: decision_id, spec, weights (the pin as you wrote it) and outcomes.
moonbounce/refund-exception.json
moonbounce/refund-exception.json
moonbounce/refund-exception.json
What counts as live
The live version of a decision is whatever your primary production traffic runs: the version inPOST /v1/evaluate calls made with a production key. Seeds, previews and test-key calls don’t count. Moonbounce has no pointer to flip; you ship a version by deploying code that sends it.
A gradual rollout is your own flag choosing between two versions, for example the current decision file and a candidate copy with a different spec or weights pin, kept outside moonbounce/ so the folder still holds one file per decision. Make the choice sticky on item_id, so one ticket always gets the same version and its evaluations stay comparable:
version, so you can compare them before moving the rest of the traffic.