Task: Carry one canonical form for each fact
Table of Contents
- 1. Goal
- 2. Status
- 3. Acceptance
- 4. Discovery
- 5. What is not an alias
- 6. Plan
- 7. Where the tables are read
- 8. Measurements
- 9. Notes
- 10. Test Scenarios
- 11. PRs
- 12. Review
- 13. Result
- 13.1. Slice 1: the set states the form a configuration holds
- 13.2. Slice 2: the guard stores the form
- 13.3. Slice 3, part one: the check
- 13.4. Slice 3, part two is a decision, not a patch
- 13.5. What slice 2 changed for the database round trips
- 13.6. What making the set the only statement cost, and found
- 13.7. The form a configuration holds is a form a document may write
- 13.8. One authority over a day counter column
- 13.9. What the next slice needs
- 13.10. The renderings file states the tables the reader converts with
- 13.11. The two copies the story left behind
This page documents a task in the Normalise every ORE parameter representation — the remaining work story. It captures the goal, current status, acceptance, and any notes or results.
1. Goal
One fact is held once. A value set states the canonical form of each of its
values and every other spelling ORE accepts for it, so a screen offers the
canonical forms, a writer may send any spelling the set knows, and the store ends
up holding the canonical one. The compiled tables in
projects/ores.ore/core/src/domain/conventions_fields.cpp stop being the place
that knowledge lives.
2. Status
| Field | Value |
|---|---|
| State | BACKLOG |
| Parent story | Normalise every ORE parameter representation — the remaining work |
| Now | Slice 2 is done: the four guards refuse a value outside the set and store the form the set names, so a rendering does not reach a row by any of the four write paths. The mappers still convert first, which slice 3 removes. |
| Waiting on | Nothing. |
| Next | Slice 3: stop the mappers converting in one commit, and add the check that no guarded table holds a non-canonical value. |
| Last touched | 2026-11-01 |
3. Acceptance
- Every parameter whose ORE reader accepts more than one rendering is named, with the reader that accepts it and the canonical form chosen for it.
- The map from an accepted rendering to the canonical form is data: the value set states it, and no compiled table holds ORE's spellings.
- A write converts an accepted rendering to the canonical form at one boundary for every parameter, and a rendering the map does not know is refused naming the set.
- The screens offer the canonical forms and not the accepted ones, and the guard accepts every accepted rendering.
- A check fails when a table holds more than one rendering of one fact.
4. Discovery
The spellings live in one file, in six tables, and the numbers are read from it by
evidence/alias_census.py, whose output is alias_census.txt beside it:
| Table | Spellings | Canonical forms |
|---|---|---|
business_day_conventions |
26 | 7 |
day_counters |
72 | 23 |
frequencies |
18 | 9 |
compoundings |
4 | 4 |
date_rules |
12 | 12 |
day_counter_spellings |
11 | 11 |
| total | 143 | 66 |
Two tables name fewer canonical forms than spellings, which is the whole problem:
Unadjusted answers to five spellings, ACT/ACT.ISDA to eight, and a store that
holds the spelling it was handed holds the same fact six ways.
Four of the six tables convert in one direction only. business_day_conventions,
day_counters, frequencies and date_rules map a spelling onto the canonical
form the store holds; day_counter_spellings maps the other way, from the stored
form onto the spelling an export writes. A set that states both directions states
the fact once.
compoundings and date_rules have one spelling per canonical form, so they are
not aliases at all: they are the set's own values, and the table adds nothing.
4.1. The same knowledge is stated three times
evidence/alias_copies.py reads the other two copies, and alias_copies.txt
holds what it printed. The reader's tables are one statement; the refdata entities
convert with a table of their own, and the conventions seeder normalises with a
third before it writes a row:
business_day_conventions: 26 renderings, the reader
27 renderings, refdata (and 'Nearest' is not a rendering the reader accepts)
day_counters: 72 renderings, the reader
34 renderings, the seeder (5 of its keys no reader accepts, 43 reader rows it lacks)
frequencies: 18 renderings in both
So the story's claim is measured rather than asserted: three tables in three components hold the same knowledge, and two of them disagree with the reader.
4.2. Two mismatches the reader cannot see
Comparing the reader's tables with the parser map that accepts the renderings found two defects, both fixed in this slice:
| Mismatch | Effect |
|---|---|
Act/360 (Incl Last) is a rendering ORE's parser accepts, and day_counters held no row for it |
the engine reads a document this store refuses |
ThirdThursday, ThirdFriday, MondayAfterThirdFriday, TuesdayAfterThirdFriday and EveryThursday are rows date_rules holds and ORE's parser never produces |
a rule nothing can apply |
NthBusinessDay and EighthBusinessDay are renderings ORE's parser accepts, and date_rules held no row for them |
the engine reads a document this store refuses |
The check that found them now runs on every build: a rendering one source names and the other does not stops the generator and names the rendering.
5. What is not an alias
A boolean is the case the guard already settles. ORE's XML writes Y or N and
the screens write true or false, and the guard accepts all four and stores
what arrived. That is one fact written two ways, and the story counts it: the set
that governs a flag states true and false and the guard maps Y to true.
The aliasing is therefore about the tables above and the flag's two pairs, and nothing else in the tree.
6. Plan
6.1. The canonical form is a column on the value
ores_reporting_ore_parameter_value_set_values_tbl gains canonical_value,
null when the row is the canonical form and the canonical value when it is
another spelling of one. The set's values therefore state both directions, and a
screen asks for the rows whose canonical_value is null.
The alternative, a second table of aliases, states the same fact twice: the alias row would carry a value and a pointer to a value row, and the set's own value would be the only thing that decides what is canonical. One column keeps the row count and the ordering the set already states.
6.2. A catalogue set could not state it, so the four sets became enumerated
convention.business_day_convention, convention.day_counter and their two
curve. twins were catalogue sets: their values were the rows of the refdata
registers, and a catalogue set holds no value rows, so it has nowhere to put a
canonical form. The registers hold every rendering ORE accepts — 72 day counters,
where 23 facts exist — because a guard refuses a spelling the register lacks. A
set drawn from them therefore offered the same day counter four times, which is
the defect the story is about, in the one place a person sees it.
So the four sets state their renderings themselves, and the registers keep their own job: they are the whitelist a column guard checks, and the rows a convention holds. A rendering is a fact about a parameter, so it lives with the parameter's rule, not with the entity that happens to spell it. The registers still accept every rendering, so no stored value changes and no guard loosens.
The alternative, marking the rendering rows in the registers, would have made a 26-row convention register hold 19 spellings, and every screen that lists a register would have to filter rows that are not conventions.
6.3. Conversion happens in the guard, not in the mapper
The mappers convert today, before the store sees a value, so the store's guards never meet a spelling. That is why the knowledge had to live in code: a value that never reaches the database cannot be converted there.
A helper, ores_reporting_canonical_value_fn(set_code, value), returns the
canonical form of a value the set knows, and the four leaf guards call it as they
walk the column they are about to write: NEW.column = ores_reporting_canonical_value_fn(...).
The guard is the one boundary every writer passes through, so the shell, a screen,
an import job and a publish function all convert once, in one place.
The columns are guarded but not rewritten today, and a BEFORE trigger may rewrite
NEW, so no schema change is needed to carry this.
6.4. The order the slices go in
The change has a state that is worse than either end: mappers stopped converting and the guard not converting yet would let a spelling reach a row unwatched. So:
- Seed the renderings into the sets from the compiled tables, with a generator
that reads
conventions_fields.cpp, so the set states what the table states and a check compares them. Nothing changes at runtime, and a combo shows a fact once for the first time. Done, PR #4244. - Teach the guards to convert, and keep the mappers converting: the guard's conversion is then exercised by a test that writes a spelling straight to a table, and the import path still converts first, so no row changes. Done, PR #4248.
- Stop the mappers converting, in one commit, so the spelling reaches the guard for the first time. The round-trip suites prove nothing was lost, and a check that no table holds a non-canonical value proves the guard converted.
- Delete the compiled tables and the census script's reason to exist, which is the story's "no compiled table holds ORE's spellings".
Slice 1 is data and a generator; 2 and 3 are the runtime change; 4 is the deletion that makes the claim true.
6.5. The check
A query over the map and the guarded tables: for every child row, the value a row holds must equal the canonical form of its set, so a spelling that reached a row by any path is named with its table, its column and its set. The check reads the map, so a column that gains a guard gains the check with it.
7. Where the tables are read
conventions_fields.hpp is included by 27 files under projects/ores.ore/core,
and the four conversion helpers it exports are called 21 times:
$ grep -rln "conventions_fields.hpp" projects/ores.ore | wc -l
27
$ grep -rn "business_day_code\|day_counter_code\|frequency_code\|compoundings\|date_rule_code" \
projects/ores.ore/core | wc -l
21
So moving the knowledge is a change to 21 call sites in one component, and none outside it. The mappers that write a convention row are the callers, and each one converts the spelling it read before the row exists.
8. Measurements
Every number this task claims about a tree, with the command that produced it
and the commit it was measured at. A number without both is not a measurement,
because a clean-up record describes a tree and trunk moves under a branch: a
consumer count, a gate's component count and a suite's size all change with no
code changing, and the reader of a bare number cannot tell which tree it came
from. Where the raw output matters, put it in an evidence/ file beside the
task and name that file in the Command column.
| What | Value | Command | Commit | |
|---|---|---|---|---|
| spellings the reader's tables hold | 143 over 66 canonical forms | evidence/alias_census.py |
4a5c6ea68e plus this branch |
|
| places that state them | 3, in 3 components | evidence/alias_copies.py |
4a5c6ea68e plus this branch |
|
| renderings the sets now state | 153 rows over 5 sets, of which 132 are a fact's rendering | select count(*) from …value_set_values_tbl where canonical_value is not null |
4a5c6ea68e plus this branch |
|
| day counter set | 23 choices over 52 renderings | select … where value_set_code = 'convention.day_counter' |
4a5c6ea68e plus this branch |
|
| business day convention set | 7 choices over 20 renderings | … 'convention.business_day_convention' |
4a5c6ea68e plus this branch |
|
| frequency set | 9 choices over 9 renderings | … 'convention.frequency' |
4a5c6ea68e plus this branch |
|
| value rows the seed writes | 566 | =grep -c "canonical_value\ | ('" on the populate script= | 4a5c6ea68e plus this branch |
| files that include the table header | 27 | grep -rln "conventions_fields.hpp" projects/ores.ore |
4a5c6ea68e plus this branch |
|
| conversion call sites | 21 | =grep -rn "business_day_code\ | …" projects/ores.ore/core= | 4a5c6ea68e plus this branch |
9. Notes
10. Test Scenarios
Manual QA scenarios (scaffolded via compass add test_scenario)
that verify this task. Link
new ones here as they're created; the scenario doc itself links back
via its "Verifies task" field.
| Scenario | State | Notes |
|---|---|---|
11. PRs
| PR | Title |
|---|---|
| #4244 | [reporting] Carry one canonical form for each ORE value |
| #4248 | [sql] Store the form a value set names, in the guard |
| #4252 | [sql] Name every row that holds a rendering |
| #4431 | [refdata] Let the guard be the only converter |
| #4444 | [ore] Generate the reader's rendering tables from the renderings file |
| #4446 | [codegen] Remove the store transform no model states |
| #4450 | [docs] Restate the boundary that converts a stored value |
12. Review
| Comment summary | File | Decision | Notes |
|---|---|---|---|
13. Result
13.1. Slice 1: the set states the form a configuration holds
A value row names the form a configuration holds and is empty when the row is that form, so one table states which renderings are accepted, what each means and which one a configuration carries. The four sets that drew on a refdata catalogue became enumerated, because a catalogue set holds no value rows and so had nowhere to put the column; the registers keep the whitelist a guard checks.
The numbers, measured after the seed and read back through ores.shell:
| Set | Choices | Renderings |
|---|---|---|
convention.day_counter |
23 | 52 |
curve.day_counter |
23 | 52 |
convention.business_day_convention |
7 | 20 |
curve.business_day_convention |
7 | 20 |
convention.frequency |
9 | 9 |
| total | 71 | 153 |
The renderings are read from ORE's reader and its parser rather than curated, so the check that they agree runs on every build. It found five defects, all fixed: three renderings the reader refused and five rows it could never apply.
Acceptance, as it stands:
- Every parameter whose ORE reader accepts more than one rendering is named, with
the reader that accepts it and the canonical form chosen for it. Done, by
ore_aliases.pyreading the reader's six tables and the five parsers. - The map from an accepted rendering to the canonical form is data, and no
compiled table holds ORE's spellings. Half done: the reading direction is
data; the direction a write emits is still
day_counter_spellingsandbdc_spelling, because the store's form is not always a form a document may write. Slice 4 states that direction as data too. - A write converts through one boundary and refuses a rendering the map does not know, naming the set. Done: the four guards convert, and a value outside the set is refused with the set's name.
- The screens offer the canonical forms and the guard accepts every rendering. Done: a combo offers the canonical forms, and the guard accepts a rendering and stores its canonical form.
- A check fails when a table holds more than one rendering of one fact. Not yet: slice 3's query over the map and the guarded tables.
13.2. Slice 2: the guard stores the form
ores_reporting_canonical_value_fn(set_code, value) answers with the form the set
names for one value, and with the value itself when the set states no other
rendering of it. All four guards call it after they accept a value, so a rendering
does not reach a row by any of the four write paths.
Two of the guards walk columns they read from a table, so they rewrite the row through its JSON form: a trigger cannot name a column that a query named.
The measurement that matters, on a freshly recreated database whose conventions were published afterwards:
$ psql -f tmp/noncanon.sql NOTICE: checked 73 columns: 0 hold a rendering
The 73 are the guarded columns whose set states a rendering. Before this slice the
same query named 45 rows, all of them A365, a rendering of ACT/365.FIXED,
written by the conventions publish: the publisher is a writer that never went
through the mappers. After it, the same publish leaves ACT/365.FIXED in all 45.
| Path | What the test writes | What the row holds |
|---|---|---|
| a curve column | A365F |
ACT/365.FIXED |
| a convention term | Z, MODFOLLOWING, A360 |
Once, ModifiedFollowing, ACT/360 |
| a product parameter | NONE |
Alternating |
| a run setting | NONE |
None |
13.3. Slice 3, part one: the check
ores_reporting_canonical_value_violations_fn() names every live row in a guarded
column whose value is not the form its set names, with the table, the column and
the set. It reads the element map, so a column that gains a guard gains the check
with it, and it reads only the live version of a row: a value written before the
rule existed is history, not a defect.
On the database whose conventions were published after slice 2 it answers nothing. Run it with:
./compass.sh sql -- -c "select * from ores_reporting_canonical_value_violations_fn()"
13.4. Slice 3, part two is a decision, not a patch
Stopping the mappers converting was tried, and the measurement stopped it. The
reader was changed to check a spelling against its table and hand the spelling on,
which is the shape the task describes; ores.ore.core.tests then failed on 72
pinned exports of the ORE corpus, because that suite models the row path without
a database, and the canonicalisation it used to perform in memory now happens in
the guard.
The suite's pinned bytes therefore describe a pipeline that has a canonicaliser. Reverting the readers leaves them describing the one that exists; keeping the change means re-pinning 72 documents whose only difference is that they keep the spelling the document wrote, and accepting that the suite then measures the codec rather than the store.
Two ends are honest and this tree can have either, not both:
| End | The reader | The export |
|---|---|---|
| the store canonicalises, and the codec states the renderings too | converts, as it does now, and the tables stay | writes a spelling the schema allows |
| the store canonicalises alone | passes the spelling on | writes the stored form, so a form the schema cannot write needs a spelling from data first |
The second end is where the story points, and it needs the export spelling stated as data before the codec can stop. That is the next slice: a column on the value row for the form an export writes, or a writer that reads the set. Until then the compiled tables have a reason to exist, and slice 4 waits on it.
13.5. What slice 2 changed for the database round trips
Slice 2 made the store hold one form of each vocabulary value, and five database round-trip cases compared the export against the document that went in. They read the canonical form as a defect. They now state the round trip on what the store returns, and every count assertion stays.
The same run found a list rule that refused a good list: a value written across
several lines carries the newline and tab around each item, and btrim trims
spaces alone, so a multi-line list of periods was refused. The guard now trims
the whitespace around an item and still refuses an item that is nothing but
whitespace.
Three eventing cases in ores.refdata.core.tests fail on a synthetic day counter
code (A365-0) that the guard refuses. The generator invents the code, and the
guard states the set; they fail on main too and are not this task's work.
13.6. What making the set the only statement cost, and found
Three things came out of getting main green after these slices.
A test that seeds a register row and then names it in a config relied on the set
being the register's live rows: it invented the day counter code A365-0, and
the catalogue view showed it because the view holds every tenant's rows. The set
now states the spellings, so the fixture states one (A365). That is the
behaviour the story asked for, and the three eventing cases are the only place
that relied on the old one.
Eight settings whose document value is a comma separated list were stated as one
value, so the guard refused a good list. Their sets now state cardinality =
many: CalibrationMoneyness, BucketTimesDeltaGamma, BucketTimesVega,
Bootstrap.CalibrationGrid, ReferenceCalibrationGrid, GridCoarsening,
calibrationIndexTerms, recoveryRateProbabilities, QuantoCurrencyPairs and
Pricing.ConversionRatioDiscretisationGrid. The list rule already trims the
whitespace around an item, which a multi-line element carries.
Heston.FixedValues is the tenth, and it needs a set of its own: it is five
flags, and it links to the shared flag set, which cannot become many without
turning every boolean parameter into a list. That is curation work, not a schema
change, and it is recorded here rather than guessed at.
13.7. The form a configuration holds is a form a document may write
The store's form and a document's spelling were two different things for four
facts: ACT/360 (incl. last), ACT/365 (Canadian Bond), ACT/365 (JGB) and
Nearest were forms no document could write, so the codec held a second table to
spell them on the way out. Each now takes the spelling the schema states:
ACT/360 (Incl Last), Act/365 (Canadian Bond), Actual/365 (JGB) and
NEAREST.
With that, day_counter_spellings, bdc_spelling and both helpers are gone, and
put_day_counter and put_bdc write the stored form as it stands. The generator
holds the invariant: a table that makes a form no rendering writes stops the run,
so the write direction cannot come back.
The export changes for the 20 forms the old table rewrote into another spelling: a
document that says A365 exports ACT/365.FIXED where it used to export A365F.
The 72 pinned corpus digests moved once, written by the case itself under
ORES_REGEN_CONVENTIONS_DIGESTS, and the rendering counts fall from 52 and 20 to
49 and 19 because three forms became renderings of themselves.
The reader still converts: a document read has to reach a column. Deleting that table leaves the guard as the only converter, and it waits on the sets being authored rather than generated from the table.
13.8. One authority over a day counter column
A curve's day_counter was ruled by two things at once: the value set, which
states the renderings and the form a configuration holds, and the entity's soft
foreign key to ores_refdata_day_counters_tbl, which states the spellings a
register happens to hold. A generated code such as A365-0 was the visible
symptom: the register admitted it and the set refused it, so which authority
spoke depended on the trigger order. The register also left a hole the tests
hid: the guard stores ACT/365.FIXED, which a tenant's own register may not
hold, so a later update of that row could fail a check the write had passed.
The sixteen entities that named the register as a foreign key no longer do, so a day counter column has one authority. The table stays as reference data – the list of day counters ORE's schema names, which a person may read – and states no rule. Which spellings a column accepts, and which form a configuration holds, is the value set's.
13.9. What the next slice needs
Slice 2 adds ores_reporting_canonical_value_fn(set_code, value) and calls it
from the four leaf guards while the mappers still convert. The helper reads a
set's rows: the enumerated values, or the catalogue relation the set names. It
returns the value when the set holds no row for it and the guard's whitelist is
what refuses it, which is the one case a reader has to keep straight.
The three copies of the knowledge named in Discovery are still there. The refdata
conventions_code header and the seeder normalisers are slice 3 and 4 work: once
the guards convert, each has no reason to.
13.10. The renderings file states the tables the reader converts with
Five tables in conventions_fields.cpp held 132 renderings over 55 forms, and
ore_renderings.json held the same knowledge — written from them. Two copies in
one direction: the file was a projection of the C++.
The direction is now the other one. The file is the statement, and ORE's reader
converts with tables generated from it by
projects/ores.ore/scripts/generate_renderings_tables.py into
projects/ores.ore/core/src/domain/conventions_renderings.hpp. The generated
header is a copy that cannot drift: --check fails when the committed header
differs from the file, and when conventions_fields.cpp states a code_table of
its own.
That second half is the point. A generated copy is one statement that survives
being compiled; a table written by hand next to it is a second statement, and the
check now refuses one. reader_tables() and the three regular expressions that
read the reader's source are gone with it, and so is
generate_value_renderings.py, which wrote the file from them.
Behaviour is unchanged, and the measurement is the tables: the generated header states the same 132 pairs over the same 55 forms as the tables it replaced.
$ python3 projects/ores.ore/scripts/generate_renderings_tables.py --check 55 canonical forms across 5 tables match projects/ores.sql/utility/ore_renderings.json.
When the tables were replaced, a script parsed the five out of the previous commit and compared them with the five the header states: 132 pairs each, and no pair only in one of them.
The header compiles in the unity translation unit that holds
conventions_fields.cpp, which is the only file the change touches in C++.
The plan above said "delete the compiled tables". They are still compiled, because the reader must convert and a reader cannot read a JSON file at run time. What changed is that they are no longer a second authority: nothing reads them to learn what the mapping is, and the file wins wherever the two differ.
13.11. The two copies the story left behind
Two pieces of the old design outlived the slices above: a codegen hook whose only user the story had deleted, and a knowledge cluster that still stated the design the hook belonged to.
The hook, store_transform, let a column name a function the mapper calls on the
way into the row, plus the header that declares it. #4431 removed its one user,
stored_business_day_convention, so no model set the property, and
template_variables.org still named the deleted header as its example. The three
template branches, the include gate in core.py, its test and both doc mentions
are gone. Generated output does not change, because a branch only emits when a
column sets the property.
The knowledge cluster was the record of the old design. Three pages put the conversion in the entity mapper, and the order page gave the codec as the reason it could not be anywhere else. The codec converts a conventions document on read, from the generated tables, and the guard converts every write the codec did not. The pages now state the two call chains with the lines that do each step, the two figures say the same, and the inbox capture the cluster was filed against records that both defects it names are fixed.
$ python3 build/scripts/local_checks.py --exclude-heavy (docs branch) ✅ local checks: 9/9 passed $ select count(*) from ores_reporting_canonical_value_violations_fn(); 0
Three checks failed on this story's branches, and all three failed in a clean
worktree of origin/main too. Two are fixed in #4444, because the branch could
not go green until they were: the junction-events test now states the
subject_prefix the template emits since the events namespace landed, and
ores_schema.dq_staging.puml is regenerated. The third,
component-drift, names the msgpack-c lines a337e95529 added by hand to
projects/ores.storage/service/src/CMakeLists.txt; it belongs to the storage
model and stays open. All three are recorded in
The local check gate is red on main.