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.

marivo doctor confirms the active interpreter, installed package version, package path, and project state before handing off to Python. Use the environment’s own marivo executable when it is not activated. 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=mv.time_scope(start="2026-01-01", end="2026-04-01"),
grain=mv.grain("month"),
)

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

Use a PeriodCalendar when business periods cannot be derived safely from a Gregorian grain (for example, a fiscal or 4-4-5 calendar). Declare the civil-date spine and named levels with ms.period_calendar(...), then acquire that source once with persist_values=True and pass the immutable DiscoverySnapshot to catalog.preview(calendar_ref, using=snapshot). Certification requires exhaustive coverage and publishes a snapshot-bound authority; it does not query the source again during catalog navigation.

calendar_ref = ms.period_calendar(
name="fiscal",
date=ms.ref.time_dimension("sales.calendar.calendar_date"),
boundary_timezone="UTC",
coverage=(date(2026, 1, 1), date(2027, 1, 1)),
levels={"week": ms.ref.dimension("sales.calendar.fiscal_week")},
)
snapshot = md.inspect(ms.ref.datasource("warehouse"), md.table("calendar")).sample(
scope=md.unpruned(max_rows=400, timeout_seconds=30),
columns=("calendar_date", "fiscal_week"),
persist_values=True,
)
catalog.verify(calendar_ref)
catalog.preview(calendar_ref, using=snapshot)
calendar = catalog.period_calendars.get("sales.fiscal")
grain = calendar.grain("week")
week_scope = calendar.period("week", "FY2026-W01")
assert week_scope.kind == "calendar_period"
week_scope.contract().show() # exact bounds and certified snapshot identity

The reserved day level is derived from the certified coverage and is not persisted as one record per day. calendar.periods(level, limit=..., cursor=...) returns bounded exact scopes for declared levels; missing coverage, stale definitions, and malformed evidence fail closed with structured semantic errors. Use marivo.help("analysis.calendar.period") for the bounded exact lookup syntax.

Use a TemporalSet for named windows such as holidays, campaigns, launches, or incidents whose intervals may overlap or leave gaps. Declare explicit start and exclusive end fields with ms.temporal_set(...), then certify the values from one exhaustive DiscoverySnapshot with persist_values=True:

campaigns_ref = ms.temporal_set(
name="campaigns",
occurrence_id=ms.ref.dimension("sales.campaigns.campaign_id"),
start=ms.ref.time_dimension("sales.campaigns.starts_at"),
end=ms.ref.time_dimension("sales.campaigns.ends_at"),
boundary_timezone="Asia/Shanghai",
coverage=(date(2026, 1, 1), date(2027, 1, 1)),
category=ms.ref.dimension("sales.campaigns.category"),
)
snapshot = md.inspect(ms.ref.datasource("warehouse"), md.table("campaigns")).sample(
scope=md.unpruned(max_rows=400, timeout_seconds=30),
columns=("campaign_id", "starts_at", "ends_at", "category"),
persist_values=True,
)
catalog.preview(campaigns_ref, using=snapshot)
campaigns = catalog.temporal_sets.get("sales.campaigns")
launch = campaigns.occurrence("spring-2026")
frame = session.observe(
revenue,
time_scope=launch,
grain=mv.grain("day"),
)

mv.grain("day") is always bounded in the session report timezone. When the occurrence uses another boundary timezone, configure the session with the same report_timezone (or use a certified calendar day grain); Marivo never silently reinterprets a bare built-in day grain from the occurrence timezone.

occurrence(key) returns an exact, snapshot-bound TimeScope; catalog paging is bounded and filters half-open interval overlap without reading the datasource again. Temporal sets do not infer recurrence or match similarly named occurrences across years. Compare two exact occurrence-selected day-grain frames with mv.occurrence_progress(anchor="start" | "end", unmatched="fail" | "drop") when relative local-day progress is the intended business axis.

Use a WorkSchedule when the business source already owns the final working status for every civil date. It is independent of named holiday or campaign windows: certification requires one exhaustive boolean row per date in finite coverage, and no recurrence or holiday rule is inferred at runtime.

schedule_ref = ms.work_schedule(
name="cn_sales_schedule",
date=ms.ref.time_dimension("sales.calendar.date"),
is_working=ms.ref.dimension("sales.calendar.is_working"),
boundary_timezone="Asia/Shanghai",
coverage=(date(2026, 1, 1), date(2027, 1, 1)),
)
schedule_snapshot = md.inspect(
ms.ref.datasource("warehouse"), md.table("calendar")
).sample(
scope=md.unpruned(max_rows=400, timeout_seconds=30),
columns=("date", "is_working"),
persist_values=True,
)
catalog.preview(schedule_ref, using=schedule_snapshot)
schedule = catalog.work_schedules.get("sales.cn_sales_schedule")

Pass that catalog entry to mv.working_day_progress(schedule=schedule, unmatched="fail" | "drop") to pair zero-based working-day ordinals across two day-grain time-series or panel frames. The exact schedule snapshot is retained in the comparison contract; missing, duplicate, sub-day, or uncovered dates fail closed.

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
import marivo.analysis as mv
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), scoped HTTP auth fields
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",
)

For a protected DuckDB HTTP JSON source, authentication stays on the datasource, not in md.json(...). The project stores only the environment variable name and the narrow URL prefix allowed to receive the credential:

md.duckdb(
name="hawkeye",
http_scope="http://hawkeye.example/report/api/",
http_bearer_token_env="HAWKEYE_TOKEN",
)

Custom-header authentication maps header names to secret environment variables. The map may contain one header or a machine-authentication pair:

md.duckdb(
name="change_focus",
http_scope="http://change-focus.example/api/v2/change/list",
http_headers_env={
"x-secretid": "CHANGE_FOCUS_SECRET_ID",
"x-signature": "CHANGE_FOCUS_SIGNATURE",
},
)

Bearer and custom-header modes are mutually exclusive. Marivo resolves each value when connecting and creates a temporary DuckDB HTTP secret restricted to http_scope; POST execution reuses those scoped headers from connection memory.

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]NoNoneStable output column aliases 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)namedatabase, columnsA catalog-backed table or typed projection 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 schemaformat, records_path, query_params, method, bodyJSON file, glob, GET URL, or parameterized-body POST URL; records_path selects a wrapped record array.

md.table(...) supports two closed modes. Omit columns= to use catalog-backed discovery. Supply columns={alias: md.source_column(...)} to declare a typed projection with stable entity output aliases:

events = ms.entity(
name="events",
datasource=warehouse,
source=md.table(
"raw.events",
columns={
"event_time": md.source_column("event.timestamp", data_type="timestamp"),
"score": md.source_column("generated.score", data_type="float64"),
},
),
primary_key=["event_time"],
)

The declared type is an assertion, not a cast, and the mapping is the complete projection allowlist. For a projected source, semantic primary_key and direct field column= values must name these stable aliases. Inspection can report a declared-only warning when catalog metadata cannot confirm a binding. In that case, choose an explicit bounded sample, acquire one snapshot, run scoped preview, and then use zero-query readiness. Static load and verification prove declaration coherence; preview is the runtime authority. The source is recorded as TABLE_PROJECTION, while its aliases remain bindings inside one physical table rather than becoming separate sources.

A dimension is a categorical attribute you group or filter by. For direct entity output 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.
columnstrYesStable output column alias 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.
columnstrYesStable output column alias 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 entity output 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.
columnstrYesStable output column alias 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. Built-in grain values are week, month, quarter, and year; a certified custom calendar uses the typed TemporalGrain returned by ms.calendar_grain(...).

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

For a fiscal or other certified calendar, carry the calendar authority in the grain value instead of repeating its level as an independent analysis option:

fiscal_mtd = ms.cumulative(
name="fiscal_mtd",
base=revenue,
over=event_time,
anchor=ms.grain_to_date(
grain=ms.calendar_grain(
calendar=ms.ref.period_calendar("sales.fiscal"),
level="fiscal_month",
)
),
)
fiscal_week = session.catalog.period_calendars.get("sales.fiscal").grain("fiscal_week")
frame = session.observe(
fiscal_mtd,
time_scope=mv.time_scope(start="2026-01-01", end="2026-03-01"),
grain=fiscal_week,
)

The calendar snapshot owns fiscal membership and its boundary timezone; changing the session report timezone changes presentation only.

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. A trailing day is exactly 86,400 seconds and a trailing week is exactly 604,800 seconds. They are not report-timezone civil periods, so DST transitions do not resize the rolling span.

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 anchor. This includes all_history, whose result is an observed level difference with exact evaluation cutoffs, not asserted interval flow; source revision is unverified. Mixed anchors and cumulative/non-cumulative mixes are rejected. After compare, attribute accepts the current cumulative delta: business dimensions explain endpoint level changes, while the exact cumulative over axis explains base flow for direct sum/count structures. Mixed time/business axes, cumulative count_distinct, and component time bridges fail closed. decompose and forecast remain unsupported on cumulative frames.

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=mv.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=mv.time_scope(start="2026-01-01", end="2026-04-01"),
)
revenue_entry = catalog.require(ms.ref.metric("sales.revenue"))
frame = session.observe(
revenue_entry,
time_scope=mv.time_scope(start="2026-01-01", end="2026-04-01"),
)

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("semantic.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()

For a date or timestamp window, reuse the same bounded scope type:

scope = md.time_range(
"created_at",
start="2026-07-10T00:00:00+00:00",
end="2026-07-11T00:00:00+00:00",
max_rows=1000,
timeout_seconds=30,
)

The predicate is half-open ([start, end)). Bounds must use the same date or datetime kind and the same timezone awareness; aware datetimes are canonicalized to UTC. A projected source must expose the range column. Unlike equality partition scopes, time ranges can bound a transformed temporal partition.

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.

ClickHouse inspection also lists safe adapter-only physical columns under projectable_columns. The table is bounded and each row includes a copyable md.source_column(...) declaration using the exact physical name and normalized type. Columns whose active parts disagree on type, or whose backend type cannot be parsed, are warned about and omitted. This does not enumerate dynamic MapV2 keys: an unmaterialized key must be materialized upstream, exposed through a database view, or used only through terminal md.raw_sql(...).

md.duckdb(...) declares the datasource. md.table(...) selects an internal table or view inside that datasource. md.parquet(...) and md.csv(...) are DuckDB file source descriptors; md.json(...) is a DuckDB-backed JSON file or HTTP API source descriptor. All are used with the datasource ref and are not datasource declarations.

events = md.json("data/events/*.json", schema={"event_id": "string"}, format="newline_delimited")
api_events = md.json(
"https://api.example.com/events",
schema={"event_id": "string", "occurred_at": "timestamp"},
records_path="$.result.items",
query_params={
"query": "sum(pending_containers) by (cluster)",
"start": md.source_param("start"),
"end": md.source_param("end"),
"step": "60s",
},
)
gpu_servers = md.json(
"https://root.example/api/v1/graphql",
schema={
"name": "string",
"bs": "string",
"gpuAbstract": "string",
"status": "string",
},
method="POST",
body={"query": "{ queryServers { name bs gpuAbstract status } }"},
records_path="$.data.queryServers",
query_params={"policy-domain": "gpus"},
)
orders_parquet = md.parquet("data/orders/*.parquet")
orders_csv = md.csv("data/orders/*.csv", schema={"order_id": "string"})
md.inspect(warehouse, events).show()

records_path supports a deliberately small JSONPath subset: $ followed by object members, for example $.data or $.result.items. It does not support filters, wildcards, recursive descent, or array indexes. A present empty array materializes as zero rows. For each selected record, declared schema fields are projected in schema order: missing fields become typed NULL values and additional object fields are ignored. Present values must be convertible to their declared types. A missing path or non-array value fails at execution so an authentication or response-envelope error is not mistaken for empty data.

For the minimal POST API case, set method="POST" and provide one JSON object through body=. A md.source_param(...) may occupy any complete JSON value, including a value inside an object or array. The request is issued only when the resulting table is executed. POST requires an HTTP(S) URL and format="auto"; automatic pagination, retry, and incremental ingestion remain outside this contract. Datasource-owned bearer or custom headers are applied only within http_scope, so credentials remain outside the source descriptor.

changes = md.json(
"http://change-focus.example/api/v2/change/list",
schema={"change_id": "int64", "title": "string"},
method="POST",
body={
"platform_id": 1,
"source_type": 2,
"specific_source": [md.source_param("app_id")],
"env_id": [1],
"page_num": md.source_param("page_num"),
"page_size": 100,
},
records_path="$.data.change_infos",
)

query_params keeps fixed request parameters in the source descriptor. md.source_param(name) declares a required non-secret runtime value occupying one complete query parameter value; Marivo URL-encodes it and does not support substring templates. Bind exact values around analysis rather than adding an API-specific argument to observe:

with session.source_bindings({
ms.ref.entity("monitoring.api_events"): {
"start": "now-3600",
"end": "now",
},
}):
frame = session.observe(ms.ref.metric("monitoring.pending_containers"))

The binding is nested, execution-local, and keyed by the owning Session runtime; another Session in the same task cannot consume it. Missing or extra parameters fail before the source is read, and the non-secret values participate in snapshot and analysis identity. For datasource authoring evidence, pass the same mapping as inspection.sample(..., source_params={...}).

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.

Evidence cards derive null rates from the bounded row and null counts. If a dimension or values sample contains NULL, the existing evidence judgment asks for null_semantics in ai_context.guardrails. Explain whether NULL means an inapplicable event branch, an unknown value, or something else. Marivo neither labels a high null rate as a quality failure nor filters nulls automatically.

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.

Most projects need the metric path above. Add Event and StateModel objects only when the question depends on ordered occurrences, funnels, elapsed time, or replayed business state.

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.