Rating files — authoring and compiling a grid
Stage Abacus says what S2 signs out; this page says how a rate actually gets into the engine. It is the mechanics the abacus configure skill encodes: where a rate lives, the four calls that publish it, the grid grammar, and the five ways a grid compiles cleanly and prices wrongly.
Provenance and status. Everything here comes from the PC 9951 run on the Ablera dev database, 31.08.2026 — authored, compiled and priced, not inferred (Product Configurator § SRC, full text in the extract). It has not been re-measured on the customer estate, except the version-stage counts in § 2 dated 03.09.2026. Where it disagrees with a later sweep of the running system, the sweep wins and this page is corrected — the belief order of Agents Memory.
1. Where a rate lives
F Rates are rows in SRD_ANLT.PPA_PRODUCT_FILES — FILE_NAME is the key, CONTENT a JSON CLOB. A cover or a loading does not hold rates; it holds the file name: PPA_PRODUCT_COVERS.COVER_BP_RATING for a cover's base-premium grid, PPA_PRODUCT_LD.LD_RATING for a loading or discount.
F That indirection is shared, not scoped: two products can point at the same file, and editing it changes both. A product with its own tariff needs its own files, named after itself. The only rule the platform enforces on the name is no spaces — a file named after the product you copied from is how you end up editing someone else's rates.
P So the S2 plan names the file set it will create, and the write auditor treats a write to a file another product references as a shared-row write — W3 in Gating, a person every time.
2. The four steps
F Publishing a rate is four calls, and skipping either of the last two changes nothing at all:
| # | Call | What it does |
|---|---|---|
| 1 | saveLdFile |
authors the grid — {"ruleFileLD": {…}}; modifierAmount is a string |
| 2 | saveLdRating |
binds it, with ldFileName plus exactly one of coverId or correctionId; neither is refused outright. Covers are per object, so a cover offered on two objects is two bindings |
| 3 | genRuleTable |
compiles the grids into RATING_TABLE_FILES |
| 4 | ppaProductVersionsUpdate |
versionStage: "DEPLOYED" |
F The engine reads the compiled artefact, never the source grid. A rate that "did not take" is nearly always a missing step 3 or 4, not a bad grid.
F genRuleTable requires the version at DONE or CONFIRMED, so recompiling a live product means reverting it through ppaDoneRevert first. An OPEN/DONE version is not selected; with no matching DEPLOYED version getRates fails (No configured premium…), and with an older matching DEPLOYED version that one is priced — which is why the S2 test asserts the version_id (D113). QA snapshot 03.09.2026: DEPLOYED 1 903 · OPEN 63 · DONE 3.
F ⚠ The compile's cache reset rebinds only on the one process that handled that message; other workers and replicas keep their stale RATING_TABLE_FILES in their own LRU until they receive an invalidateCache. A deploy is not atomic across replicas — the smoke test of Configuration Module § 3 can pass against one worker and miss another.
P The deployer therefore asserts the compile and the cache invalidation, and the S2 acceptance script re-quotes after the invalidation rather than immediately after the deploy.
3. The grid grammar
F rows and columns are lists, and either may name more than one factor. A cover has exactly one COVER_BP_RATING, so a tariff keyed on four things is not four files — it is one grid with a three-factor row axis and a one-factor column axis:
{"rows": ["DEDUCTIBLE", "EV_CATEGORY", "BATT_CLAUSE"], "columns": ["BATT_AGE"]}
The CSV then carries one leading empty cell per row factor in the header and one label cell per row factor on every data row. The row labels are the full cartesian product, catch-alls included — three values plus (Other) on each of three factors is 64 rows, not 27. Live precedent: 3443_LIABCR_1B_9538_EUR, whose rows is ["LOAD_CAPACITY", "VEHICLE_TYPE"].
F A row-axis level set collapses a whole dimension. A tariff whose rows are keyed on a grouping of a factor's values — nine vehicle types falling into four tariff groups — does not need a derived group factor. Write the group as a level set on the underlying factor: CP_VEH_TYPE in (PASSENGER;MINIBUS_9_16;TRUCK_UP_35). Four such rows plus a catch-all, crossed with a seven-value clause axis and a six-band numeric age axis, is one file of 41 rows carrying 168 published rates.
P This qualifies the escalation rule of Stage Abacus. "A grid cannot hold a derived factor, therefore it is a filter → gate Hd to the Development module (id source)" is true of a factor that must be computed (a count, a cross-cover minimum), and not true of one that is merely a grouping of an existing factor's values — that is a level set, and escalating it to code is a needless change request. The tariff analyst reports which of the two shapes it read.
4. The two axis kinds, and their catch-alls
F Categorical and numeric axes are validated differently, and treating them alike either wastes a column or fails the deploy:
- Categorical (
PPA_FACTORS.DATATYPEanything butNUMBER/IV) — every live multi-row grid ends the axis with a catch-all. The form is<axis> (Other), with noin; a value label is<axis> in (VALUE); a level set is semicolon-separated. - Numeric (
NUMBER/IV) — the bins must exactly tile the axis andgenRuleTableasserts it (Nodes not consistent with bins!). Anchoring the ends with(<= n]and(> n)completes the tiling, so no(Other)is needed and adding one is what breaks it.CAR_AGE (<= 3], (3 - 5], (5 - 7], (7 - 10], (10 - 13], (> 13)compiled and priced all six bands. Spacing is free —get_numbertakes the first number in the label.
P The catch-all's rate is a decision, not a formality, and it goes the opposite way on the two kinds of grid: on a cover grid make it fail expensive, because an unreachable cell that silently prices at zero is a worse failure than one that overcharges; on a loading or discount grid it is 0, because there the zeros are the condition.
F ⚠ A categorical axis whose level values are all digits fails to compile. genRuleTable dies with 'numpy.float64' object has no attribute 'upper': the compiled CSV is reloaded with a bare read_csv and no dtype=str, that axis's column holds digit-strings for its own rows and NaN for every other row in the concatenated table, so pandas infers float64 and get_nodes calls .upper() on it. The exception is re-wrapped as Deploying configuration failed with exception …, so it does not name the axis. The tell is DATATYPE LIST with every value matching ^[0-9.]+$ — check it before deploying.
P The fix is non-numeric level codes — D000 / D200 / D400, with the readable amount in PRICING_FACTOR_VALUE_NAME, as used on 9951. Adding an (Other) entry also stops the coercion, but on a cover grid it obliges a rate the document does not give. This is the first of the three renames Stage Abacus § SUM-6 allows to contradict the specification.
5. Conditional loadings
F A conditional LD is a grid, not a conditional single row. The obvious shape — singleRow with applyAlways: false and ruleItems — does not compile: without defaultValue the validator refuses, with it genRuleTable dies on an empty ruleTable. Files in that shape exist in the estate and nothing recompiles them.
F Express the condition as a one-dimensional multipleRow grid over the driving factor, a single row labelled =>, with the zeros carrying the condition: the LD exists on every policy and contributes nothing where it does not apply. A numeric driver uses the interval form — NUM_PPL_GRP(9-24] is "10 to 24" for an integer count, which is how >= is expressed when the operator set has only < and >.
F ⚠ rows must be an empty list [], never [""]. A "" axis name fails as a bare Internal error: '', which reads like a missing factor and is a malformed axis.
F ⚠ An LD authored applyAlways: true applies to every policy, unconditionally, and never errors. Four such loadings on one product netted by cancellation to exactly the right total — a configuration wrong in four places producing the correct premium. P So a reconciling total is not evidence: the worked examples of Stage Abacus § SUM-5 must include at least one case that separates the loadings, and the S2 acceptance asserts each LD's contribution row, never only the roll-up.
6. Before compiling: the vocabulary check
F A grid keyed on values the engine never receives compiles perfectly and then misses at lookup. Two failure shapes, and only one is loud:
| Overlap between the grid's level sets and the source column's actual contents | What happens |
|---|---|
none, and no (Other) |
every lookup misses → Default value is required when rule is incomplete! — loud, and easy to trace once known |
partial, with an (Other) |
the unmatched values fall to the catch-all and are priced as if the loading did not apply — silent, and it under-charges |
F Free-text-ish columns are where this lives: the same marque appears as MERCEDES BENZ, MERCEDES-BENZ and MERCEDES, and a level set naming one of them quietly exempts the others. Case is not the hazard — levels and values are upper-cased on both sides. Spelling, punctuation and abbreviation are.
P So the S2 plan carries one assertion per categorical axis: the level sets are compared against a GROUP BY of the real source column before the compile, and any value in the source that no level set names is either added or deliberately assigned to the catch-all, in writing.
Challenges
- C The
applyAlwaysand vocabulary checks above are assertions the estate has no tooling for. Whether they belong in theabacus configureskill or in a standingconfiguration.verifiercheck is decided with the first abacus-only slice. - C Cache invalidation across replicas is asserted here but the invalidation call has not been exercised from the platform. First deployment to a multi-replica environment settles what the deployer must send.
Related: Stage Abacus · Quote Verification · Write Envelopes · Shared Vocabulary