Task: Give every fixing index name an oresmd identity

Table of Contents

This page documents a task in the Clean ores.marketdata to the component clean standard story. It captures the goal, current status, acceptance, and any notes or results.

1. Goal

Every distinct fixing index name in external/ore/examples resolves to an oresmd identifier, so a market_fixing row can carry an oresmd_uri and the identity cutover has a way back for the fixing half of the corpus.

2. Status

Field Value
State DONE
Parent story Clean ores.marketdata to the component clean standard
Now Nothing.
Waiting on Nothing.
Next Nothing.
Last touched 2026-09-30

3. Acceptance

  • from_index_name names every distinct index name the corpus carries, asserted by a corpus-driven test whose allowlist is empty. Met: the class list is empty and the census reads 156 of 156 index names.
  • Each asset class the corpus puts in the fixing space has a stated mapping, or a recorded reason it has none. Met: the two rows that are not index names are the only ones with a reason, and it is recorded.
  • The corpus fixing import creates a series for every fixing row, with no skipped-name warnings. Not met, and not this task's: the import still writes the index name into the series qualifier and never calls from_index_name, which is the identity cutover's work. Review row 11 records it.

4. The index names, and what each class needs

Measured over external/ore/examples (every *fixing*.txt and *fixing*.csv outside ExpectedOutput): 158 distinct index names. They fall into seven shapes as first read, and only the first was nameable at that point. COMM- and POWER- are one shape up to the prefix but two classes in the census below, because the prefix is what the class tag is: 25 and 48 names.

class count shape nameable now what it needs
ir 57 CCY-FAMILY[-TENOR] 34 23 families the enum does not hold
inflation 7 a bare index code 0 the inflation class already models it
equity 8 EQ-<ticker-or-ric> 0 a currency the name does not carry
fx 7 FX-<source>-<CCY1>-<CCY2> 0 a source field
commodity 25 COMM-<EXCHANGE>:<CONTRACT>-<YYYY-MM> 0 a currency, a delivery period
power 48 POWER-<EXCHANGE>:<CONTRACT>[-<YYYY-MM-DD>[-<SEC>-<SEC>[-DST]]] 0 a delivery window, and the class
security 2 BOND-ISIN:<isin>[-<month>] 0 the security class already models it
(unclassified) 4 GENERIC-... 0 split below: generic for the two index names, a refusal for the two keys

The interest-rate 57 split two ways. The ones whose family the enum holds are nameable; 22 of the rest are real benchmark families it does not: BBSW, CAMARA, REPOFIX, CZEONIA, PRIBOR, CIBOR, DKKOIS, CMS, EONIA, FedFunds, NIBOR, SIOR, STIBOR, SIFMA, HIBOR. The twenty-third, JPY-TONAR, is the spelling ORE writes for tona. The identifier already carries index_spelling for exactly this – "the spelling that arrived is carried beside the family so the key reads back as it came" – so a spelling needs an alias beside its family, and the missing families need adding. AONIA is neither: the family token is matched case-insensitively, so it has always resolved to aonia.

Two of the four GENERIC names are not fixings at all. They are GENERIC-MD/EQUITY_OPTION/PRICE/RIC:.SPX/USD/2025-10-03/3300/C and its sibling: market-data keys that appear inside a fixings file, read by the whole-line parser as fixings because that is the file they are in. Whether the reader should refuse a key that is not an index name is part of this task. The other two, GENERIC-JuniorNote and GENERIC-PortfolioBasket#1, are a curve name and a basket name, and need classifying rather than mapping.

5. The decision that gates the rest

Four of the six classes need a field the index name does not carry:

  • an equity fixing (EQ-SP5) names a ticker and no currency, and the equity identifier requires one;
  • a commodity or power fixing (COMM-NYMEX:CL-2024-12, POWER-ICE:PDQ-2021-01-04-14400-18000) names a contract and no currency, and the commodity identifier requires one;
  • an FX fixing (FX-ECB-EUR-USD) names a source as well as a pair, and the FX identifier has no field for one.

Two readings, and they produce different permanent URIs:

  1. A fixing's identity does not include a currency. For type=fixing the currency is a property of where the instrument is quoted, not of which index it is, so the grammar should not require it. oresmd://equity/sp5?type=fixing and oresmd://commodity/nymex:cl-2024-12?type=fixing, each carrying a contract as its point where there is one. This is the reading the IR case already follows in reverse – there the currency is in the name, so the identifier has it – and it needs no field the corpus cannot supply.
  2. The caller supplies the missing fields. The fixing boundary would look the currency up (refdata knows a ticker's currency) and add ccy to the URI. This keeps the identifier's invariants intact and moves the problem to the import, but it makes the URI depend on reference data: the same file would produce different URIs in two databases that disagree.

The recommendation was the first, with the FX source carried as a spelling beside the pair the way index_spelling carries one. The owner took the first and kept the source as a field rather than a spelling, because a spelling is orthographic and the two sources are two different rates; the decision is recorded under * Decided.

6. Decided

Requiredness follows the entity kind: a fixing and a quote do not share a set of required fields, because they do not answer the same question. A quote says what a thing is worth and in what currency; a fixing says what an index was on a date. So the currency is required of every type that is not a fixing, and not of a fixing – which is what the parser now enforces, with the type carrying it as optional rather than mandatory.

This settles the equity and commodity half of the gap. Still open, and separate:

  • the interest-rate families the enum does not hold – EONIA, CIBOR, NIBOR, STIBOR, SIOR, FedFunds, CMS, SIFMA, BBSW, AONIA, CAMARA, REPOFIX and the rest – which is data rather than grammar;
  • the FX source (FX-ECB-EUR-USD against FX-TR20H-EUR-USD), which is a field the FX identifier does not have; and
  • the power contract-hour shape, which is a shape the grammar does not have.

The FX fixing carries its source, and the pair as it was quoted. The source is a field, not a spelling: FX-ECB-EUR-USD and FX-TR20H-EUR-USD are two different rates, where a spelling difference is two ways of writing one. So source joins the FX identifier, lower-cased, with source_spelling carrying the token as ORE wrote it when the two differ, and both are meaningful only when type=fixing – ORE's FX quote keys carry no source at all.

The pair is never put into a canonical order. A fixing's value is only meaningful in the direction it was published, and the corpus writes FX-TR20H-EUR-GBP and FX-TR20H-GBP-EUR as one rate both ways, on 122 shared dates, reciprocal to about three decimal places and no more. The model brief carries the measured pair of figures. Canonicalising would put two values under one date, and nothing in the import inverts a value.

7. The power contract-hour shape, against ORE's own grammar

The last grammar decision. POWER-ICE:PDQ-2021-01-04-14400-18000 arrives as an index name, and ORE writes the grammar for that name down rather than leaving it to be inferred from the corpus.

ORE states the format. OREData/ored/utilities/indexparser.cpp carries a parser of its own for this family, parseIntradayPowerIndex(), standing beside parseCommodityIndex() rather than inside it, and its failure message is the grammar:

Expected format: POWER-COMMNAME[-YYYY-MM-DD[-DELIVERYSTART-DELIVERYEND[-DST]]]

It builds a QuantExt::IntradayPowerIndex, a type of its own, and parseIndex() gives the power parser its own place in the dispatch order. The parser splits the remainder after POWER- on dashes and reads tokens[0] as the commodity name, so the name's boundary is the first dash and a power commodity name carries no dash – ICE:PDQ is whole. It then accepts the remainder only at three token counts: one (the commodity name alone, with no date), four (a name and a date, with no time), and six or seven (a name, a date, a start, an end, and an optional DST, matched case-insensitively). Every power name the corpus carries is the six-token form but for one four-token name, and no name has a shape the grammar does not admit.

The two numbers are seconds from midnight, and the window they name is half-open: the corpus's windows are all 3600 seconds wide and tile a day without a gap.

The date is the delivery date. Every power row carries the same date in the file's first column and in the name, so the power payload alone cannot say which the name's date is. Its sibling settles it: COMM-NYMEX:CL-2025-04 is observed on 2024-12-02, so a period in a name of this family is the delivery period. ORE's own types agree – IntradayPowerIndex takes a deliveryDate, and the trade that consumes it, IntradayPowerForwardData, carries DeliveryDate, DeliveryStart and DeliveryEnd as three separate fields, the last two in seconds.

The window is not an hour. The corpus samples 3600-second windows only, but the trade model carries DeliveryStart 2700 and DeliveryEnd 4500 for the forward it calls "0.45am to 1.15am". The coordinate is therefore a second-resolution window, and "the contract-hour shape" names the corpus's own sampling rather than a rule the model may hardcode.

What the corpus carries. 48 distinct names, every one of them on ICE:PDQ:

shape names example carried
POWER-COMMNAME 0 POWER-ICE:PDQ no
POWER-COMMNAME-YYYY-MM-DD 1 POWER-ICE:PDQ-2021-01-04 yes
POWER-COMMNAME-YYYY-MM-DD-START-END 47 POWER-ICE:PDQ-2021-01-04-14400-18000 yes
POWER-COMMNAME-YYYY-MM-DD-START-END-DST 0 POWER-ICE:PDQ-2021-01-04-14400-18000-DST no

The one date-only name is the whole-day contract for 2021-01-04, at 38.75 against 25 to 38 for that day's 23 hourly windows. That day carries 24 rows and 2021-01-01 to 2021-01-03 carry 8 each, so the corpus is not a complete tiling of a day and nothing may assume one window per hour of a delivery date.

The name is an index name and nothing else. Every other class with an index-name prefix in the corpus also appears as a market quote key – FX/RATE on 1372 rows, EQUITY/PRICE on 105, COMMODITY_FWD/PRICE on 2803, BOND/YIELD_SPREAD on 92 – so each of them has a quote key to project as well as a fixing key. POWER does not: the string occurs on the 48 fixing rows and nowhere else, and an intraday power price is quoted as SHAPE_PROFILE/SHAPE_FACTOR/PROFILE/DATE/SECOND/UNIT, which the shape_profile class already owns. So the index name is the only key this class has, and the class has no quote type at all.

7.0.1. Worked example

The four forms the grammar admits, and the identifier proposed for each:

ORE index name proposed oresmd URI
POWER-ICE:PDQ oresmd://power/ice:pdq?type=fixing
POWER-ICE:PDQ-2021-01-04 oresmd://power/ice:pdq?type=fixing&delivery=2021-01-04
POWER-ICE:PDQ-2021-01-04-14400-18000 oresmd://power/ice:pdq?type=fixing&delivery=2021-01-04-14400-18000
POWER-ICE:PDQ-2021-01-04-14400-18000-DST oresmd://power/ice:pdq?type=fixing&delivery=2021-01-04-14400-18000-DST

to_index_name() renders POWER-, the commodity code upper-cased, and the delivery with the same dashes the field arrived under; from_index_name() strips the prefix, takes the first dash-delimited token as the commodity code, and reads the tail by token count exactly as ORE does – one token for no delivery, four for a date, six or seven for a date and a window – refusing every other length. A date that is not the canonical YYYY-MM-DD is refused rather than reshaped, because a name that would not read back as it arrived is not an identity.

7.0.2. Why the coordinate is a fixing field and not point

point is defined as the coordinate a quote or a vol URI names, and the model's own is_scalar() already draws the line: a fixing whose coordinate is a field of its own is scalar – oresmd://ir/usd?type=fixing&index=libor&tenor=3m is, and a case asserts it – while an identifier that carries a point is not. The precedent for "the coordinate that says which index a fixing is" is therefore a dedicated field – tenor for interest rates, source for FX – and both leave the fixing scalar. The delivery window is not a per-observation coordinate either: every observation of POWER-ICE:PDQ-2021-01-04-14400-18000 carries the same window, and it is the series that is specific to it.

The sibling shape_profile class does put this same intraday coordinate – a delivery date, a second of the day, a unit and a DST flag – in point, but it does so for a quote, which is exactly the case point is for. One coordinate, two slots, because ORE itself splits them: the coordinate as a quote is SHAPE_PROFILE/SHAPE_FACTOR/..., and as an index it is POWER-....

One delivery field carries the tail whole, because the grammar's own nesting is already "a date, then optionally a window, then optionally DST, each level available only under the one before it", and one key keeps the query grammar small – the same reason credit folds its seniority and tenor into point and shape_profile folds four parts into it. Typed delivery_date, delivery_start, delivery_end and dst keys remain available if the coordinate should be queryable per component.

7.0.3. Why power is its own class and not commodity

They are the same shape up to the prefix – COMM-COMMNAME-YYYY-MM and POWER-COMMNAME-YYYY-MM-DD[-START-END] – so folding power into commodity is the obvious alternative, and the shared token argues for it: ICE:PDQ is a commodity code, and the daily-average curve the intraday power curve is built on is quoted as COMMODITY_FWD/PRICE/ICE:PDQ/USD/<date>.

The prefix cannot be derived, and that is what decides it. ORE's power parser accepts a bare POWER-ICE:PDQ, and its commodity parser accepts a bare COMM-ICE:PDQ; under one class both would be the identifier commodity/ice:pdq?type=fixing with no coordinate, and one of the two names could not be written back. A discriminator field would be needed to tell them apart, and the URI's class tag is already that discriminator. So power joins shape_profile and rating as a class the corpus's own prefix names, and the class_of() census, which already counts power separately, stays true.

8. The remaining classes, and the two rows that are market data

The last open question is settled. The four GENERIC names split two ways.

GENERIC- is a prefix ORE resolves in a parser of its own, so GENERIC-JuniorNote and GENERIC-PortfolioBasket#1 are index names like any other, and a generic index's identity is its own name. GENERIC-MD is not the same prefix: it is the market-data wrapper whose inner type and metric name the series, and it is already one of the coverage gate's two recorded exemptions. Its two rows in Products/Input/fixings.csv carry SPX call prices – 2862.29 at the 3300 strike and 2767.41 at 3400, about 95 apart for a 100 strike step – so they are market data in a file whose header declares fixingId for its second column. The owner reads them as errors rather than as fixings to model.

So the target is 156 of 158: the two GENERIC-MD rows are errors, and the other nineteen names are named.

class names what it needs
inflation 7 a known index-code list: the corpus's own header words are dash-less too, and ORE carries such a list in its parser
equity 8 nothing new: ccy_optional_for_fixing already settles the currency, so the projection is EQ-<ticker-or-ric>
security 2 delivery reused for the bond future's expiry month, the shape commodity's contract month already uses
generic 2 the name as the entity, with the case it arrived in as a spelling

The names are AUCPI, EUHICP, EUHICPXT, FRHICP, UKRPI, USCPI and ZACPI for inflation; EQ-SP5 and seven EQ-RIC: or EQ-FIGI: tickers for equity; BOND-ISIN:IE00BH3SQ895 and its -2025-08 future for security; and GENERIC-JuniorNote and GENERIC-PortfolioBasket#1 for generic.

One row stays unexplained and is not part of this: GENERIC-JuniorNote is valued at 4000000 in Legacy/Example_46/Input/fixings.txt, alone after a blank line among ordinary index fixings. Four million is not a rate or a price, so it should be checked against Example_46's portfolio rather than assumed either way.

9. The generic index, and the two keys that are not index names

GENERIC- is a prefix ORE resolves rather than a spelling of another class, so it gets a class of its own. ORE's parser resolves the prefix, and the name is all the identity there is: a generic index is whatever its producer called it, the corpus carries no other key for either series, and ORE has no market-data key for one. The corpus carries two, GENERIC-JuniorNote in Legacy/Example_46/Input/fixings.txt and GENERIC-PortfolioBasket#1 in Products/Input/fixings.csv, both on fixing rows. So generic joins power as a fixing-only class: it carries no quote type, and quote_key_generic() returns nothing.

9.0.1. Worked example

The three names, and the identifier each projects to:

ORE index name oresmd identifier
GENERIC-JuniorNote oresmd://generic/juniornote?type=fixing&name_spelling=JuniorNote
GENERIC-PortfolioBasket#1 oresmd://generic/portfoliobasket%231?type=fixing&name_spelling=PortfolioBasket%231
GENERIC-SOMEINDEX oresmd://generic/someindex?type=fixing

to_index_name() renders GENERIC- and the name; from_index_name() strips the prefix and takes the tail whole. A tail that carries a slash is refused, because the slash is the market-data key grammar's separator and not an index name's: that is what keeps GENERIC-MD/EQUITY_OPTION/PRICE/RIC:.SPX/USD/2025-10-03/3300/C and its 3400 sibling out of the fixing space. The two are SPX call prices – 2862.29 and 2767.41, about 95 apart for a 100 strike step – in a file whose header declares fixingId for the column they sit in, and the owner reads them as errors rather than as fixings to model.

9.0.2. Why the name is a spelling, not the entity's own case

The identifier holds the name upper-cased, as every other class's entity field does, and to_uri() lower-cases it into the path segment as it does for every class. The case the corpus wrote sits beside it in name_spelling. Both corpus names arrive in mixed case, so without the field neither would read back as ORE wrote it. That is the FX source_spelling precedent, and the same rule the interest-rate family spells under: the identifier holds the normalised token, and the name keeps the spelling it arrived under.

Carrying the name verbatim as the entity is the alternative, and it is the only one that needs a special case: the parser's entity assignment and to_uri()'s path segment are generated from the Fields table for every class alike, and both case-fold what they carry. The spelling is a query key whose value the parser carries raw, which is what the FX source already is, so the class needed one guard slot in the parser's field list and no new shape of its own.

The character set is ORE's and not the URI's. # is a fragment delimiter, so it is percent-encoded in the path segment and in the spelling, and the parser decodes both. A case reads GENERIC-PortfolioBasket#1 out to a URI and back to prove it.

10. The last three classes, and the allowlist they close

Inflation, equity and security are closed, which leaves no class the corpus puts in the fixing space unnamed: 156 of its 158 names in a fixing column resolve, and the other two are the market-data keys.

Inflation is the one class whose index name has no prefix. UKRPI is the whole of the name: there is no INFLATION-, no separator, and nothing in the string says which class the token belongs to. So the class carries the codes it knows, in an Index codes table in inflation_quote_type.org, and the index-name projection names a dash-less token only when it is one of them. A stray token or a header word keeps its name instead of being given an inflation identity, and a code written in another case is refused rather than reshaped – the rule the power delivery date follows, and why the seven codes the corpus carries are all upper-case. The list is data: a code ORE adds is a row.

Equity needs no new field. A fixing carries no currency, which ccy_optional_for_fixing already settles, so the whole projection is EQ-<ticker>. The ticker is the tail ORE writes, its identifier scheme included: the corpus carries EQ-RIC:.SPX and EQ-FIGI:BBG00R251JN8 as fixings beside EQUITY_OPTION/RATE_LNVOL/RIC:.SPX/USD/... as the quote key for the same instrument, so the scheme belongs in the name rather than in a field beside it.

Security reuses delivery. The corpus writes BOND-ISIN:IE00BH3SQ895 for the bond and BOND-ISIN:IE00BH3SQ895-2025-08 for the future that expires in August 2025, so the trailing period is a contract month: the shape commodity already gives its delivery. delivery joins security's Fields table and leaves its Reject keys table, and the shared shape helper becomes contract_month_is_the_shape now that it has a second caller. A quote carries that information inside the name it is quoted under, which is why nothing reads the field for one.

The allowlist is empty and the population is still whole. The two GENERIC-MD/EQUITY_OPTION/PRICE/... rows are not index names, so the census counts them in a bucket of their own, pins them by name and asserts that the projection refuses both. That case replaces the recorded-list entry that used to excuse them, so nothing was dropped to make the list empty.

11. Plan

  1. Measure first. Walk every fixing payload with the real fixing reader, report the yield per index-name class, and pin the classes that name nothing. The instrument landed with the interest-rate work rather than after it: the branch this task descends from skipped three quarters of the distinct series with a warning, and no test asked about the names it skipped.
  2. Interest rates are data, not grammar. The families and the spellings ORE writes for them go in the Index family table of ir_quote_type.org. The tenor rule moves into that table too, so a family cannot join the enum without being classified, and the hand-written header that held a copy of the rule goes.
  3. Then the grammar, one class at a time. The FX source and the power contract-hour shape each need a field or a shape the identifier does not have. Each is a decision taken with a worked example against the corpus, and each shrinks the pinned list by one class.

(Implementation strategy. Written when work starts; key decisions are distilled into the parent story's * Decisions at close, but the plan itself stays — it is the historical record of what we did.)

12. 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
Fixing payloads walked 66 files, 158 distinct index names, 8 classes ores.marketdata.core.tests "[fixing]" 495dc8d2e3
Interest-rate names that name, before 34 of 57 same 495dc8d2e3
Interest-rate names that name, after 57 of 57 same 495dc8d2e3
Interest-rate names that round trip, after 57 of 57 same 495dc8d2e3
Fixing payloads the reader refuses, before 1: CurveBuilding/Input/fixings_bondyieldshifted.csv same 495dc8d2e3
Fixing payloads the reader refuses, after 0 same 6fa10a87f4
ores.marketdata.core suite 1393 assertions in 333 cases ores.marketdata.core.tests 6fa10a87f4
ores.ore.core suite 429839 assertions in 552 cases ores.ore.core.tests 6fa10a87f4
codegen suite 955 passed pytest projects/ores.codegen/tests 6fa10a87f4
Regeneration is idempotent git status empty on a second run ./projects/ores.codegen/codegen.sh generate --model projects/ores.marketdata/modeling/oresmd/model.org --address ores.cpp.oresmd 6fa10a87f4
FX names that name, before 0 of 7 ores.marketdata.core.tests "[fixing]" fb3c5a4f31
FX names that name, after 7 of 7 same fb3c5a4f31
FX names that round trip, after 7 of 7 same fb3c5a4f31
Corpus names that name, after 64 of 158 (interest rates 57, FX 7) same fb3c5a4f31
ores.marketdata.core suite, after the FX work 1472 assertions in 346 cases ores.marketdata.core.tests fb3c5a4f31
codegen suite, after the FX work 961 passed pytest projects/ores.codegen/tests fb3c5a4f31
The two GBP-EUR directions 122 shared dates; product 0.9997 median, 0.9964 worst a walk of external/ore/examples pairing FX-TR20H-EUR-GBP with FX-TR20H-GBP-EUR fb3c5a4f31
Power index names the corpus carries 48: 47 with a delivery window, 1 date-only, 0 DST, on one commodity code a walk of external/ore/examples/MarketRisk/Input/Intradaypower/fixings.txt d3325300d5
Power names that name, after 48 of 48, all round-tripped ores.marketdata.core.tests "[fixing]" 2a0d8afef6
Commodity names that name, after 25 of 25, all round-tripped same 2a0d8afef6
Corpus names that name, after the power and commodity work 137 of 158 (interest rates 57, power 48, commodity 25, FX 7) same 2a0d8afef6
ores.marketdata.core suite, after 1556 assertions in 350 cases ores.marketdata.core.tests 2a0d8afef6
Instrument-key coverage, after 485606 of 485606 keys, unchanged ores.marketdata.core.tests "[oresmd]" 2a0d8afef6
Power index names ORE's grammar refuses 0 of 48 the same walk, reading the tail by token count as parseIntradayPowerIndex() does d3325300d5
Power windows that are one hour wide 47 of 47, contiguous and half-open, 01:00 to 24:00 at the widest the same walk d3325300d5
Commodity fixing rows whose period is not the observation month 711 of 928 a walk of external/ore/examples/Products/Input/fixings.csv d3325300d5
Generic names that name, after 2 of 2, both round-tripped through a URI and back ores.marketdata.core.tests "[fixing]" 7b3918b6fe
Corpus names that name, after the generic work 139 of 158 (interest rates 57, power 48, commodity 25, FX 7, generic 2) same 7b3918b6fe
ores.marketdata.core suite, after the generic work 1641 assertions in 378 cases ores.marketdata.core.tests 7b3918b6fe
codegen suite, after the generic work 990 passed pytest projects/ores.codegen/tests 7b3918b6fe
codegen suite, after the declared-case check 992 passed same 9edae76203
Declared oresmd test cases no template emitted, before 22 of 198 (power 11, generic 11) pytest projects/ores.codegen/tests/test_oresmd_spec_test_cases.py, run against the same generated files at 7b3918b6fe~1 9edae76203
Inflation, equity and security names that name, after 17 of 17 (inflation 7, equity 8, security 2), all round-tripped ores.marketdata.core.tests "[fixing]" 38f63cb9b6
Corpus index names that name, after the last three classes 156 of 156, with the 2 market-data keys counted apart and refused same 48eb0e27aa
ores.marketdata.core suite, after the last three classes 1764 assertions in 394 cases ores.marketdata.core.tests 48eb0e27aa
codegen suite, after the last three classes 992 passed pytest projects/ores.codegen/tests 48eb0e27aa
Component file lists, the marketdata parts and codegen up to date, rc 0 for each regenerate_cmake_component_files.py --component <name> --check 48eb0e27aa
ores.marketdata.core suite, after the review fixes 1770 assertions in 398 cases ores.marketdata.core.tests 509bb244d7
ores.marketdata.core suite, after the pull request review 1771 assertions in 398 cases same a4fb8923f8
Corpus names, after the review fixes 156 of 156 index names, 2 keys pinned apart ores.marketdata.core.tests "[fixing]" 509bb244d7

The test binaries are under build/output/linux-clang-debug-make/publish/bin/. The per-class table is in the WARN output of the [fixing] cases, printed on every run rather than asserted in prose.

13. Notes

The curve-configuration CHECK is a different rule from the index-name one. requires_tenor says whether every index name ORE writes for a family carries a tenor, so that the family is never written bare. The CHECK constraint on ores_synthetic_ir_curve_generation_configs_tbl says which (family, tenor) pairs a synthetic curve may be configured with, and the two disagree for shibor, taibor and tiie: the CHECK wants tenor = '' for them, and the corpus writes CNY-SHIBOR-3M, TWD-TAIBOR-3M and MXN-TIIE-28D. The old is_overnight() kept both rules in one predicate, which is why the hand-kept family list in the parser tests called those three overnight and failed once the corpus was measured.

A family in the enum is not a family synthetic can configure. The same list is written into projects/ores.synthetic/modeling/ores.synthetic.ir_curve_generation_config.org three times – the membership CHECK, the tenor-combo CHECK and a static_combo – and it generates the SQL from there. None of the fifteen families this task adds is in it, so a synthetic curve cannot be configured for them. That is a decision for that component, not a consequence of this table.

ftiie is in the enum and in a migration but not in create/. git grep ftiie finds it in the marketdata enum and in synthetic_ir_curve_generation_configs_add_ftiie_migration.sql, and finds nothing under projects/ores.sql/create/ or in the synthetic model. A database created from create/ therefore rejects a family a migrated one accepts. Recorded here because this task walked the same list; it belongs to ores.synthetic.

The refused fixing file was invisible to every count. Its two names, USD-FedFunds and USD-LIBOR-3M, also appear in files the reader could read, so refusing it changed no total. Only a test that asks about the file can see it, which is why the coverage test asserts on refused payloads and not only on names.

The semicolon separator is read for market data too. tokenize() is shared by parse_fixings() and parse_market_data(), and it normalises separators rather than detecting them, so adding ; widens both readers. Measured: none of the 66 market payloads the coverage test selects contains a semicolon, so the corpus measures the same either way; the widening is the design's own shape rather than a concession to one file, and it is the shape a producer's semicolon-delimited market file would need.

One line of the PlantUML diagram is not this task's. The index_family enum in ores.marketdata.puml was missing ftiie, which the enum has held since an earlier change, and the file lists the enum, so the gap was closed while the table was being extended. Measured: the enum declares 38 values and the diagram now lists the same 38.

A type-gated key is gated by the table now, not by the template. The section took only a key and the template hard-coded quote. It takes a type column; a row that names none is gated on quote, which is what the interest-rate table's one row means.

Three classes do not emit the Reject keys table their model carries. equity (delegate_function), credit and commodity (inline_delegate) route their query validation through the shared check instead, so the rows added to their tables are documentation and the shared check is what refuses the keys. That is pre-existing – their other rows are equally unenforced – and it is why the two new keys went into the shared check as well as the six tables that do emit.

The reciprocal figure took two attempts. The corpus's two GBP-EUR directions were first recorded as reciprocal to six decimal places, from a single sampled date; the correction then read the median of the absolute deviation as a product and said 1.0003. Over all 122 shared dates the median product is 0.9997 and the worst 0.9964, so the two agree to three decimal places and not more, and slightly below one. The decision not to canonicalise does not rest on the precision: two values still land on one date.

A test case a model declares is invisible until a template block emits it. The two fixing-only classes each had eleven: power declared its Projections, Round-trip and Rejection rows in the identity change and no {{#power_*}} block existed to render them, and generic's rows were written beside them in the same state. Neither the C++ suite, the drift check nor the coverage gate said so, because nothing that runs reads a spec's * Test cases table; the table is input to mustache and to nothing else. test_oresmd_spec_test_cases.py now loads every model through the generator's own loader and requires each declared description to appear as a TEST_CASE in the generated sources. Run against the tree before the wiring it names all 22 rows.

The delivery key is gated in the template, because the table cannot reach it. The Type-gated keys table is emitted only for the classes that build a validate_<class>(), so a class that validates inline gets no gate from it, which is the codegen gap the models record for delivery. The gate therefore lives in the shared {{#has_delivery}} block, where every class that names the field passes through it, so commodity, power and security all refuse a delivery off a fixing and the claim that no class accepts the key and drops it is true of all three.

One component file list is stale on main, and it is not this task's. regenerate_cmake_component_files.py --all --check reports projects/ores.workflow/api/src/component_files.cmake stale at 48eb0e27aa, and git diff origin/main shows this branch does not touch ores.workflow at all, so the drift arrives with main. Every component this task edits is clean: the four marketdata parts and ores.codegen each report rc 0 on the same check. Fixing another component's generated list here would put an unrelated regeneration in this pull request, so it is recorded rather than fixed.

14. 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
     

15. PRs

PR Title
#2355 [marketdata,codegen] Name the last three fixing classes
#2353 [marketdata,codegen] Name the generic fixing index names
#2346 [marketdata,codegen] Name the power and commodity fixings
#2340 [marketdata] Carry an FX fixing's source and the pair as quoted
#2336 [marketdata] Give every interest-rate fixing index name an identity

16. Review

# Comment summary File Decision Notes
1 Six added comments narrate what the code used to be rather than what it is templates, tests, market_data_parser.cpp Accepted Fixed in 0d8b0bdc8a; each states the present rule
2 The first commit's subject is 82 characters, over the documented 72 -- Accepted Rebuilt at 70 characters
3 record_index_spelling and record_index_name_spelling are one shape with the comparison swapped oresmd_projections.cpp Declined The two key spaces spell differently on purpose: an ORE key segment is case-folded, an index name is compared exactly. A shared helper needs a mode flag, which hides the difference rather than removing it
4 aliases is a comma-joined cell split in the loader org_loader.py Declined A second table for two aliases is more structure than the data holds
5 index_name_ir() carried eleven lines of comment oresmd_projections.cpp Accepted Trimmed to four in 0d8b0bdc8a
6 is_fixing_payload and named_as_fixings duplicate a predicate corpus_files.hpp, classification_series_classifier_tests.cpp Declined They select different sets: a payload a reader feeds, against a census of the whole tree that includes ORE's own outputs. Both say which
7 Untracked .misspell-fixer.3* byproducts are in the tree -- No action None remain. The only .misspell-fixer* file is the tracked ignore file; the numbered ones are transient within a run
8 "Five fixing index classes remain", and then lists seven task_clean_marketdata.org Accepted Corrected to seven
9 The interest-rate class is called "half" of the key space; it is 36% of the names this task Accepted Both places say "third"
10 Acceptance "a corpus-driven test whose allowlist is empty" is not met oresmd_fixing_coverage_tests.cpp Accepted The task stays STARTED and the per-class list is the record; the analysis carries the update. Only interest rates are closed
11 The import creating a series for every fixing row is neither delivered nor tested import_service.cpp Accepted Recorded in Result: the import still stores the index name in the series qualifier and never calls from_index_name, which is the identity cutover's work
12 The spec said the two spelling mismatches were AONIA and TONAR this task Accepted Corrected: AONIA has always resolved, the family token being matched case-insensitively; the second spelling is ESTER
13 The Notes paraphrase inverted requires_tenor this task Accepted Corrected
14 ; widens the shared tokenize(), so market data reads it too market_data_parser.cpp Accepted Measured: no selected market payload carries a semicolon, so the corpus measures the same. Recorded in Notes and in the pull request's limitations
15 The PlantUML edit adds ftiie, which is not this task's ores.marketdata.puml Accepted Disclosed in Notes. The file lists the enum, and the enum declares 38 values; leaving one out keeps the diagram wrong
16 The commit message counted five lost round trips where there are six -- Accepted Corrected in the rebuilt commit
17 source is accepted and dropped by equity, credit and commodity, whose Reject keys tables are not emitted equity/credit/commodity_quote_type.org, ores.cpp.oresmd.parser.org Accepted Found by both axes. The keys joined the shared check those three do run, which was renamed validate_no_foreign_keys for it; a case covers the three. The underlying codegen gap is recorded in Notes
18 from_index_name re-implemented split_index_name's tokenizer oresmd_projections.cpp Accepted Extracted split_on_dash, which both use
19 The loader comment narrated how the section was first used org_loader.py Accepted Present tense
20 The spelling rule was stated three times parser, projections, model Accepted One statement per site
21 fx_fixing_has_no_quote_key is negative-only oresmd_oresmd_projections_tests.cpp Declined The same table's positive rows fail if to_quote_key ever returned nullopt, so the pair is meaningful
22 A positive assertion sat inside the FX rejection case oresmd_oresmd_projections_tests.cpp Accepted Moved into the case that owns the direction
23 The reciprocal figure was six decimal places this task, fx_quote_type.org Accepted, then corrected again The first correction read the median of the absolute deviation as a product and reported 1.0003; the median product is 0.9997. Caught by the second Spec pass, which also found the figure restated in three places
24 The review ran while the tree was still being written -- Accepted Process: the axis pinned a revision and re-verified, but the worktree should have been still. Findings re-checked by hand
25 The FX round-trip boilerplate justified having no key-readback comparison with a quote's reason, which is false for a fixing ores.cpp.oresmd.parser_tests.org Accepted The block's note now reads true for both, and says the exact readback is the coverage tests' claim
26 The new class-refusal loop named six own-table classes and tested two of them same Accepted The loop covers every class but FX, ten assertions
27 validate_no_foreign_keys' comment called index and metric keys "one asset class owns" ores.cpp.oresmd.parser.org Accepted Reworded: the six belong to other classes, and for the three that delegate this is their whole query validation
28 split_index_name's comment ended up above split_on_dash oresmd_projections.cpp Accepted Moved back above the function it describes
29 Two model-driven reject rows duplicated the hand-written case fx_quote_type.org Accepted Rows removed; the type-gated table still declares the rule and the case keeps the positive assertion. Case count 346 to 344
30 The type-gated template line had grown to 111 columns ores.cpp.oresmd.parser.org Accepted Split
31 Both commit bodies run past 72 columns -- Recorded, not fixed Measured: 22 of main's last 40 non-merge commits also exceed it, and correcting these means force-pushing a branch whose reviewed revisions are already recorded. Titles are 71 and 67 and the trailers are complete
32 source and the pair are checked for shape, not against a currency list -- Recorded as a limit The library has no reference-data dependency by design, as its own header states; the model brief says what is checked. Tightening it belongs to the migration that gives the library one
33 Edit-history narration: "four ways now", and a list of which classes are absent oresmd_fixing_coverage_tests.cpp accept Reworded to present state. The reviewer cited a template as the source; no such template exists and the test is hand-written, so the fix landed in the file itself.
34 Two delivery-shape helpers differing only in how they count parts ores.cpp.oresmd.projections.org accept A shared is_number() is extracted. The two predicates stay two, because a power window and a commodity month are different grammars and are not the same predicate called twice.
35 is_digits duplicates one in ores.utility same decline That copy is file-local in a .cpp, so the projections translation unit cannot reach it.
36 An assertion whose comment claims more than it pins ores.cpp.oresmd.projection_tests.org accept The comment now says what the assertion covers.
37 A ragged 143-column comment wrap in the template and its output ores.cpp.oresmd.projections.org accept Caused by an earlier edit of mine joining two lines; the wrap is restored.
38 model.org keeps a stale #+updated after gaining a row model.org accept Bumped, and commodity's model with it.
39 delivery accepted and silently dropped by every class but commodity and power every oresmd model and the parser template accept The real defect, and the same one the FX source work closed. delivery joins validate_no_foreign_keys and the nine other Reject keys tables, a case walks every class but the two that name it, and commodity moves onto its own reject list so the shared check stops refusing the key commodity owns.
40 The task's Result section is stale and contradicts its own Status this task accept Superseded by this PR's result row below.
41 analysis.org carries no update for the power and commodity commits analysis.org accept Recorded as the next step before the PR, since the section still counts power 48 as unnameable.
42 Both variant doc comments still say "seven" identifiers and requirements templates accept The count is dropped rather than raised to eleven, so it cannot go stale again.
43 The DST flag is refused in a spelling ORE matches case-insensitively projections and the power model decline Deliberate: a flag written any other way would not read back as it arrived, which is the test the rest of the class's refusals are held to. Already recorded in the model, and the fix if the corpus ever writes another spelling is a delivery_spelling field, the FX source_spelling precedent.
44 inbox.org gained index rows for two captures unrelated to this work inbox.org decline The regenerator rewrites a bucket index from the directory, so it lists captures the index had drifted from. No capture file is touched by this branch.
45 The explicit DST check in the power arm overlaps the shape check ores.cpp.oresmd.projections.org accept A comment now says why it is deliberate: the shape check runs on the lower-cased tail, so it cannot tell ORE's spelling from an invented one.
46 parse_power accepts type=quote, which then projects nothing the power arm and its cases decline The behaviour is already pinned by from_index_name_rejects_power_names_that_are_not_the_shape, and IR has the same property: its default type is quote and a quote with no quote key names nothing. Rejecting the type would need a gate no class has.
47 reject_if_present("power", "model", ...) sits below the id setup ores.cpp.oresmd.parser.org decline Cosmetic, and generated that way for every inline class rather than for power: the model reject comes from the shape-slot branch that follows the type parse.
48 Commodity's reject list is a hand copy of the shared check minus delivery the commodity model and the parser template decline It is the codegen gap the task already records, now confirmed by the reviewer. A shared helper taking an allowed set is the fix, and it belongs with the rest of the validation-mode work rather than here.
49 The shape checks admit a calendar-invalid date and an out-of-day window ores.cpp.oresmd.projection_tests.org accept Deliberate and already in the PR's limitations; a comment on the case now says so, so a reader is not surprised by what passes.
50 The fixes made after the two-axis review were not re-reviewed the branch accept Agreed and already stated in the PR's testing limitations. The two comment changes here are the only code touched since, and they are comments.
51 Stale #+updated: on the four archetype sources this branch edits, and on analysis.org the four oresmd templates, analysis.org accept All five bumped to 2026-09-28. The field is read by no template, so nothing regenerates from it; the nine models with the same defect were fixed in a7bfbc2f91.
52 generic_quote_type.org's #+description: is 124 characters, over the documented 120 generic_quote_type.org accept Shortened to 108. Siblings exceed it too, and no tooling enforces it.
53 The power and generic template blocks are byte-identical; one shared key would carry both ores.cpp.oresmd.parser_tests.org, projection_tests.org decline The per-class block is the file's idiom for all twelve classes, and each block's position reproduces the hand-crafted file it replaced. A shared list would need a class discriminator and would stop a reader finding one class's cases by name.
54 The name-spelling case re-lists the same eleven class URIs as the delivery case ores.cpp.oresmd.parser_tests.org decline The refusal is per key and each case reads as the key it pins. The list is data in a test rather than logic, and neither list can drift without a class being added, which the corpus census already fails on.
55 The new check has no scripts/check_*.py entry point test_oresmd_spec_test_cases.py decline It runs in the codegen suite, which the pull-request checks run. The sibling scripts are invoked by path because they are gates over the tree rather than pytest cases.
56 ORE is claimed to build the GenericIndex from the rest of the name, four times this task, generic_quote_type.org, analysis.org, the projections template and its case accept The claim is not verifiable here – ORE's source is not vendored – and it is removed. The prose now states what the corpus shows: the name is the identity, and no other key exists for either series.
57 "The path segment is upper-cased" is backwards this task accept Reversed: the identifier's field holds the name upper-cased, and to_uri() lower-cases the segment as it does for every class.
58 "The class adds no template branch of its own" is false this task, Result accept It adds one guard slot to the parser's field list for a query key the parser carries raw. The sentence now says that.
59 The codegen-suite measurement cites 992 at a commit whose suite is 990 this task accept Split: 990 at 7b3918b6fe and 992 at 9edae76203, the commit that added the two-case file.
60 The census table's "seven shapes" and "(unclassified) 4" contradict the new section this task, oresmd_fixing_coverage_tests.cpp accept The table is marked as the first reading and its last row points at the split. The coverage test no longer states a shape count, so it cannot go stale again.
61 Acceptance 1 (an empty allowlist) and 3 (the import creates a series) remain unmet this task no action Acknowledged and unchanged: the Status stays STARTED, rows 10 and 11 record both, and the three remaining classes are the next step.
62 The diff also emits power's eleven declared cases and adds the cross-model check the branch no action Disclosed in the commit bodies and the pull request's changes. The check is what keeps the repair from being undone.
63 name_spelling is accepted with a value that is not a case variant of the name the parser template accept The parser refuses it now: the identifier would otherwise hold two identities, the one its entity names and the one its index name reads back as. A case covers the refusal. FX's source_spelling is not checked and is left as merged, so the two differ until the cutover.
64 type=quote is accepted on a generic URI and projects nothing the parser template decline The same property power has, and the same one the previous review declined in row 46: a class with no quote key names nothing, and rejecting the type would need a gate no class has. The codegen gap is recorded in the Notes.
65 from_index_name hard-codes substr(8) for the prefix length the projections template accept A named generic_prefix carries the prefix, and the test and the offset both read from it.
66 to_upper on a non-ASCII name depends on the shared helper's behaviour the projections template recorded as a limit detail::to_upper is the helper every class's entity already passes through, and the corpus is ASCII. Widening the input belongs to the cutover.
67 The check matches the literal TEST_CASE("<description>" test_oresmd_spec_test_cases.py accept A comment states the coupling: a template that reformats the call breaks the check rather than passing it.
68 The commit bodies run past the documented 72 columns this branch accept The three original messages are re-wrapped by a reflow over the branch; the two written after this round are wrapped as they are written.
69 The loader's new comment claimed a last-item marker nothing emits org_loader.py accept The comment now says what the template does: one code per row, comma included.
70 The prefix-and-tail shape is duplicated between commodity and security the projections template accept split_code_and_month and index_name_code_and_month carry it, and both classes' arms and emitters call them.
71 market_data_key names a class label, while the vector holds keys the coverage test accept Renamed market_data_key_class.
72 "The one index-name prefix pair that is a bare token" names nothing the projections template accept The comment now says the class has no prefix to strip.
73 The inflation rule holds on the read side only the parser and projections templates accept Both sides use the same generated table: a fixing URI whose code the model does not carry is refused as it is parsed, and an identifier holding one has no name to write.
74 A rejection case also asserts a positive routing the projections tests accept The assertion moved into the inflation case that owns the routing.
75 delivery is accepted on a security quote and dropped the parser template and the three models accept The same defect class the previous review closed elsewhere, and it is closed for every class that names a delivery: the gate is in the shared block, since the Type-gated keys table reaches only the classes that build a validator. Each of the three models declares the rejection.
76 The inflation table is bypassed where an identity is minted the parser and projections templates accept Both directions are gated on the same table, so neither mints an identity the other refuses.
77 The census reports its population without asserting it the coverage test accept It now asserts that the classes and the pinned keys add up to the corpus's 158 names, so a payload that leaves the walk fails instead of shrinking the total quietly.
78 The acceptance cites the wrong review rows this task accept Row 11 alone records the import gap; row 10 records the allowlist, which is met.
79 The classifier is narrower than the projection for a security name the coverage test accept It matches any BOND- tail, as the projection accepts one.
80 The emitters write names the reader would refuse, a code carrying a dash and a ticker carrying a slash the projections template accept Both are refused now, which is the rule this round applied to inflation: the write side refuses what the read side refuses.
81 The parser is stricter than before about a delivery on a non-fixing URI the parser template no action Intended. Every delivery URI the models and the tests carry is a fixing, and no oresmd URI is stored yet, so nothing depends on the old behaviour.
82 The inflation code list is emitted in two translation units the parser and projections templates decline One model table, two generated arrays. They cannot drift, and a shared header for one list is more machinery than the list holds.
83 The delivery refusal is emitted once per class the parser template decline The template holds the check once; what repeats is generated output, and a runtime helper for three generated lines adds indirection rather than removing it.
84 No case covers a delivery month the parser accepted and the writer refuses the projections tests accept The security shape case now pins that a month which is not a contract month projects no name; the lower-case inflation code was already pinned by the dash-less case.

17. Result

The interest-rate third of the fixing key space is closed. from_index_name names all 57 distinct interest-rate index names the corpus carries, and each projects back to the name it arrived as; before this work it named 34 and round-tripped 28. That is 57 of the corpus's 158 names, so most of the key space is still open, and none of it is wired: import_service still stores the index name in the series qualifier and never calls from_index_name, so the acceptance criterion about the import creating a series for every fixing row belongs to the identity cutover.

Fifteen families joined the enum: BBSW, CIBOR, CMS, HIBOR, NIBOR, PRIBOR, REPOFIX, STIBOR, CAMARA, CZEONIA, DKKOIS, EONIA, FedFunds, SIFMA and SIOR. The two spellings ORE writes differently, ESTER for estr and TONAR for tona, became aliases in the model, and the identifier now carries the spelling a name arrived under so it reads back as it came. The tenor rule left the hand-written oresmd_index_family_utils.hpp for the Index family table, which the generated requires_tenor() and both suite's family lists are built from.

A curve identifier drops the tenor of an overnight family, because the tenor belongs to the curve key; a fixing identifier keeps the tenor the index was published at, because USD-SOFR and USD-SOFR-3M are two series. role is what tells the two apart, and both readings are pinned in the projections tests.

The FX class is closed with it. An FX fixing carries source, lower-cased, and source_spelling for the token as ORE wrote it, both meaningful only when type=fixing; the pair stays the pair as published. All seven FX names the corpus carries name and round-trip, from none, and every other asset class refuses the two keys rather than dropping them. The corpus total is 64 of 158.

The new coverage test walks every fixing payload with the real reader and reports the table per class. It found a fixing file the reader refused: CurveBuilding/Input/fixings_bondyieldshifted.csv is semicolon-separated, is the fixingDataFile its example names, and is a file ORE reads. The reader now reads it, and every one of the 66 payloads is walked.

The power and commodity halves are closed with the next change. ORE states the intraday power index grammar in its own parser rather than leaving it to be inferred, and its commodity parser reads a contract month the same way, so a fixing's delivery period becomes a delivery field of its own rather than a point: point is what a quote or a vol names, and a fixing whose coordinate is a field stays scalar, as the interest-rate tenor already does. Power becomes a class of its own because ORE accepts a bare POWER-ICE:PDQ and a bare COMM-ICE:PDQ, and one class could not write both names back. Power is 48 of 48 and commodity 25 of 25, all round-tripped, which takes the corpus total to 137 of 158. delivery joins the keys the other classes refuse, so no class accepts it and drops it.

Still open, each its own decision: inflation (7 names), equity (8) and security (2). The two GENERIC-MD/EQUITY_OPTION/... rows the corpus puts in a fixing column are errors rather than fixings to model, so the target is 156 of 158 rather than 158, and the census counts them as market-data-key so that the population it walks stays whole.

The generic class is closed with the next change. ORE states the prefix in a parser of its own, so generic joins power as a class the corpus's prefix names: the identifier carries the index name upper-cased and the case ORE wrote it in beside it in name_spelling, which is the FX source_spelling rule. Both names the corpus carries name and round-trip, the second of them through the %23 its # becomes in a URI, and 2 of 2 takes the corpus total to 139 of 158.

The fixing key space is closed with the last three classes. Every class the corpus puts in it resolves and round-trips: interest rates 57, intraday power 48, commodity 25, equity 8, inflation 7, FX 7, security 2 and generic 2, which is 156 of the 158 names in a fixing column. The other two are market-data keys in a fixing column, and the projection refuses them, so the target is 156 of 158 rather than 158: the owner reads them as errors rather than as fixings to model.

The corpus-driven coverage test now names the classes with an empty allowlist, so a class that stops resolving fails it, and the two keys are pinned in a bucket of their own and asserted to project to nothing. Nothing was dropped to make the list empty: the fixing space and the keys misfiled inside it still add up to every distinct name the corpus carries.

What remains is not this task's. The import still stores the index name in the series qualifier and never calls from_index_name, so the acceptance criterion about the import creating a series for every fixing row belongs to the identity cutover, as Review row 11 records.

Emacs 29.3 (Org mode 9.6.15)