Skip to content

Semantic Layer

A metric is not merely a column or query result. It is a versioned agreement about the business number an agent is allowed to analyze: what it means, what it excludes, how it is calculated, and where it may be used. Marivo can test whether the declared object and matching preview evidence are technically ready; only the accountable business owner can approve whether the contract is correct for the intended decision.

Use a semantic layer already maintained by your team, or build one for a new project.

When models/ contains semantic objects, reuse them. Install dependencies, configure local environment variables, and ask the agent to check whether the objects fit the current question. If there is no gap, start analysis without regenerating objects. Run scoped readiness when the cloned model changed or a workflow requests technical certification.

Give the agent the datasource, business goal, and decided rules. The agent uses marivo-semantic to inspect evidence and draft objects; the user confirms and adjusts business meaning. Technical validation is not business approval.

marivo-semantic supplies the stable ordering and ownership boundaries. Current constructor placement, signatures, prerequisites, loaded-object lookup, and error recovery come from marivo.help(...), result cards, .contract(), and structured errors. Before reading user data, the agent must ask for required non-observable inputs such as the accountable domain owner or target business concept. Sampling cannot answer those questions.

python -m marivo help only confirms the active interpreter, installed package version, and environment fingerprint before handing off to Python. marivo.help(...) is the sole public focused-help coordinator. Qualified datasource.*, semantic.*, and analysis.* content remains owned by its native registry; md and ms execute domain operations and intentionally have no .help() aliases.

Datasource authoring errors display a stable code and stage. When bounded acquisition fails during execution, retry that exact acquisition at most once only when the caller’s remaining data-access budget permits it. Caller-provided read-count, row, and timeout limits take precedence. If the same structured code and backend name recur, stop and report the backend blocker instead of resampling or bypassing Marivo.

Maintain and share the semantic layer with Git

Section titled “Maintain and share the semantic layer with Git”

Semantic objects are Python source files under models/ and belong in version control:

  • team members clone the project to reuse the same definitions;
  • use a branch and pull request for definition changes, showing owner, ai_context, guardrails, and affected metrics;
  • review business meaning and owner approval, not only whether Python runs;
  • commit datasource *_env references, never credential values;
  • do not commit .marivo/ runtime state, local caches, or generated secrets;
  • after a semantic change, ask the agent to check configuration and scoped readiness.

Git makes declarations reviewable and shareable. It does not grant business approval or prove current data is ready.

The agent can inspect datasource evidence, draft semantic objects, validate them, and explain unresolved choices. You remain responsible for deciding whether a metric represents the business outcome you intend to analyze.

When asking the agent to create or revise a metric, share the rules that matter to the business: important inclusions or exclusions, the relevant event time, the unit, and known limits on how the metric may be used. You do not need to translate those rules into Marivo fields or Python.

For example:

Add a completed-order revenue metric for this project. Exclude cancelled and fully refunded orders, use the order completion time, and ask me about any business rule that cannot be established from the project.

The agent uses marivo-semantic to inspect evidence and handle the authoring workflow. Review the proposed definition in business terms: does it measure the right outcome, and are the important boundaries visible? Correct the meaning before approving it. Passing validation or readiness does not approve the business definition on your behalf.

The agent should ask for input when available evidence cannot settle a business choice or when two plausible definitions would produce materially different results. Technical declaration details, previews, and readiness checks do not need separate user instructions.

Datasource → Domain → Entity → dimensions / time dimension / measures → metric

Python declarations are the contract. Decorator bodies return ibis expressions, not raw SQL strings. SQL is metadata only, through provenance=ms.from_sql(sql=..., dialect=...), for example when checking parity with an established query.

This small representation has one orders entity, a region dimension, an order_date time dimension, an amount measure, and a revenue aggregate metric. Its concise declarations illustrate the object graph; production objects also need the user-approved ai_context described below:

import marivo.datasource as md
import marivo.semantic as ms
sales = ms.domain(name="sales", owner="Mina Zhang")
warehouse = ms.ref.datasource("warehouse")
orders = ms.entity(name="orders", datasource=warehouse, source=md.table("orders"), primary_key=["order_id"])
region = ms.dimension_column(name="region", entity=orders, column="region")
order_date = ms.time_dimension_column(name="order_date", entity=orders, column="order_date", granularity="day", is_default=True)
amount = ms.measure_column(name="amount", entity=orders, column="amount", additivity="additive", unit="CNY")
revenue = ms.aggregate(name="revenue", measure=amount, agg="sum")

After user approval and loading, routine analysis reads the metric and observes it:

revenue_entry = catalog.metrics.get("sales.revenue")
revenue_entry.details().show()
frame = session.observe(revenue_entry, time_scope={...}, grain="month")

If the metric was just authored or changed, finish the scoped authoring closeout described below before this routine analysis sequence.

An Event gives one business occurrence a reusable identity, occurrence time, and named participant roles. The source Entity is inferred from owner(occurred_at), so Event authoring does not repeat an entity argument. Every Event uses the single @ms.event(...) decorator form.

For an Entity where each row is one order creation, the Event has no additional row filter and explicitly returns ms.all_rows():

order_id = ms.dimension_column(
name="order_id",
entity=orders,
column="order_id",
)
@ms.event(
name="order_created",
identity=(order_id,),
occurred_at=order_date,
participants=(
ms.participant(name="order", cardinality="one"),
),
ai_context=ms.ai_context(
business_definition="An order creation occurrence."
),
)
def order_created(order_rows):
return ms.all_rows()

For a filtered Event on a shared event-log Entity, return the restricted boolean expression instead, for example ms.bind(event_type, event_rows) == "payment_succeeded". Do not use a body-free constructor, bare True, or a separate filtered-Event API.

ms.participant_role(event=order_created, name="order") resolves the typed role used by Event analysis. Omitting path means the Event source Entity itself is the participant; a non-empty path follows declared Relationships to another Entity. A role used as the analysis subject must have cardinality="one". Its endpoint Entity’s declared primary key is the subject identity, so callers do not provide that identity again.

A StateModel declares the allowed states and Event-driven transitions for one subject Entity. It describes business meaning only. Replay windows, seeds, completeness assumptions, cohorts, and observed violations belong to analysis, not to the StateModel.

For example, assume the order source also exposes a paid timestamp and status:

paid_at = ms.time_dimension_column(
name="paid_at",
entity=orders,
column="paid_at",
granularity="second",
)
order_status = ms.dimension_column(
name="order_status",
entity=orders,
column="status",
)
@ms.event(
name="order_paid",
identity=(order_id,),
occurred_at=paid_at,
participants=(
ms.participant(name="order", cardinality="one"),
),
ai_context=ms.ai_context(
business_definition="An order entered the paid state."
),
)
def order_paid(order_rows):
return ms.bind(order_status, order_rows) == "paid"
created = ms.lifecycle_state(name="created", initial=True)
paid = ms.lifecycle_state(name="paid", terminal=True)
order_lifecycle = ms.state_model(
name="order_lifecycle",
subject=orders,
states=(created, paid),
transitions=(
ms.inception(on=order_created),
ms.transition(
from_state=created,
on=order_paid,
to_state=paid,
),
),
ai_context=ms.ai_context(
business_definition="Normative commercial order lifecycle."
),
)

State names are local immutable authoring values. Exactly one state is initial; terminal states cannot have outgoing transitions. An Event trigger is inferred only when exactly one cardinality="one" participant ends at the StateModel subject. If several roles qualify, pass the exact ms.participant_role(...) handle instead of repeating Event and role fields.

After loading, use ms.model_state(model=order_lifecycle, name="paid") to construct the typed state identity accepted by analysis. A StateModel may be valid without an inception for future projection-based seeding, but it is not ready for replay from inception. Use marivo.help("semantic.state_model") for the current constructor contract and the catalog entry’s verify/preview/readiness continuations for current-state guidance.

Datasources are declared in models/datasources/*.py with a typed helper per backend. The helper registers the connection; it does not return a value. Semantic files refer to it through the exact ms.ref.datasource("warehouse") factory.

models/datasources/warehouse.py
import marivo.datasource as md
import marivo.semantic as ms
md.duckdb(
name="warehouse",
path="warehouse.duckdb",
ai_context=ms.ai_context(
business_definition="Local DuckDB warehouse for sales analysis.",
guardrails=["Use only for development or approved local analysis."],
),
)

Every helper (md.duckdb, md.sqlite, md.mysql, md.postgres, md.trino, md.clickhouse) shares these parameters:

ParameterTypeRequiredDefaultMeaning
namestrYesGlobal lowercase snake_case datasource name. Used by ms.ref.datasource(name).
ai_contextAiContextValueNoNoneAgent-facing context, via ms.ai_context(...).
extradictNoNoneRare JSON-safe ibis keyword arguments the typed helper does not model.

Backend-specific parameters:

HelperRequiredOptional
md.duckdbpath (default ":memory:"), read_only (default False)
md.sqlitepath (default ":memory:"), read_only (default False), type_map
md.mysqlhost, databaseport (3306), autocommit, user_env, password_env
md.postgreshost, databaseport (5432), schema, autocommit, user_env, password_env
md.trinohost, catalogport (8080), schema, source, timezone, http_scheme, client_tags, session_properties, user_env, auth_env
md.clickhousehostport (9000 / 9440 secure), database, secure, settings, user_env, password_env

ClickHouse datasources disable clickhouse-connect’s autogenerated session id by default so cached analysis backends can run repeated or concurrent queries without sharing a server session lock. If a datasource needs ClickHouse session state such as temporary tables, opt in explicitly with extra={"autogenerate_session_id": True}. Keep server/query settings in settings={...}; client keyword arguments belong in extra={...}. Marivo’s bounded read-only paths enforce the ClickHouse server setting readonly=1; they preserve other declared settings and never retry through a write-enabled connection.

SQLite datasources use md.table(...) for tables and views. read_only=True enables SQLite’s connection-level query-only mode, and type_map={...} can map declared SQLite type names to Ibis type strings when affinity inference is not enough. Parquet, CSV, and JSON descriptors remain DuckDB-only. SQLite does not support median or percentile metrics, or string strptime expressions in the current Ibis backend; Marivo reports these as structured unsupported-operation errors. Use a supported aggregation and a native temporal column instead.

models/datasources/lake.py
import marivo.datasource as md
md.trino(
name="lake",
host="trino.example.internal",
catalog="hive",
user_env="TRINO_USER",
auth_env="TRINO_AUTH",
)

ms.domain(...) opens a namespace. Call it once per _domain.py. It returns a Ref[domain] you can pass as domain= to override the active domain for an object declared in a sibling file.

ParameterTypeRequiredDefaultMeaning
namestrYesDomain namespace, e.g. "sales". Objects become <name>.<object>.
ownerstrYesHuman owner accountable for semantic correctness and quality, e.g. "Mina Zhang".
defaultboolNoTrueWhen True, decorators in this file resolve to this domain unless domain= is passed.
ai_contextAiContextValueNoNoneAgent-facing context, via ms.ai_context(...).
import marivo.semantic as ms
ms.domain(name="sales", owner="Mina Zhang")

An entity is one physical source (a table or file) plus its primary key. It is the anchor that dimensions, measures, and metrics attach to.

ParameterTypeRequiredDefaultMeaning
namestrYesEntity name. Becomes <domain>.<name>.
datasourceRef[datasource]Yesms.ref.datasource("warehouse").
sourcesource builderYesDatasource-owned md.table(...), md.parquet(...), md.csv(...), or md.json(...).
primary_keylist[str]NoNoneColumn names forming the primary key.
versioningms.snapshot | ms.validityNoNoneSnapshot or SCD2 validity versioning (see below).
domainRef[domain]Nofile defaultOverride the active domain.
ai_contextAiContextValueNoNoneAgent-facing context, via ms.ai_context(...).
warehouse = ms.ref.datasource("warehouse")
orders = ms.entity(
name="orders",
datasource=warehouse,
source=md.table("orders"),
primary_key=["order_id"],
ai_context=ms.ai_context(business_definition="One row per order."),
)
BuilderRequiredOptionalUse for
md.table(name)namedatabaseA table in the datasource (use database="schema" for Trino/MySQL).
md.parquet(path)pathhive_partitioningSelf-describing Parquet files through DuckDB.
md.csv(path, schema=...)path, typed schemaheader, delimiterCSV file source through DuckDB.
md.json(path, schema=...)path, typed schemaformatJSON file source, glob, or DuckDB httpfs URL.

A dimension is a categorical attribute you group or filter by. For direct physical columns, use ms.dimension_column(...); for expressions (e.g. table.region.upper()), use the @ms.dimension decorator whose body returns a single ibis expression over the entity table.

ParameterTypeRequiredDefaultMeaning
namestrYesDimension name. Becomes <domain>.<entity>.<name>.
entityRef[entity]YesThe owning entity.
columnstrYesPhysical column name on the entity table.
domainRef[domain]Nofile defaultOverride the active domain.
ai_contextAiContextValueNoNoneAgent-facing context, via ms.ai_context(...).
region = ms.dimension_column(
name="region",
entity=orders,
column="region",
ai_context=ms.ai_context(business_definition="Sales reporting region."),
)

A time dimension is a special dimension that carries grain and parsing metadata. Only time dimensions can serve as the time axis for session.observe.

ParameterTypeRequiredDefaultMeaning
namestrYesDimension name.
entityRef[entity]YesThe owning entity.
columnstrYesPhysical column name on the entity table.
granularitygrain literalYesyear, quarter, month, week, day, hour, minute, or second — the finest grain at which queries are meaningful.
parseparse variantNoNoneHow the source column becomes a time value (see below). Omit for native temporal columns — the parse variant is inferred at analysis time.
is_defaultboolNoFalseMarks the default time axis when the entity has several. observe uses it when time_dimension= is omitted.
domainRef[domain]Nofile defaultOverride the active domain.
ai_contextAiContextValueNoNoneAgent-facing context, via ms.ai_context(...).

The parse= value declares the physical encoding of the column. When omitted, the parse variant is inferred from the column’s ibis dtype at analysis time (native date, datetime, and timestamp columns do not need an explicit parse). For string or integer columns, provide ms.strptime(format) or ms.hour_prefix(prefix). The variant must be compatible with granularity (e.g. an hour grain needs a time-bearing format).

BuilderSource column is…Key parameters
(omit parse)a native temporal column
ms.datetime()a native datetimetimezone (IANA), sample_interval
ms.timestamp()a native timestamptimezone (IANA), sample_interval
ms.strptime(format)a string/integer to parsetimezone, sample_interval
ms.hour_prefix(prefix)an hour-only partitionsample_intervalprefix is the day-grain Ref[time_dimension] that supplies the date

For a native naive datetime or timestamp, declare its source timezone (for example, ms.datetime(timezone="Asia/Shanghai")). An explicit ms.datetime() or ms.timestamp() without timezone= makes readiness return the undeclared_naive_time_axis blocker: runtime would otherwise fall back to the datasource read timezone, while analysis windows use the report timezone and may shift at day or hour boundaries. The zero-query gate reports those runtime-resolved contexts but never guesses the business timezone. sample_interval like (5, "minute") marks a periodically-sampled axis used by semi-additive folds.

For time-series and panel frames, bucket_start is always a report-timezone bucket label emitted as a timezone-naive timestamp or date. For example, with report_timezone="Asia/Shanghai", the first hourly bucket for a Shanghai business day is shown as 2026-06-20 00:00:00, not the equivalent UTC instant.

# Day partition stored as the string "20260131"
log_date = ms.time_dimension_column(
name="log_date",
entity=orders,
column="dt",
granularity="day",
parse=ms.strptime("%Y%m%d"),
is_default=True,
)
# Native UTC timestamp, usable for sub-day buckets
event_ts = ms.time_dimension_column(
name="event_ts",
entity=orders,
column="event_ts",
granularity="minute",
parse=ms.timestamp(timezone="UTC"),
)

A measure is a row-level quantitative fact you intend to aggregate (e.g. an amount or quantity). For direct physical columns, use ms.measure_column(...); for expressions over one or more columns, use @ms.measure. Measures carry additivity and an optional unit.

ParameterTypeRequiredDefaultMeaning
namestrYesMeasure name. Becomes <domain>.<entity>.<name>.
entityRef[entity]YesThe owning entity.
columnstrYesPhysical column name on the entity table.
additivityadditivity valueYes"additive", "non_additive", or ms.semi_additive(...).
unitstrNoNoneUCUM unit token: "USD", "CNY", "%", "ms", "{order}".
domainRef[domain]Nofile defaultOverride the active domain.
ai_contextAiContextValueNoNoneAgent-facing context, via ms.ai_context(...).
amount = ms.measure_column(
name="amount",
entity=orders,
column="amount",
additivity="additive",
unit="CNY",
)

A metric is not just a query result. It is the analysis-ready business number an agent may observe: its definition and calculation are declared in version control; its entity, dimensions, time behavior, unit, and additivity describe how it can be analyzed; ai_context states the business meaning and guardrails; and readiness can certify new or changed definitions before they are declared complete.

NeedUseExample
Sum or other direct aggregation of a row-level valuems.aggregaterevenue from amount
Count entities or rowsms.countcompleted orders
Compute a weighted mean from row-level measuresms.weighted_meanrequest-weighted latency
Combine metricsms.ratio, ms.linearconversion rate
Accumulate a time seriesms.cumulativeyear-to-date revenue
Require a custom Ibis expression@ms.metrica calculation not covered above

Choose the builder that matches the task. Use @ms.metric only when the existing builders cannot express the calculation.

Simple metric from a measure — ms.aggregate

Section titled “Simple metric from a measure — ms.aggregate”

Aggregates a measure. No body; additivity is inherited from the measure.

ParameterTypeRequiredDefaultMeaning
namestrYesMetric name.
measureRef[measure]YesThe measure to aggregate.
aggaggregationYes"sum", "mean", "min", "max", …
foldfoldNoNoneTime-fold override for semi-additive measures.
filterWhereFilterNoNoneAND filter from ms.where(dimension=value, ...); scalars mean equality and non-empty tuple/list values mean membership.
unitstrNoinheritedOverride the unit derived from the measure.
domain / ai_contextNoAs elsewhere.
revenue = ms.aggregate(name="revenue", measure=amount, agg="sum")
us_revenue = ms.aggregate(name="us_revenue", measure=amount, agg="sum", filter=ms.where(region="US"))

Use ms.count instead of declaring a redundant measure just to count entity rows. The helper accepts refs only and infers the metric domain from the entity ref. Pass filter=ms.where(...) to count a subset (e.g. failures) without a hand-written metric body.

order_count = ms.count(name="order_count", entity=orders, ai_context=ms.ai_context(business_definition="Total number of orders."))
failed_count = ms.count(name="failed_count", entity=orders, filter=ms.where(state="FAILED"))

ms.where(**conditions) builds AND-joined equality (dimension=value) or membership (dimension=(value1, value2)) filters. Keys are local semantic dimensions declared on the metric’s target entity, not arbitrary physical columns. Scalar values are str / int / float / bool; non-empty tuple/list values are normalized to membership. Multiple conditions are AND-joined. Load rejects missing filter dimensions, and preview/analysis checks literal compatibility against the resolved Ibis type before query submission. If a valid authored literal cannot be compared with the physical dtype, both paths raise filter_value_runtime_incompatible with query_executed=False and declaration_preserved=True. Marivo preserves the business literal and asks the user or business owner to confirm any code/label mapping; it never rewrites the rule from physical types or sampled values. Query-free static verification and semantic_static readiness can continue while runtime evidence remains unavailable.

terminal_count = ms.count(
name="terminal_count",
entity=queries,
filter=ms.where(type=(2, 4)),
)

ms.weighted_mean accepts two measures from the same entity and owns both the row-level multiplication and aggregation:

avg_latency = ms.weighted_mean(
name="avg_latency",
value=latency,
weight=request_count,
)

It computes SUM(value * weight) / NULLIF(SUM(weight), 0) over rows where both inputs are non-null. weight must be additive. The result inherits the value measure’s unit and persists exact numerator / weight components for weighted_mix attribution. It also accepts filter, unit, domain, and ai_context.

Body-free metrics composed from other metrics. The computation comes entirely from the components.

BuilderRequiredComputes
ms.ratio(name, numerator, denominator)both refsnumerator / denominator (e.g. average order value, rates)
ms.linear(name, add, subtract)add (≥2 terms total)sum of add minus subtract (e.g. net = gross - refunds)

Each also accepts unit, domain, and ai_context.

net_revenue = ms.linear(name="net_revenue", add=[gross_revenue], subtract=[refunds])
aov = ms.ratio(name="aov", numerator=total_amount, denominator=orders_count)

At observe time, division evaluates under a fixed zero_division="null" policy: a bucket whose denominator (for ms.ratio) or paired weight sum (for ms.weighted_mean) is present but zero yields a null value — never +/-inf — and the affected row count is recorded on the observed frame as meta.zero_denominator_rows and quality_summary.zero_denominator_rows.

Use ms.cumulative(...) when the business question is “how much accumulated up to bucket t”. The base must be a tier-1 ms.aggregate(...), ms.count(...), or ms.weighted_mean(...) metric using sum, count, count_distinct, or weighted-mean component accumulation. The anchor parameter selects the accumulation shape.

ParameterTypeRequiredDefaultMeaning
namestrYesSemantic metric name.
baseRef[metric]YesTier-1 simple aggregate metric using sum, count, or count_distinct.
overRef[time_dimension] | NoneNoNoneTime axis to accumulate over. Pass explicitly unless the base root entity has exactly one time dimension.
anchorGrainToDate | Trailing | NoneNoNoneAccumulation anchor. None = all-history running total; ms.grain_to_date(...) = MTD/QTD/YTD resets; ms.trailing(...) = rolling N.
unit / domain / ai_contextNoSame as other metrics.
user_id = ms.measure_column(name="user_id", entity=events, column="user_id", additivity="non_additive")
active_users = ms.aggregate(name="active_users", measure=user_id, agg="count_distinct")
cumulative_active_users = ms.cumulative(
name="cumulative_active_users",
base=active_users,
over=event_time,
)

For count_distinct base metrics, the cumulative value uses first-seen semantics: each distinct entity is counted at the earliest bucket in which it appears, so the running total is monotonically non-decreasing. Cumulative metrics may serve as ratio components — compose cumulative numerator and denominator with ms.ratio(...) for cumulative rates.

anchor=None (default) is the all-history running total: the observe window clips displayed rows but does not reset the running value. Two value-object constructors open the additional anchor kinds.

ms.grain_to_date(grain=...) resets the running total at each reset-grain boundary (MTD / QTD / YTD / WTD). Within a reset period the value accumulates; at the boundary it drops to the period’s first-bucket flow. grain is one of week, month, quarter, year.

mtd_revenue = ms.cumulative(
name="mtd_revenue",
base=revenue,
over=event_time,
anchor=ms.grain_to_date(grain="month"),
)

ms.trailing(count=..., unit=...) is a fixed-size rolling window: the value at each bucket is the base aggregation over the span ending at that bucket’s end boundary. Empty windows are true zero, not carried forward. Partial windows (the span reaches before the data start) show the actual partial accumulation and are marked partial in coverage. unit accepts only fixed-size units (second, minute, hour, day, week); calendar-variable units (month, quarter, year) are rejected with a teaching error.

rolling7_active = ms.cumulative(
name="rolling7_active",
base=active_users,
over=event_time,
anchor=ms.trailing(count=7, unit="day"),
)

Two cross-anchor rules govern query-time grain choices:

  • Grain-compatibility rule (grain_to_date): every display bucket must lie entirely within one reset period. A week query grain under a month / quarter / year reset is illegal (week buckets straddle month boundaries); day and hour are legal.
  • Integer-multiple rule (trailing): the window span must be an integer multiple of the query grain (W_buckets = span / grain). Trailing requires a time grain.

Derived metrics may compose cumulative components. Compare is supported only when every outer component is cumulative and all components use the same trailing or grain_to_date anchor. all_history, mixed anchors, and cumulative/non-cumulative mixes are rejected; attribute, decompose, and forecast remain unsupported.

import marivo.analysis as mv
import marivo.semantic as ms
session = mv.session.get_or_create(
name="revenue-investigation",
question="Why did Q4 revenue drop?",
)
catalog = session.catalog
revenue_entry = catalog.metrics.get("sales.revenue")
region_entry = catalog.dimensions.get("sales.orders.region")
revenue_entry.details().show()
current = session.observe(revenue_entry, grain="month", dimensions=[region_entry])

details().show() exposes the human-authored definition, guardrails, composition, effective entities, candidate dimensions/time dimensions, and measure lineage. Candidate axes come from the metric’s effective entities; session.observe(...) still validates cross-entity relationships and fanout. Use .contract().show() only for mechanical verify/preview/readiness continuations. .show() prints the same bounded card that .render() returns. If the object is missing or disputed, return to semantic authoring. Otherwise the observation becomes the first artifact in the session’s evidence-backed investigation. Newly authored or changed objects complete scoped readiness as part of their authoring closeout.

Once declarations are in place, load the catalog and inspect it:

import marivo.semantic as ms
catalog = ms.load() # SemanticCatalog
catalog.show() # all typed collections and counts
catalog.domains.show() # top-level domains
catalog.metrics.show() # just metrics across all domains
sales = catalog.domains.get("sales")
orders = sales.entities.get("orders")
revenue = orders.metrics.get("revenue") # one metric object
region = orders.dimensions.get("region") # one dimension object

Each typed collection accepts a local name, an exact full path, or a same-kind ref within that collection’s current scope. Scoped collections never return an out-of-scope object through a path or ref. Use the strict ref-only catalog.require(ref) when identity comes from configuration, persistence, or logs. Entry cards show the exact kind, full path, .ref, and bounded current axes; omitted members point to details().show().

The global collections are domains, datasources, entities, dimensions, time_dimensions, measures, metrics, relationships, events, and state_models. repr(catalog) points to the same bounded, zero-query catalog card that catalog.show() prints. Collection help exposes the exact .items, .refs, .get(...), .render(), and .show() surface; lookup remains exact and does not use fuzzy search.

After authoring or changing an object, run readiness to certify that the selected definition and its scoped runtime evidence are current:

catalog.verify(revenue).show()
catalog.preview(revenue, using=snapshot).show()
report = catalog.readiness(refs=[revenue, region])
if report.status == "blocked":
report.show() # blockers, with the next step for each

These runtime calls accept the current entry or its exact ref and normalize to the ref immediately. Readiness output, preview evidence, persistence, replay, and recovery stay ref-based.

Two more checks support authoring:

  • ms.richness() — advisory coverage/depth report; never blocks.
  • ms.parity_check("sales.revenue") — runs the metric against its provenance SQL and compares results. Requires provenance=ms.from_sql(...).

For how readiness decides what is “ready,” see Readiness. For how analysis records what it concludes, see Evidence. Analysis sessions load and validate metric inputs at runtime. Use scoped readiness to certify semantic changes, then continue to the Analysis Workflow.

You work through two namespaces:

import marivo.datasource as md # connections and physical sources
import marivo.semantic as ms # meaning (ms.entity, ms.metric, ...)

Every object lives under a domain and is addressed by a qualified ref:

  • Domain-level objects: <domain>.<object> — e.g. sales.revenue, sales.orders.
  • Entity-scoped objects (dimensions and measures): <domain>.<entity>.<field> — e.g. sales.orders.region.

A project is a folder of declaration files. Datasources are declared once under models/datasources/; semantics live under models/semantic/<domain>/, with a _domain.py per domain:

your-project/
marivo.toml
models/
datasources/
warehouse.py # md.duckdb(name="warehouse", ...)
semantic/
sales/
_domain.py # ms.domain(name="sales", owner="Mina Zhang") + entities, metrics, ...

For multi-repository semantic layers, keep each business-domain package in the same authored models/ layout and reference those roots from the central analysis project:

[semantic]
layer_paths = ["../finance-domain/models"]

Marivo loads the central project’s models/ first, then each configured external models/ root. All loaded datasources, domains, and semantic objects share one catalog, so names must stay globally unique. marivo doctor, catalog.preview, catalog.verify, and analysis sessions resolve datasource-backed semantic objects against the same configured roots.

Every semantic object is identified by one sealed, immutable ms.Ref value. The runtime class is always exactly Ref; the generic kind (Ref[metric], Ref[dimension], and so on) gives static type checkers the role. Create refs only through an exact factory such as ms.ref.metric("sales.revenue") or from an authoring declaration that returns the same value type.

All refs expose these read-only attributes:

AttributeTypeMeaning
.pathstrKind-relative qualified path, for example "sales.revenue".
.kindSemanticKindObject kind — one of the eight values below.
.keystrCanonical runtime key, for example "metric:sales.revenue".
.namestrFinal local-name segment.

str(ref) returns .key; equality and hashing use (kind, path). Ref(...) cannot be called directly and Ref cannot be subclassed. Public boundaries do not accept strings or catalog entries as substitutes for refs.

KindFactoryTypical authoring producerExpression binding?
domainms.ref.domain(path)ms.domain(...)No
datasourcems.ref.datasource(path)datasource declaration .refNo
entityms.ref.entity(path)ms.entity(...)No
dimensionms.ref.dimension(path)ms.dimension_column(...), @ms.dimensionms.bind(ref, entity_alias) inside a bound expression body
time_dimensionms.ref.time_dimension(path)ms.time_dimension_column(...), @ms.time_dimensionms.bind(ref, entity_alias) inside a bound expression body
measurems.ref.measure(path)ms.measure_column(...), @ms.measurems.bind(ref, entity_alias) inside a bound expression body
metricms.ref.metric(path)ms.aggregate(...), @ms.metric, ms.ratio(...), …No
relationshipms.ref.relationship(path)ms.relationship(...)No

Ref values are never callable. Inside an expression body, bind a field ref to the declared entity alias explicitly, for example ms.bind(amount, orders). The loader records that binding against the compiled catalog. Binding outside that body, with the wrong alias, or binding a non-field ref fails with a teaching error.

An authoring ref can be passed directly to analysis. When starting from a literal identity, require catalog membership once and then pass the current entry directly:

revenue = ms.aggregate(name="revenue", measure=amount, agg="sum")
frame = session.observe(revenue, time_scope={...})
revenue_entry = catalog.require(ms.ref.metric("sales.revenue"))
frame = session.observe(revenue_entry, time_scope={...})

catalog.require(ref) is the exact global lookup. Catalog collections provide browsing plus local/full-path lookup. Frozen analysis consumers accept a current catalog entry or its exact ref; authoring and persisted/configuration boundaries remain ref-based. Persisted payloads encode refs as the versioned {schema, kind, path} record; raw identity strings are not a public interchange format.

Every semantic object and datasource accepts an optional ai_context parameter, constructed with ms.ai_context(...). This is where business meaning and guardrails live — the context an agent reads before it uses the object. All parameters are optional, but unknown keyword arguments are rejected by Python at call time.

FieldTypeRequiredDefaultMeaning
business_definitionstrNoNoneWhat the object means in business terms, in a sentence or two.
guardrailslist[str]No[]Rules an agent must respect: required filters, exclusions, scope limits.
ai_context=ms.ai_context(
business_definition="Gross order amount before refunds.",
guardrails=["Validate refund exclusions before using as net revenue."],
)

For entities whose rows change over time, declare how to read the current state:

  • ms.snapshot(partition_field, grain="day", timezone=None, format=None) — daily partitioned snapshots; partition_field must be a Ref[dimension] or Ref[time_dimension].
  • ms.validity(valid_from, valid_to, interval, open_end, timezone=None) — SCD2 validity intervals; valid_from and valid_to must be Ref[dimension] or Ref[time_dimension] values. interval is "closed_open" ([from, to)) or "closed_closed"; open_end lists the sentinel values that mean “still current” (e.g. (None,) for SQL NULL, or ("9999-12-31",)).

Use this escape hatch when the metric cannot be expressed as ms.aggregate(...), ms.count(...), or a derived metric builder. The body returns one ibis aggregation, and you declare additivity directly.

ParameterTypeRequiredDefaultMeaning
namestrNofunction nameMetric name.
entitieslist[Ref[entity]]YesEntities the body reads.
additivityadditivity valueYes"additive", "non_additive", or ms.semi_additive(...).
root_entityRef[entity]Nothe single entityRequired when entities has more than one.
fanout_policy"block" | "aggregate_then_join"No"block"How to handle join fan-out across entities.
unitstrNoNoneUCUM unit token.
provenanceSqlProvenanceNoNonems.from_sql(sql=..., dialect=...) for parity checking.
domain / ai_contextNoAs elsewhere.
@ms.metric(
entities=[orders],
additivity="additive",
name="revenue",
provenance=ms.from_sql(
sql="SELECT SUM(amount) AS revenue FROM orders",
dialect="duckdb",
),
ai_context=ms.ai_context(business_definition="Gross order amount before refunds."),
)
def revenue(table):
return table.amount.sum()

Additivity and time behavior determine whether a number remains meaningful when an agent changes the analysis grain or segments it. SQL provenance does not execute a metric; it gives ms.parity_check(...) a reference query for checking that the declared semantics match an established implementation.

  • ms.semi_additive(over, fold) — for snapshot/status facts that are additive across most axes but folded over a time axis. over is the status time dimension ref returned by @ms.time_dimension(...); fold is "last", "first", "mean", "max", or ("percentile", 0.95).
  • ms.from_sql(sql, dialect) — attaches SQL as provenance only, enabling ms.parity_check(...). It is never executed as the metric body.
snapshot_date = ms.time_dimension_column(
name="snapshot_date",
entity=inventory_daily,
column="snapshot_date",
granularity="day",
)
on_hand_units = ms.measure_column(
name="on_hand_units",
entity=inventory_daily,
column="on_hand_units",
additivity=ms.semi_additive(over=snapshot_date, fold="last"),
)

Declares how two entities join, so metrics and dimensions can reach across them. Keys are dimension refs, not raw column names.

ParameterTypeRequiredDefaultMeaning
namestrYesRelationship name.
from_entityRef[entity]YesSource entity.
to_entityRef[entity]YesTarget entity.
keyslist[JoinKey]YesOne or more ms.join_on(from_key, to_key) pairs.
domain / ai_contextNoAs elsewhere.
ms.relationship(
name="orders_to_customers",
from_entity=orders,
to_entity=customers,
keys=[ms.join_on(order_customer_id, customer_id)],
)

For datasource-backed authoring, use one explicit evidence snapshot. Consult marivo.help("datasource.authoring") for datasource inspection and scope, marivo.help("semantic.authoring") for the semantic dependency ladder, and marivo.help("datasource.ai_context") for the shared ms.ai_context(...) contract:

warehouse = ms.ref.datasource("warehouse")
orders = md.table("orders")
inspection = md.inspect(warehouse, orders)
inspection.show()
inspection.partitions().show()
scope = md.partition({"dt": "20260710"}, max_rows=1000, timeout_seconds=30)
snapshot = inspection.sample(
scope=scope,
columns=("order_id", "region", "created_at", "amount"),
)
snapshot.entity(columns=("order_id",)).show()
snapshot.dimensions(columns=("region",)).show()
snapshot.time_dimensions(columns=("created_at",)).show()
snapshot.measures(columns=("amount",)).show()

Inspection is metadata-only and reports schema, physical extent, partition state, and enforceable capabilities before sampling. Scope requires positive row and timeout guards plus explicit columns. LIMIT does not guarantee bytes scanned. For a ClickHouse Distributed source, local system.parts observations are shown only as scope=local_node_only notes; source-wide rows and bytes remain unknown, and Marivo does not issue a cluster-wide fanout query.

md.duckdb(...) declares the datasource. md.table(...) selects an internal table or view inside that datasource. md.parquet(...), md.csv(...), and md.json(...) are DuckDB file source descriptors used with the datasource ref; they are not datasource declarations.

events = md.json("data/events/*.json", schema={"event_id": "string"}, format="newline_delimited")
orders_parquet = md.parquet("data/orders/*.parquet")
orders_csv = md.csv("data/orders/*.csv", schema={"order_id": "string"})
md.inspect(warehouse, events).show()

Snapshot projections are query-free and expose complete structured evidence even when rendering is bounded. Values are memory-only by default; use persist_values=True only when bounded plaintext project-local caching is acceptable. Uncommon formats, keys, timezone, aggregation, unit, additivity, relationship cardinality, and business meaning remain agent-owned.

Inspection cards include the exact source descriptor and real schema names and types. Partition cards report the captured value source, completeness, truncation, bounded values, and a scope template without issuing another query. The snapshot card reports its scope, selected columns, coverage, and value/cache state; every projection is explicitly data_access=none.

Use md.test(ref).show() as the connection-test stop point. A failed DatasourceTestResult exposes a structured .failure, a focused .repair, and a blocked validate_connection transition from .contract(). Snapshot connection, source-resolution, timeout, and execution failures likewise remain structured and state whether a query was executed; backend messages are bounded and sanitized.

When evidence cannot mechanically settle a decision, result.contract().judgment_requirements exposes frozen requirements with stable IDs, subjects, evidence IDs, and authority="user_or_business_owner". They do not recommend a value, create a constructor transition, or record approval. Reuse the same snapshot for all local projections. Reacquire only for missing required columns or value evidence, staleness, or a datasource/source/scope identity mismatch.

Focused constructor help describes each call as a declaration fragment inside its reported loader path. After loading, use the generated catalog handoff: catalog = ms.load(), entry = catalog.<collection>.get(...), entry.show(), and entry.contract().show().

After one Python declaration, reload its current catalog entry and run catalog.verify(entry), catalog.preview(entry, using=snapshot), then zero-query catalog.readiness(refs=[entry]). Use entry.ref only when an exact persisted, logged, or configuration identity is required.

When readiness reports several missing runtime previews, repair them through the same entry point with catalog.preview_many(report.preview_required_refs, using=snapshot_or_mapping). This batches compatible queries but preserves independent evidence for every ref; it is not a batch-authoring shortcut.