Exact Numbers and Economic Change

Table of Contents

1. Summary

A money quantity is held exactly: an exact decimal in the domain type and in the column, and the two agree. A continuous quantity stays binary floating point. The two never mix, and the conversion between them runs one way, at the numerical kernel seam.

The ORE boundary keeps the rule. The document states an amount as text. The generated codec table holds a money member as an exact decimal parsed from those digits, and no float sits between the text and the decimal.

An economic change is detected by a digest over the fields the model declares economic, folded from every component up to the trade. That fold is what moves the trade's external version, and it is what makes the null-amend rule in Trade Versioning enforceable.

2. The numeric rule

The Regime 2 checklist of doc/knowledge/architecture/data_oriented_design.org is where this rule is enforced: the system of record holds what the document stated.

Class Examples Held as
Money rate, spread, notional, amount, price, strike, fee, premium, margin, face value exact decimal
Continuous volatility, correlation, weight, probability, recovery rate, tranche fraction, day-count fraction, unit quantity binary float

Conversion runs from decimal to double at the numerical kernel seam, and only there. A double never enters the persisted domain.

ores::utility::decimal::decimal wraps boost::multiprecision::cpp_dec_float_50 and offers parse, format and compare, with no arithmetic. That is not a gap: it is the boundary stating itself. A caller that must compute converts to double and computes in Regime 1.

The criteria this rule adds are in the Regime 2 checklist of doc/knowledge/architecture/data_oriented_design.org: Exact money at rest and One-way numeric conversion.

The rule is also enforced by the build, not only by review. build/scripts/check_money_decimal_columns.py runs as the money-decimal-columns check from build/scripts/local_checks.py. It reads the models, classifies each column by name, and checks both halves of the rule: a money column fails when its SQL type is floating point, when its SQL type is text, or when its SQL type is exact with a double in the domain; a continuous column fails when its SQL type is exact. A column is money only when the money word is the head noun of its name — premium_amount is money, premium_date and price_type are not — and a trailing qualifier sits after the quantity without replacing it, so face_value is money. A column the name rule cannot tell from money, because the model states it is a quote, an identity or a code, is recorded in the money baseline with its reason in that column's prose. Each direction has a baseline of the offenders that existed when the rule was written, so the debt is visible and cannot grow. The baselines are the authoritative inventory for the work that pays it down; do not restate them in prose, because a second copy drifts and it is the copy that goes stale.

3. How the ORE boundary keeps the amount

ORE types the same money concept as xs:float, xs:double and xs:string in different places, so the schema cannot say which numbers are money. The generator takes the shape of each record from the schema and the kind of each number from this project's money vocabulary: the --exact-decimal list in projects/ores.ore/scripts/regenerate_xml_bindings.sh. A member on the list, or every number of a family when the list holds *, is held as an exact decimal. A number that is not on the list stays a binary float, which is right for a continuous quantity.

The reader parses the digits of the element straight into the decimal, so the text the document wrote is the value the record holds. A projection reads it with record_reader::decimal for an element, or with record_reader::own_decimal for a type that carries its number as its own text. It writes it with record_writer::set_decimal.

The writer formats a decimal as fixed point from those digits. The output still validates against input.xsd, because a decimal literal is a valid xs:float literal. The round trip harness reads each corpus document, writes it, validates it, reads it again and compares every field including the exact decimals.

A new money member needs three steps:

  1. Name the member in the --exact-decimal list.
  2. Regenerate the tables with regenerate_xml_bindings.sh.
  3. Add a mapper test that holds the digits of the member.

The drift check build/scripts/check_xml_bindings_drift.py fails when a committed table and the generator disagree, so a table never carries a hand edit.

What the digest sees follows from this. It is computed over the stored decimal, so two documents that differ in a digit a float cannot hold are distinguished.

4. Detecting an economic change

Trade Versioning classifies an amendment on four orthogonal axes and notes that classification is "a lookup on the reason rather than a field-by-field diff of the trade". A reason states what the user intended to change; it cannot state whether the value actually changed. The same page then requires that a null amend "must not move the external version", which a reason lookup cannot guarantee.

A digest over the instrument's economic fields supplies the missing diff. It is computed where the whole shape exists at once — the ORE import client, which issues every child write for one instrument, or the composite reader that reassembles it. It is stored as plain text, following the sha256 precedent on ores.compute.app_version_platform_junction.

The digest covers declared economic fields only. A digest over the whole shape would fire on a description or a comment and move the external version for a change the customer never agreed to, which is the failure the two-version rule exists to prevent. Which fields are economic is therefore a model declaration, not a code decision.

4.1. A field name is part of the canonical form

canonical_struct writes each included field's name before its value, as name=value;, so two fields that hold the same value do not cancel and a value cannot be read as a neighbour's. That is the safe direction, and it has a consequence worth stating: renaming a C++ member changes every digest that covers it. The next write of each affected trade would then bump its external version for a customer-facing change that never happened, spread over whatever time those writes take.

A rename is rare and is a schema change that needs a migration anyway, so this is a property to know rather than a defect to fix. The two ways to answer it if it ever bites: drop the name from the canonical form, which stays unambiguous because the fields are framed and ordered, or migrate the stored digests in the same change as the rename.

Two placements were weighed. A column on each instrument table is atomic with its row but cannot be computed by a trigger, because children arrive in later transactions and a parent trigger cannot see them. A side table generic to every family costs a lifecycle but needs no per-family change and covers the families that instrument_batch omits. The side table is the choice for the generic capability; a column on bond_issue carries the bond-identity case that prompted it.

The digest does not need the code generation extended to iterate a type's fields. Compile-time reflection walked a value generically at the time this was written. So a digest can be written as one function that walks the fields, skips the ones named identity or audit, renders each remaining field canonically and hashes the result — no per-family code, and no generator change.

The first version decided inclusion by name, which a name cannot do well: it can state what a field is called, not what it means. The declaration below replaced that rule, so the digest decides nothing by a field's name.

4.2. What was built

projects/ores.trading.api/domain/economic_digest.hpp holds economic_digest(const T&) for any reflected domain type: it walks the fields with reflection, skips the excluded ones at compile time through field_type::name(), renders each remaining field into a canonical text and returns sha256::hex_digest of it. Values are length-prefixed, so no value can run into the next and no two different values frame to the same text.

A field takes part when its model says so. The digest reads the type's economic_fields_of declaration, which codegen writes from the model, and renders only the members that list names. The name list it replaced is gone.

4.3. What was built, part two: the trade's digest

trade_economic_digest(trade, component_digests) composes the trade's own digest with one digest per component. Every value in the stream is framed by length — the trade digest, the component count, and each component digest — and the component list is wrapped in braces, so the list's boundary is stated rather than inferred from the count. Framing the count is the point: an unframed count is terminated only by the next length prefix, which makes the stream's safety depend on the digest being a fixed 64 characters, and the framing exists precisely so that nothing depends on that.

Nine cases, database-free: stable; moves when a component moves; moves when components are reordered; moves when one is added or removed; an empty list differs from a one-element list; the trade's audit tail is ignored; a trade classification moves it; and it does not collide on ambiguous concatenation.

4.4. The limit, in one example

security_id is the ISIN: external identity, and economic. issue_id is a surrogate key: identity, and not. Both end in _id, so no suffix rule could separate them and the allow-list was a list of exceptions rather than a rule. That was the concrete case for declaring the fields instead of naming them.

The suite that proves it uses the real bond_issue: changing security_id moves the digest, changing issue_id does not. It passes unchanged under the declaration, where security_id says :economic: true and issue_id says :economic: false.

4.5. Why the test is believable

Nine cases, none touching a database. The load-bearing one asserts that changing modified_by, version or tenant_id leaves the digest alone, because a digest that moved on audit noise would move the customer's version for no reason. It was proved by mutation rather than asserted: removing modified_by from the exclusion list makes the case fail with two different digests, and restoring it makes the suite green. A test that has never been seen to fail is a test that may not be testing anything.

4.6. Declaring the economic fields

A column states :economic: true or :economic: false in its model drawer. Codegen writes the answer into the domain header beside the struct:

inline constexpr std::string_view vanilla_swap_instrument_economic_fields[] = {
    "settlement_lag",
};

constexpr std::span<const std::string_view>
economic_fields_of(std::type_identity<vanilla_swap_instrument>) {
    return vanilla_swap_instrument_economic_fields;
}

The overload takes the type and not a value. The digest asks it at compile time, so a member that is not economic is never rendered, and a type such as tenant_id that has no canonical form costs nothing. A struct without the overload cannot be digested: canonical_struct stops the build and says which model to declare.

Four rules keep the declaration honest.

  • A struct declares all of its members or none of them. Codegen refuses a model that declares some columns and omits others, so a field added later cannot be economic or not by default.
  • A column that reaches no flat member, such as one carried by the identity group, must not declare. The group's own model declares it.
  • The members the template writes itself, such as version, tenant_id and the audit tail, are never listed, so they are never economic.
  • A member holding a declared struct of its own, such as an identity or audit group, takes part only when that struct names an economic field.

The declaration was written once from the old rule, by scripts/declare_economic_fields.py, and checked against the old rule on 35 types before the rule was deleted, and on all 51 declared types afterwards by evidence/economic_declaration_parity.py in the task's folder. After that the models own the answer. The script is kept because a model that gains columns can be declared the same way, but it only proposes: what a field means is the model author's call.

4.6.1. The components of a shape

The trade-type catalogue's * Digest section declares which rows make up an instrument's economic shape. Instrument components lists them per routed instrument table, and Shared components lists the rows every instrument shares, with the function that reads each by trade id. Codegen refuses a routed instrument that has no entry, an entry for one that is not routed, and a component whose model declares no economic fields.

The reader is generated, instrument_economic_shape.hpp, and read_instrument_digests calls it instead of keeping 22 hand-written cases. The generated switch has no default, so a table the catalogue routes and does not list fails the build.

Declaring the shape made one gap visible. The option's exercise prices are an economic table that the digest has never read. They are not listed, because listing them changes what the digest covers.

5. Propagating it up to the trade

The skeleton already exists. The trade's seven children carry :parent_entity: trade and :bump_parent_version: true, added so that a component write moves the trade, and ores_trading_structure_version_fn folds a structure's legs the same way. That moves the internal version, which must move on every recorded change.

The external version moves on a different condition: the economic digest changed. So a component write recomputes the parent's digest and bumps the external version only when it differs. Same fold, conditional outcome.

5.1. What the trade's digest is made of

It is not a digest of the trade row alone, because a trade's economics are mostly in its instruments. It is a digest of the trade's own economic fields together with every component's digest, folded in a fixed order and framed so that no component can be confused for another or for a field.

It is stored, not derived on read. The comparison that decides whether to bump needs the trade's previous digest; a value computed on demand cannot say what it used to be. That is why economic_digest is a column and not a function like ores_trading_structure_version_fn, which can be a read because a version is only ever observed, never compared to its own past.

The cost is that the stored digest can go stale if a component is written without the fold running. The fold therefore belongs in the same transaction as the component write, not in a later sweep — the same reason :bump_parent_version: is set on the children rather than reconciled afterwards.

Computing the new digest needs the whole shape, and a parent's insert trigger cannot see children that arrive later. So the fold runs where the shape is known: after a component write, read the trade's components back and fold them. That is the same read the composite readers will need, which is why the generic case is gated on them.

5.2. The economic component set is not the internal-version component set

This is the mistake the first implementation made, and it is easy to make because the two sets look alike. The internal version already folds a set of children: the seven that carry :parent_entity: trade and :bump_parent_version: true — state, booking, identifier, additional field, portfolio, party role and structure member. They are the right set for a version that must move on every recorded change. They are the wrong set for a digest that must move only when the customer's terms change, in both directions:

  • Instruments are missing from it. A trade's economics are mostly in its instruments, and no instrument carries the parent_entity knob, so folding the seven moves the external version for a state change and leaves it still for a strike change. This is the failure that matters most: it is exactly backwards.
  • Comments and lifecycle are in it. An additional field is free text and a state or booking is lifecycle or organisational, so folding them moves the external version for a change the customer never agreed to.

The rule to apply is the same one the digest already applies per field, raised to components: a component drives the digest when its fields state terms the customer agreed to. That selects the instruments and their legs and amounts, and excludes the seven.

5.3. Once per operation, not once per row

One import is one customer-facing event. A fold that runs after each component row increments the external version once per row, so a trade with seven children lands at version eight and a customer whose records say one is out of step from the first import. The digest must be refreshed once per outer operation — at the end of the import or amend unit — not per row. This is a reason to prefer the composite writer over a per-entity observer, or to coalesce the refreshes inside the transaction.

There is no transaction boundary to discover. Measured, not assumed: the importer writes each component as its own request and each request is handled in its own transaction. `ore_import_execute_handler.cpp` calls `nats_call(nats, <put_..._request>, ...)` once per component — =`put_trade_identifier_request, `delete_many_trade_bookings_request=, and the per-family instrument requests — and `ores.ore.service` does not link =`ores.trading.core` at all, so the fold cannot be called from the importer without a new dependency. An observer inside the trading service therefore cannot see the operation; it sees a message. The boundary has to be expressed, by a batch request carrying the whole shape or by an explicit finalise step, rather than found.

The digest is necessary but not sufficient. "Digest differs → bump" is the right test for a null amend and the wrong test for a first import, because during an import the digest differs at every step and none of those steps is an agreement. What the versioning rule actually says is narrower than "the economics changed": the terms the customer has agreed changed. A trade still being imported has agreed nothing, which is why an unauthorised amendment "changes nothing but the version" while an authorised one returns the trade to unauthorised. So the condition is not one comparison but two — the digest differs, and the change crosses a customer-visible boundary — and the second half is what the authorisation state supplies. Whatever mechanism carries the fold must be able to ask that question, or the external version will count writes instead of agreements.

The interim rule, which is what shipped. Every component row carries an audit tail, including `change_reason_code`, and the ORE importer writes `system.external_data_import` on everything it creates while an amendment carries a user reason. So "refresh the digest always, and bump only when the reason is not a system one" delivers the objective's behaviour — an instrument change moves the external version, an import does not — with no new operation and no protocol change.

The irony is worth stating rather than hiding: this page argues that a reason cannot say whether a value changed, which is why the digest exists, and the interim rule then uses a reason to say whether a change was an import. Those are different questions, and a reason can answer the second one honestly even though it cannot answer the first. The weakness is that it trusts the writer to label its own writes, so a tool that amends a trade with a system reason would move no version, silently. It shipped as the interim rule and the shape-carrying request below remains the stronger design, because it does not depend on anyone's self-description.

The boundary is a request that carries the shape, and the idiom exists. `book_trade_request` is not it: it carries the anchor, the booking and the activity, and no instrument. But `trading.v1.ops.put_composite_instrument_with_legs` is exactly the right shape — one request that writes an instrument together with its legs, so one message is one instrument's worth of economics. The follow-up should extend that idiom to the whole trade shape rather than invent a new one, and the refresh belongs at the end of that handler: recompute the digest, and if it differs from the stored one, store it and bump. Then an import's per-component writes only keep the digest fresh, and the shape-carrying request is what counts as an agreement.

5.4. Where the fold runs, and why not in SQL

An earlier note on this page put the fold in a paste-block trigger. That does not work, and the reason matters: canonicalising a value is C++. A decimal renders through to_string(), a double through a fixed format, an optional through a distinction between absent and empty, and every value is framed by length. SQL cannot call that code. A trigger could hash the raw column values with a SQL expression of its own, but that is a second canonicalisation, which would disagree with the C++ one on exactly the things this page has spent its length fixing, and two implementations of one digest means two digests.

So the fold is C++, in the trading service, and every writer of a component invokes it. The alternative is to give each component table a digest column of its own, so the fold has nothing to canonicalise and a trigger can simply concatenate stored digests in a fixed order. Two designs, and the choice is not obvious:

  Fold in C++ Digest column per component
Cost none beyond the service a digest column on every instrument table
Staleness a writer that bypasses the service leaves it stale the fold is at the database, so every writer gets it
Canonicalisation one implementation one implementation, in C++, at write time

The C++ fold is smaller and keeps one implementation of the rule. The column per component is more robust, because it cannot be bypassed. This page does not choose between them yet; the trade digest and the external version both exist, so the decision is the next thing to make and not something to discover halfway through the trigger.

5.5. A third option, and the evidence for it

Every write already passes one place. ores.database/query_builder/execute.hpp exposes execute_write_query(ctx, entity, lg, desc), which forwards to execute_write_op and query_builder::insert. It is the single choke point: one function, and no repository writes without it. Re-measured on this tree: grep -rl 'execute_write_query(' projects --include=*.cpp | wc -l gives 358 files, against the 359 recorded when this page was written — the count is of files, one call per repository, not of occurrences.

It is templated on the entity type and knows nothing about what an entity means, so a trading fold cannot live inside it. But the template is the hook: a trait the trading component specialises, asked by an if constexpr in execute_write_op, keeps the mechanism generic and the policy in trading.

That gives the automatic behaviour of the per-component column — no writer can forget the fold, and there are no 30 call sites to keep in step — while keeping canonicalisation in one C++ implementation. It costs a change to a helper that every repository shares, and the write of a child then reads and writes its parent inside the same transaction, so it is not free.

Of the three, this is the one to try first: it is the only one that is both unbypassable and single-implementation. The per-component column remains the fallback if touching the shared helper proves too broad, and the plain C++ fold if the fold turns out not to need to be automatic at all.

5.6. The trait has to be visible where the template is instantiated

Measured, and it decides where the trading specialisation can live. The instantiation point is each generated `*_repository.cpp`, and the only hand-written trading header those files reach is `ores.trading.core/export.hpp` — they include their own generated repository header, which includes =export.hpp. A trait specialisation declared anywhere else is not visible at that point, and if constexpr then silently keeps the primary template, so the fold never runs and nothing complains. The specialisations therefore go into a hand-written trade_write_observation.hpp that export.hpp includes after the export macro, which needs no codegen at all.

This is worth stating because the failure mode is silence. A trait-hook design that compiles, passes its own unit test and does nothing in production is the shape to watch for here, and the only proof is a DB-backed test that writes a child and reads the parent's digest back.

5.7. The stored digest is not customer-facing until the component set is settled

A digest stored under a provisional definition is worse than no digest once anything acts on it. The first implementation folds the seven internal-version children and no instrument, so the value changes on a comment and holds still on a strike. While nothing moves the external version that is harmless. The day the authorisation-gated bump lands, the change that fixes the component set becomes indistinguishable from an economic change, and every trade bumps on its next write for a migration the customer never saw.

So the ordering is fixed: settle the component set, then reset the stored digests in the same change, and only then enable the bump. Until then the column carries a value that is explicitly provisional.

6. The scale, and what the tolerance hides

The corpus states up to 12 decimal places and 14 significant digits, against the numeric(28, 10) every decimal column carried when this was measured. The columns are numeric(38, 12) now, and the reason the first attempt at widening was to numeric(28, 12) and was wrong is in the task that paid the debt down: widening the scale without widening the precision narrows the integer part. Measured over the 132 example documents:

Decimal places 12 11 10 9 8
Values 2 196 139 54 23

The long spellings are not extra precision. They are ORE's own float round-trip spellings — 0.00650000013, 0.00999999978, 0.00120000006, 0.00499999989 — the shortest decimal that reads back as the same float. The rate elements are typed xs:float (instruments.xsd:542), so this is what the document means.

numeric(28, 10) rounds those spellings. 0.00999999978 is stored as 0.0099999998, and the round-trip classifier then files the difference under numeric and reports the document as round-tripping with nothing lost. So the gate's clean result on the bond corpus is partly tolerance and not fidelity, in the same family as a leg silently not written.

Decision. The scale is numeric(28, 12): it holds every spelling the corpus states, and still 16 integer digits. The codec holds the digits of the document as a decimal, so these values round trip as text and the classifier's numeric bucket is zero.

6.1. The classifier cannot see this loss, and a clean round trip is not the test

That acceptance test is wrong, and the reason is worth recording. Measured on a recreated database with the bond corpus:

   
source `<Spread>0.00999999978</Spread>`
output `<Spread>0.00999999978</Spread>`
stored `0.0099999998`

The database holds a value the document does not state, and the classifier reports numeric 0, lost 0, unexplained 0. The rounded digits are restored on the way out: the export renders the stored decimal through ORE's `float`, and a float's shortest round-tripping spelling of that number is `0.00999999978` — the very text that came in. The loss in and the recovery out cancel, so the gate sees an exact match.

So a clean round trip proves nothing about precision here, and any test of the scale change has to compare against the column, not against the exported document. The corpus's eleven- and twelve-place values are real and one of them is in `Cash_Bonds.xml`; the bond round trip has been passing over a truncated spread for the whole of this work.

7. What verifying one of these costs

Any change here changes the generated SQL, and the service refuses to start against a database built from different SQL. So the build proves the types compile and nothing more: the test of a column is a recreate, a re-provision and a run, which is around twenty minutes of environment.

That is why the remaining money columns are changed as one batch and verified together, rather than column by column. It is also why the digest work is sequenced after them: each unit of it carries the same cost.

8. Where the external version lives

It lives on the trade. That is recorded, not assumed.

Read a structure's version from its legs says "the external version stays on the agreement, which the authorisation and agreement work owns". That is an intention, not a built thing: external_version appears in exactly one model, ores.trading.trade, and it is the one this work added. The only agreement entities are ores.dq.netting_agreement and ores.refdata.netting_agreement — netting agreements, not trade agreements — and neither carries it.

So the trade is the first home for the external version, not a second one beside an existing column. If the authorisation and agreement work later introduces an agreement version, the trade's is the fold of it rather than a rival, and that is the point at which to revisit this.

9. See also

Emacs 29.3 (Org mode 9.6.15)