Catalogue & semantic model¶
This page walks one real example end to end — a small professional-services P&L — so you can see how every layer connects. By the end you'll be able to read any metric back to the SQL it runs and the field a client receives.
The data layers¶
Précis holds your data in a fixed pipeline. Each layer has one job; keeping them separate is what lets you change one without breaking the others.
your source → staging.* → live.* → semantic.* → catalogue
(transient) (landed) (engine shape) (what's exposed)
| Layer | Owner | Objective |
|---|---|---|
| staging | platform | Transient landing target for a load and the source of the atomic swap. You never read or model it; it mirrors live's shape. |
| live.* | you — instance/live/*.sql + bindings |
The canonical landed copy, at the grain you ingest. Holds exactly what you load, nothing more. |
| semantic.* | you — instance/semantic/*.sql (platform for trivial pass-throughs) |
Reshape live into the columns and grain the engine expects. Business meaning lives here. |
| catalogue | you — instance/catalogue/*.yml |
Declare what's exposed: metrics, dimensions, statements. |
Read it top-down: ingestion fills staging and swaps it into live; semantic
views turn live into engine shape; the catalogue names semantic objects. The
engine reads only semantic.* — never live or staging directly. That
indirection is the point: you can reshape live (add a column, change the grain)
without editing a single metric, because the semantic view absorbs the change.
The catalogue addresses semantic by name, not by schema. A domain's
source_view and a dimension's source.table name a semantic object
(v_pnl, dim_account) — the platform resolves it in the semantic schema. A
live.-qualified reference is rejected at load: if the catalogue could point
the engine at live, the indirection above would be a fiction. And when a
dimension or fact needs no transform, you do not write a pass-through view —
the platform materialises the trivial semantic.<x> AS SELECT * FROM live.<x>
for you, so the catalogue always has a semantic object to name without the
boilerplate.
Federated domains are the one exception. A domain with backend_kind: ibis
reads its facts in place on your warehouse instead of from ClickHouse, so its
source_view addresses that foreign backend rather than semantic.*. Its
dimensions are still resolved against ClickHouse semantic.*. See
ClickHouse domain vs. Ibis federated domain.
Designing the staging/live grain¶
Three rules decide what to land and at what grain — get these right before you write a binding:
- Land only the grain you need.
staging/livedefine the grain Précis stores and the grain it serves the engine from. Don't replicate your whole warehouse: if a DWH or other suitable source already holds the transactional long tail, leave it there and read it through a federated domain rather than landing detail you'll only ever aggregate. - The live grain is the partition grain. A period load runs
REPLACE PARTITION '<period>'— it atomically replaces everything at that partition grain (see Ingestion). So if one table holds several plan scenarios and you want to reload them independently, give each scenario its ownlive/stagingtable; otherwise every load replaces all scenarios at once. Then union the per-scenario tables back together in the semantic layer. - Reshape in semantic, not in live.
liveis the shape you ingest;semanticis the shape the engine needs. Renames, joins, scenario unions, excluding non-postable rows, denormalising an attribute — every transform belongs in a semantic view, never in a contorted landed table.
The two layers¶
You describe your model in two layers, kept separate on purpose:
- Semantic layer — SQL views that say what your data means: what a P&L row is, which accounts are revenue, how a period rolls up. This is where business logic lives.
- Catalogue — YAML that says what gets exposed: which metrics, dimensions, and statements exist, and how each is computed and formatted. This is the surface clients see.
The catalogue sits on top of the semantic layer and refers to it by column name. The names must line up — and the engine checks this at startup, so a mismatch is an error you see immediately, not a wrong number you discover later.
instance/
catalogue/ # YAML — what gets exposed
pnl.yml a domain: its source view + metrics
dimensions.yml the dimension registry (how you slice)
statements.yml named collections of metrics
semantic/ # SQL — what the data means
dims/ dimension master-data views
views/ fact/metric views the engine queries
This directory is your configuration — it describes your model and ships with your deployment, separate from the installed package.
The example below uses a services-business model (revenue, delivery costs, margins, headcount). Substitute your own accounts and metrics; the mechanics are the same.
Layer 1 — the semantic views¶
A dimension view¶
A dimension is a thing you slice by — account, cost centre, period. Each owns a
master-data view under semantic/dims/. Here is the whole account dimension:
-- semantic/dims/dim_account.sql
-- The chart of accounts, from the ERP master. Excludes non-postable header rows.
SELECT
account_code,
account_name,
account_type,
fs_line -- which financial-statement line this account belongs to
FROM live.dim_account
WHERE is_active = TRUE
AND account_type != 'HEADER'
ORDER BY account_code
The columns it exposes — account_code, account_name, account_type,
fs_line — are the names the catalogue will refer to. Remember fs_line: the
revenue metric uses it.
A fact view¶
A fact view is what the engine actually queries for numbers. It produces one tidy table at a known grain — one row per (account, cost centre, period, scenario) — with a measure column. Here is the P&L view, abridged to its shape (the full view also unions plan/forecast scenarios and statistical sections like hours and FTEs):
-- semantic/views/v_pnl.sql (abridged)
WITH unified AS (
-- Actuals, from the posted general ledger
SELECT
account_code AS account,
cost_centre AS cost_centre,
period AS period,
'ACTUALS' AS scenario,
SUM(amount) AS amount
FROM live.fact_gl
GROUP BY account_code, cost_centre, period
-- … UNION ALL the budget/forecast scenarios, plus statistical
-- sections (hours, FTEs) — omitted here …
)
SELECT
u.account,
ad.fs_line, -- pulled in from the account dimension
u.cost_centre,
u.period,
u.scenario, -- which dataset this number is from
u.amount -- the measure
FROM unified u
LEFT JOIN live.dim_account ad ON u.account = ad.account_code
The columns this view exposes are the contract the catalogue builds on:
| Column | Role | Used by |
|---|---|---|
account, cost_centre, period |
dimension keys — what you group by | metric dimensions |
fs_line |
an account attribute — what you filter on | the revenue metric's filter |
scenario |
which dataset (actuals, a budget, a forecast) | scenario selection at query time |
amount |
the measure the engine sums | every base metric's source_column |
Layer 2 — the catalogue¶
Binding a domain to its view¶
A domain is a group of metrics that share one source view — the P&L metrics over the P&L view, the pipeline metrics over the pipeline view. Each domain is one catalogue file, and the file names the semantic view it sits on. This one line is the join between the two layers:
# catalogue/pnl.yml
domain: pnl
source_view: semantic.v_pnl # ← every metric below queries this view
dimensions: # which columns of the view you may slice by
- { key: cost_centre, label: Cost Centre, source: cost_centre }
- { key: period, label: Period, source: period }
key: is the catalogue dimension name — the single name clients use in both
dimensions: ["cost_centre"] and filters: {"cost_centre": …}; source: names
the physical view column the engine groups by and filters against. They're
equal here only because the view column is named like the dimension — they
diverge when the column is a raw key (key: cost_centre, source: cost_centre_id).
key must be a dimension defined in the registry below; source must be a real
column in v_pnl.
A base metric¶
A base metric reads the measure column directly, optionally filtered, then
aggregates and formats. Here is revenue:
- key: revenue
label: Revenue
description: "Total recognised project revenue for the period."
calculation_note: "Sum of credit-side journal entries on revenue accounts (fs_line = 'Revenue') from gl.actuals. Stored as negative in the ledger; sign: abs converts to positive for display."
where: # restrict to revenue accounts…
- column: fs_line
op: eq
value: Revenue
source_column: amount # …sum this column…
aggregation: sum # the SQL aggregate over source rows
rollup_method: sum # how aggregated values combine across periods
sign: abs # ledger stores revenue negative; flip to positive
format: currency
fs_group: Revenue # which statement section the metric belongs to
Two of these look similar but answer different questions: aggregation is the
SQL aggregate applied to the source rows (sum, count, avg, …);
rollup_method is how the already-aggregated values combine when periods roll
up — sum for flows like revenue, closing for balances (take the last
period's value rather than adding), avg for rates.
Read it as a query against the source view:
SELECT SUM(amount)
FROM semantic.v_pnl
WHERE fs_line = 'Revenue'
AND scenario = :scenario -- chosen at query time
GROUP BY :requested_dimensions -- e.g. cost_centre, period
Every field traces somewhere: where and source_column reference columns in
v_pnl; sign and format shape the output; key becomes the field name the
client receives.
description and calculation_note carry no engine logic — but they are not
optional polish. list_kpis surfaces both to the client, and an AI agent reads
them to choose which metric answers a question and to interpret the number it
gets back. description is the one-line what this is; calculation_note is
the how it's derived, and any sign or scale gotcha — here, that revenue is
stored negative and flipped by sign: abs. Write them for a reader who can't
see the SQL, because that is exactly the agent's situation.
The where predicate¶
where is a portable filter — a list of structured predicates, ANDed
together. It replaces raw SQL filter strings so the same metric definition works
against a native ClickHouse view or a federated source — a table the
engine reads in place on your warehouse through Ibis, instead of from
ClickHouse (see
Adding metrics & dimensions).
The engine compiles the predicates to whichever backend the source view uses.
where:
- column: account_type
op: in
values: [Revenue, OtherIncome] # `in`/`not_in` take `values:` (a list)
- column: is_intercompany
op: eq
value: false # other ops take a single `value:`
Supported ops: eq, neq, gt, gte, lt, lte, in, not_in,
is_null, is_not_null. The last two take neither value nor values.
where is the only filter grammar — raw SQL filter strings are rejected at load.
A derived metric¶
A derived metric has no source_column — it's a formula over other metric
keys. Each input is aggregated independently first, then combined:
- key: gross_margin
label: Gross Margin
description: "Revenue net of direct delivery cost."
calculation_note: "revenue − direct_cost. Positive means revenue exceeds direct cost."
formula: "revenue - direct_cost" # references two other metric keys
format: currency
fs_group: Margins
- key: gross_margin_pct
label: "Gross Margin %"
formula: "gross_margin / revenue * 100" # derived metrics can build on derived metrics
format: percent
fs_group: Margins
revenue and direct_cost here are the exact keys of other metrics in the
catalogue. A typo is a load-time error, not a silent zero.
A statement¶
A statement is an ordered list of metric keys — what the engine assembles into a financial table:
# catalogue/statements.yml
statements:
pnl:
label: "P&L Statement"
lines:
- revenue
- direct_cost
- gross_margin
- gross_margin_pct
- separator # a visual rule, not a metric
- indirect_cost
- contribution_margin
- sga
- ebitda
- ebitda_margin_pct
Each line is a metric key from the catalogue. Asking for the pnl statement runs
each metric and stacks the results in this order.
How you slice — the dimension registry¶
catalogue/dimensions.yml defines each dimension once: its master-data view, its
key column, its display attribute, and its place in any hierarchy. Every dimension
is one of three kinds: a leaf owns a master table; a derived dimension
reads its members from a column on another dimension's table; a ragged
hierarchy presents several levels as one browsable axis. The account dimension
is a leaf, mapping onto the SQL view from earlier:
# catalogue/dimensions.yml
account:
label: Account
attributes: # the descriptive fields this dimension carries
name: { label: Account Name }
display_attribute: name # which attribute is shown as the member label
source:
table: semantic.dim_account # ← the dimension view from Layer 1
key_column: account_code
attribute_mapping: # attribute → column on the source view
name: account_name
parents: # the hierarchy this dimension rolls up into
fs_line: { source_column: fs_line }
account_type: { source_column: account_type }
The first four fields are the dimension's display contract. attributes
declares the descriptive fields a member carries (here just name);
attribute_mapping wires each one to a real column on the source view. The
names rarely match — attribute name ← column account_name — and keeping
them separate means renaming a column never reaches a client.
display_attribute picks which attribute is shown as the member label, so a
client sees "Consulting Revenue", not the raw key 4000. An optional
sort_attribute (not shown) sets member order — e.g. sort employees by code
while displaying name; omit it and members order by key. Every name in
attribute_mapping, display_attribute, and sort_attribute must be one of
the keys declared in attributes, or the catalogue refuses to load.
parents declares hierarchy bottom-up: every account belongs to an fs_line and
an account_type. Those become derived dimensions — dimensions whose members
are attribute values of another. This is how the revenue metric can filter on
fs_line = 'Revenue' even though fs_line isn't its own table: it's an attribute
of account.
A parent can be derived or a leaf. The parents above name derived dimensions —
fs_line is just a column value on dim_account, with no master data of its own.
But a parent can equally be a first-class leaf: a project dimension can
declare client as a parent (source_column: client_id), where client is a
leaf with its own dim_client table, attributes, and display name. Use a derived
parent when the level is only a grouping label; use a leaf parent when it's an
entity in its own right — one you also want to filter, give attributes, or roll up
further. Either way the parent entry names the dimension and the column on this
table that holds its key.
Multi-level hierarchies. A parent can have its own parent. A cost centre rolls up to a department, and a department to a division:
cost_centre: # the leaf — owns the master table
label: Cost Centre
source: { table: semantic.dim_cost_centre, key_column: cost_centre }
parents:
department: { source_column: department }
department:
label: Department
derived_from: { dimension: cost_centre, source_column: department }
parents:
division: { source_column: division } # the next level up
division:
label: Division
derived_from: { dimension: cost_centre, source_column: division }
Note where derived_from points: both department and division derive
from cost_centre — the leaf that owns the table — not from each other, because
the department and division columns both live on dim_cost_centre.
derived_from only says where each level's members are read; parents is what
threads the levels into a chain. The loader walks that chain at load time, so a
filter on division resolves all the way down to the cost-centre IDs it covers
(SELECT cost_centre FROM dim_cost_centre WHERE division = ?). The same shape
models SKU → subcategory → category, or point-of-sale → region → country.
Ragged hierarchies. The derived dimensions above let a client group by one
level — by department, or filter by division. A ragged dimension goes
further: it presents the whole chain as a single sliceable axis, so one request
returns the rollup at every level at once — All → Division → Department → Cost
Centre — the way you drill an EPM hierarchy.
org_structure:
label: Organisational Structure
ragged: true
root_label: "— All Cost Centres —" # the synthetic top node
leaf_dimension: cost_centre # where the tree bottoms out
levels: # ordered root → leaf
- { dimension: division, display_prefix: "[D] " }
- { dimension: department, display_prefix: "[BU] " }
- { dimension: cost_centre, display_prefix: "[CC] " }
source:
type: generated # built from the levels above, no extra table
A ragged dimension reuses dimensions you already declared (division,
department, cost_centre) — it does not redefine them. levels lists them
root-to-leaf; display_prefix tags each level in the output so a client can tell
a department node from a cost-centre node; source: { type: generated } tells the
platform to build the tree from those levels rather than from a parent-child
table. The result is one dimension key, org_structure, that a client slices to
see every level of the org at once. Model a SKU rollup (All → Category →
Subcategory → SKU) or sales geography (All → Country → Region → POS) the same way.
Provided hierarchies (relationships, not levels). When the hierarchy isn't a
clean set of columns on the leaf's master data — a node rolls up into more than
one parent, or branches reach different depths — supply the topology as two
tables instead: a node master (node_id, node_name, node_type) and a
child→parent edge table. Land both like any other master data, add a one-line
pass-through view for each, and point the dimension at them:
solution_portfolio:
label: Solution Portfolio
ragged: true
leaf_dimension: cost_centre
source:
type: provided
node_table: portfolio_nodes # your node master
edge_table: portfolio_edges # your child→parent edges
child_column: child_node_id # columns on the edge table
parent_column: parent_node_id
You supply only the non-leaf nodes — the platform injects the leaf members automatically. A provided hierarchy is the form that lets one member roll up into several parents (a shared team reported under two portfolios) and lets a member attach directly to a top node.
A domain's dimensions: block (in pnl.yml) is the subset of these you can slice
that view by. The registry defines all dimensions; each domain opts into the
ones its view supports.
Role-playing dimensions. To slice a fact by the same master in two roles —
e.g. a transfer with both a primary and a counterparty cost centre — give the
second role its own leaf dimension over a second view of the same table
(semantic/dims/dim_counterparty_cc.sql = SELECT * FROM live.dim_cost_centre),
and bind each to its own fact column; they then filter and roll up independently.
The separate view is needed because the auto pass-through generator keys views by
table stem, so two dimensions sharing one table would collide.
Scenarios¶
A scenario identifies which dataset a number comes from — actuals, a budget, a
forecast. It's the scenario column in the fact view, and clients choose one (or
compare two) at query time.
Scenarios are not defined in a catalogue file — but they are not implicit in
your data either. The engine loads the set of valid scenarios from the
semantic.scenarios registry at startup, seeded from instance/scenarios.yml.
The registry and the data must agree on the identifier: the scenario column
value in the fact view must equal a scenario_id in the registry, and a scenario
value present in the data but absent from the registry is not exposed. See the
semantic.scenarios section of
What your ClickHouse must contain for the table
shape and how to seed it.
Generated reporting scenarios¶
On top of the real scenarios you seed, the engine generates a reporting
vocabulary at runtime (ScenarioRegistry) — you do not declare or seed these:
| Form | Key pattern | Meaning |
|---|---|---|
| Period-shifted | <alias>_py, <alias>_pp |
the scenario shifted −12 months (prior year) or −1 month (prior period), e.g. actuals_py |
| Comparison | <left>_vs_<right> |
signed variance, left − right, e.g. actuals_vs_budget |
| Comparison % | <left>_vs_<right>_pct |
percentage variance vs the comparator |
Comparison operands may be real or shifted, so year-on-year is just a comparison
against a shifted scenario: actuals_vs_actuals_py (signed) and
actuals_vs_actuals_py_pct (percentage). prior_year and prior_period are
accepted as compatibility aliases for actuals_py and actuals_pp.
Generated scenarios are read-only — you cannot write a plan into actuals_py
or into a comparison key. Comparison columns are colour-coded
favourable/unfavourable; the polarity is the metric's variance_effect (see the
invariants in Adding metrics &
dimensions), not a property of the scenario. Discover the live set with the
list_scenarios tool, or =PRECIS.SCENARIOS("shifted") / ("comparisons") in
Excel.
Tracing one query¶
Putting it together — "revenue by cost centre for the P&L, actuals":
- The client asks for metric
revenue, grouped bycost_centre. - The engine looks up
revenueinpnl.yml, seessource_view: semantic.v_pnl. - It runs
SELECT SUM(amount) … WHERE fs_line = 'Revenue' AND scenario = 'ACTUALS' GROUP BY cost_centre. - It applies
sign: absandformat: currency. - It returns rows keyed by
cost_centre, with the metric under the field namerevenue.
Every step is something you declared. Nothing is implicit.
The naming contract¶
The one rule that ties it all together: the same name appears in every layer.
semantic view column → catalogue reference → client field
amount source_column: amount
fs_line where: [{column: fs_line}]
account_code key_column: account_code
revenue (metric key) lines: [revenue, …] revenue
If a name doesn't line up — a metric points at a column the view doesn't have, a statement lists a metric key that doesn't exist, a dimension maps to a missing column — the engine reports it at startup and refuses to serve an inconsistent model. Fix the name; restart; it's caught before any client sees a number.
You don't have to wait for a restart to find out, though. Run the model check ahead of time — it validates the catalogue and confirms the semantic views it names exist in ClickHouse, without starting the server or changing anything:
python -m precis_mcp.clickhouse_init --scope open --check
See What your ClickHouse must contain for the full preflight.
Related¶
- Ingestion & data sources — getting data into the views above.
- Quickstart