Skip to content

Analysis Workflow

This page is the deeper reference for the agent-guided journey introduced in First agent-guided analysis.

Give the agent the question, not the procedure

Section titled “Give the agent the question, not the procedure”

Tell the agent what you want to understand, along with any metric, comparison, scope, or focus that is already known. The agent chooses the typed operators, keeps the work in an analysis session, and records the evidence.

For example:

Analyze why completed-order revenue fell last quarter compared with the same quarter a year earlier. Start with regional differences and report the main conclusion, supporting evidence, and limitations.

You do not need to choose operators, request show() calls, or plan checkpoints.

Join the investigation when meaning could change

Section titled “Join the investigation when meaning could change”

The agent should continue independently while the next step stays within the confirmed metric and question. It should pause when it needs to change the metric, population, comparison, or a business assumption in a way that could materially change the conclusion.

Confirm or correct that choice in business terms. If the semantic definition itself is missing or wrong, the affected typed branch stops. The agent may use terminal md.raw_sql(...) with explicit temporary inferred semantics, but it must not change the semantic layer during analysis and must request approval for the smallest durable fix at closeout.

At the end, check that the conclusion answers the question, the important claims have visible evidence, and the stated limitations do not make the conclusion unsuitable for the intended decision. The technical mechanics are documented below for readers who need to inspect or integrate the runtime.

Every Marivo analysis starts from governed semantic inputs and runs as a write-run-read loop: an agent writes an intent (an operator call plus its fields), runs it, and reads a typed result before deciding the next step. Most analysis starts with session.observe(...) and continues through metric comparison, attribution, discovery, or quality assessment. Event journey and replay-based Lifecycle analysis are specialized paths covered later on this page.

The runtime pieces are:

  • A session holds the guiding question, the semantic catalog, and the persisted results of every step.
  • An operator call is one deterministic analysis step — usually observe, compare, attribute, or discover.<objective>, with specialized namespace operators such as events.match used when needed. Its parameters are the analysis specification.
  • An artifact is the typed result of an operator. Artifacts are the boundary between steps: each operator consumes specific artifact families and produces another.
import marivo
import marivo.analysis as mv
import marivo.semantic as ms

When analysis needs a newly authored or changed object, close out that change first: md.inspect(...) → explicit scope → inspection.sample(...) once → snapshot projections → one Python declaration → current entry → catalog.verify(entry)catalog.preview(entry, using=snapshot) → zero-query catalog.readiness(refs=[entry]). The equivalent exact-ref form remains valid. Routine analysis of unchanged objects does not repeat this authoring evidence flow. Uncommon formats and semantic judgments remain agent-owned.

session = mv.session.get_or_create(name="revenue-investigation", question="Why did Q4 drop?")
catalog = session.catalog
revenue = catalog.metrics.get("sales.revenue")
region = catalog.dimensions.get("sales.orders.region")
revenue.details().show() # definition, candidate axes, and measure lineage
region.details().show()

get_or_create is idempotent: it attaches to an existing session of that name or creates one, and sets it current. The narrow session API is get_or_create, current(), recent(), inspect(name), and delete(name).

Reusing a session name with a different non-empty question emits SessionQuestionMismatchWarning and keeps the original question — pass a fresh name to start a new analysis, or omit question to resume the existing session.

repr(session) points to session.show(). Call it for a bounded state card with the question, read/write status, report timezone, timestamps, and the registered direct operators, namespaces, and catalog/job/frame inspection paths; session.render() returns the same text without writing stdout.

Use catalog.domains.show() and scoped navigation to discover entries, then inspect every metric, dimension, and time dimension used by the analysis with .details().show(). The details output is where authored ai_context, metric composition, effective entities, candidate axes, and measure lineage appear. Candidate axes are static discovery; observe remains the authority for relationship and fanout validity. .contract() is reserved for mechanical verify/preview/readiness continuations, while .render() returns the same bounded card that .show() prints. If a required object is missing or disputed, stop the affected typed branch before the first observe; terminal raw SQL may continue provisionally, while semantic authoring waits for closeout approval.

MetricFrame.show() makes these persisted execution facts visible: the observation window, grain, axes and slices; aggregation, additivity, and reaggregation status; and any fold strategy, status axis, sample interval, or snapshot identity keys. Sampled folds include expected-slot coverage. Unsampled snapshot selection explicitly reports that expected-sample coverage is not applicable. A recovered frame renders the same facts without re-querying.

Operators take exact current entries/refs, closed expressions, and a few shared value objects. You will reuse these across almost every intent:

ValueShapeMeaning
metric inputcatalog.metrics.get("sales.revenue")Exact current MetricEntry or Ref[metric], or a closed RuntimeMetricExpr for observe; stale/cross-catalog entries, generic refs, and strings are rejected.
dimension inputcatalog.dimensions.get("sales.orders.region")Exact current dimension/time-dimension entry or matching ref (see Semantic refs). Used for dimensions, slice_by keys, and axes.
time_scopemv.time_scope(start="2026-10-01", end="2027-01-01")Half-open time range — start inclusive, end exclusive.
grainmv.grain("day") or ms.calendar_grain(...)Unified time bucket value. Present ⇒ time series or panel.
dimensions[region, country]Segment axes. In v1 all must resolve to the metric’s entity.
slice_by{region: "US"} or {amount: {"op": ">", "value": 100}}Pre-aggregation row filter (see ops below).
AlignmentPolicymv.window_bucket()How two windows are paired for compare / correlate.

Treat a supplied time_scope.end literally. Marivo does not include that bound, and an agent should not advance it to reinterpret the request as an inclusive range. For example, end="2026-06-30" excludes June 30; use end="2026-07-01" only when the intended range includes all of June.

The result’s semantic kind follows from grain and dimensions: scalar (neither), time_series (grain only), segmented (dimensions only), or panel (both).

Keys are catalog dimensions; values are a scalar (==), a list (in), or a structured {"op": ..., "value": ...} form:

FormMeaning
"US"== (equality)
["US", "CA"]in (membership)
{"op": "!=", "value": "US"}not equal
{"op": ">", "value": 100} (>=, <, <= likewise)numeric comparison
{"op": "between", "value": ["2026-07-01", "2026-09-30"]}inclusive range (exactly two values)

The starting point for any analysis: materialize a metric over a time range and/or segments.

ParameterTypeRequiredDefaultMeaning
metrics`MetricEntryRef[metric]RuntimeMetricExpr`, or non-empty sequenceYes
time_scopeTimeScopeNoNoneOne scope returned by mv.time_scope(...) or an exact catalog period lookup.
grainGrainNoNoneOne value returned by mv.grain(...) or ms.calendar_grain(...).
dimensionsentry/ref sequenceNoNoneExact current dimension/time-dimension entries or refs.
slice_byentry/ref-keyed mappingNoNoneGlobal pre-aggregation row filter.
time_dimensiontime-dimension entry/refNoentity defaultPick the time axis when the entity declares several.
expect_shapeshapeNoNoneGuard; raises before backend work if the predicted shape differs.

Current entries are also accepted for Session.attribute(axes=...), discovery search-space/peer axes, and transform slice/rollup axes. Every qualifying call normalizes entries to canonical refs before planning, persistence, evidence, or replay; catalog.require(ref) remains the strict ref-only path for identity recovered from configuration, logs, or persisted state.

current = session.observe(
revenue,
time_scope=mv.time_scope(start="2026-10-01", end="2027-01-01"),
grain=mv.grain("month"),
dimensions=[region],
)

When a temporal request has no candidate time dimension, receives an ordinary dimension as its time axis, has ambiguous candidates, resolves different implicit axes across metric roots, or requests an incompatible encoding/grain, Marivo fails before backend execution whenever compiled catalog facts are sufficient. The structured repair exposes exact current candidates without guessing a time axis; a retry appears only when the replacement is mechanically unique.

Pass a sequence through metrics= to observe several same-scope metrics in one frame. metrics also accepts a single metric, while the single-metric positional form remains valid. The former metric= keyword is not an alias. Marivo requires temporal roots to resolve the same exact time-dimension ref, fuses same-datasource metrics into one query, and outer-joins compatible cross-datasource metrics on that shared time axis. Use frame.metric(id) to project an arity-1 frame for drill-down on a single metric.

Every public read of a single-metric MetricFrame uses the metric short name for its value column, including show(), columns, contract(), indexing, and to_pandas(). The name stays stable after transforms. When it collides with an axis column, Marivo uses the qualified metric id instead and appends a deterministic #N suffix if that name is also occupied. Multi-metric frames continue to use one column per metric, so frames can be merged without arity-specific renames. Read frame.value_columns for the exact public name(s).

Every artifact card also prints output_columns, the exact ordered names returned by .columns and to_pandas(). The mechanical contract exposes the same tuple as contract.output_columns, derived directly from contract.artifact_schema.columns. For Metric, Event, and Lifecycle artifacts, contract.semantic_inputs records the retained semantic role/path, any bound output column, the exact session.catalog.<collection>.get("<path>") reacquisition call, and its focused catalog help target. The card renders the same acquisition calls, so agents do not need to infer a catalog collection from a result column.

Typed transforms still use the internal canonical value column. For example, frame.transform.filter(predicate=lambda data: data["value"] > 0) remains valid; afterward, read the result with filtered[filtered.value_columns[0]] or filtered.to_pandas().

report = session.observe(
metrics=[revenue, catalog.metrics.get("sales.total_orders")],
time_scope=mv.time_scope(start="2026-10-01", end="2027-01-01"),
grain=mv.grain("month"),
)
revenue_only = report.metric("sales.revenue")

When a downstream capability requires one metric, a multi-metric frame.contract() exposes one executable full-id frame.metric(...) projection for every carried root. It does not choose one.

When the catalog does not contain the required analysis-only caliber, build a closed expression and still materialize it through session.observe(...):

failed = mv.runtime_metric.aggregate(
failed_requests_measure,
agg="sum",
slice_by={state: "failed"},
label="Runtime failed requests",
)
weighted_latency = mv.runtime_metric.weighted_mean(
latency_measure,
request_count_measure,
label="Runtime weighted latency",
)
failure_rate = mv.runtime_metric.ratio(
failed,
requests,
zero_division="null",
label="Runtime failure rate",
)
frame = session.observe(failure_rate, time_scope=window, grain=mv.grain("day"))

Runtime expressions may recursively contain runtime expressions and catalog Ref[metric] children. runtime_metric.slice(..., by=...) is branch-local; observe(..., slice_by=...) is global. Both catalog and runtime roots lower to one canonical graph (maximum depth 10, maximum 256 submitted pre-CSE occurrences) and one model/source domain. Labels affect presentation only, not value identity or catalog authority. Every runtime_metric constructor, including nested expressions, requires a non-empty label. The label becomes the stable public value-column handle when the expression is materialized as an observed root. Downstream operators use persisted frame state rather than catalog/runtime origin.

runtime_metric.weighted_mean(value, weight) performs row-level multiplication and paired aggregation automatically. Both measures must have the same entity and physical row grain, and the weight must be additive; no precomputed SUM(value * weight) measure is required.

Every observed root and mixed forest persists a recursive component graph. Use frame.components() to inspect node roles, evaluator contracts, quality, coverage references, and governed leaf lineage. A separate component_ref exists only when the root also supports numerical decomposition.

Observed additivity, aggregation, and status-time semantics participate in the artifact identity for single, derived, cumulative, and multi-metric frames. After an upgrade, re-running observe therefore bypasses legacy cached frames that do not carry the current semantic gate.

Quantify change between two observe results (current minus baseline). The frames must share semantic kind and persisted comparable value semantics; their catalog and runtime identities may differ.

ParameterTypeRequiredDefaultMeaning
currentMetricFrameYesCurrent-period frame.
baselineMetricFrameYesBaseline-period frame.
alignmentAlignmentPolicyNowindow_bucketHow buckets/segments are paired.
baseline = session.observe(
revenue,
time_scope=mv.time_scope(start="2025-10-01", end="2026-01-01"),
grain=mv.grain("month"),
dimensions=[region],
)
delta = session.compare(current, baseline)

compare pairs buckets with window_bucket by default. Pass alignment= to choose one closed helper: mv.window_bucket(), mv.day_of_week(), mv.period_progress(), mv.period_correspondence(), or mv.occurrence_progress(), or mv.working_day_progress(schedule=...). The latter five require exact certified authority carried by the source frames; the period helpers use a period authority, occurrence_progress uses temporal-set occurrence scopes, and working_day_progress uses one exact work schedule. There is no analysis-local holiday-calendar parameter.

mv.occurrence_progress(anchor="start" | "end") is reserved for day-grain time-series or panel frames selected by exact TemporalSet occurrence scopes. It pairs zero-based effective local-day ordinals (forward from the start or backward from the exclusive end); it does not infer recurrence or label matches. The built-in day grain still uses each session’s report timezone; it must match the occurrence boundary timezone for occurrence_progress admission.

mv.working_day_progress(schedule=...) is also restricted to day-grain time-series or panel frames. It excludes certified non-working dates, pairs the remaining zero-based working-day ordinals, and records exclusions and unmatched ordinals in alignment evidence.

The delta carries additivity, aggregation, and status-time semantics only when both source frames have the same three values and additivity is known. Missing or mismatched source semantics remain unknown, causing attribution to fail closed instead of inheriting one side.

Attribute a delta’s movement across explicit axes. This is the default public entry point for “why did it change?” analysis. Component-aware ratio and weighted-mean deltas use mix attribution. Plain segmented non-linear point estimates still cannot be summed. Graph-owned count_distinct, median, and percentile(q) roots instead use dedicated distribution-aware methods when their persisted attribution basis is admitted.

The DeltaFrame persists the semantic gate used by attribution; Marivo does not look up a possibly changed catalog at attribution time. The original delta is checked before Marivo replays observations to materialize a requested axis. DeltaFrame.show() states whether attribution is supported or blocked, and DeltaFrame.contract().attribute_admission is the sole typed source for that same boundary: unknown and ordinary non-additive deltas fail, semi-additive deltas require axes that exclude the status time dimension, and persisted ratio/weighted-mean component paths remain available.

Persisted metric semanticsAxis attribution
additiveSupported by sum and hierarchy attribution.
semi_additiveSupported on non-time axes; rejected on its status_time_dimension.
Component-aware ratio / weighted_meanSupported by ratio/weighted mix attribution.
Tier-1 mean over a measureAutomatically lowered to sum(measure) / count_non_null(measure) and supported by weighted mix attribution.
Graph-owned count_distinctDistinct membership allocation for reproducible scalar keys; raw distinct keys remain in the datasource.
Graph-owned median / percentile(q)Exact DuckDB value-frequency or mergeable Trino qdigest replacement attribution. ClickHouse reservoir sampling remains blocked.
Other non_additive metricsRejected, including opaque/tier-2 means, min, max, unsupported quantile sources, and non-additive linear compositions.
Missing additivity metadataRejected. Re-run observe and compare before retrying.

Mean lowering uses non-null measure count, not entity row count. For a rejected metric, follow DeltaFrame.contract().attribute_admission: re-observe legacy artifacts or author the aggregate-specific component/distribution evidence named by its repair.

ParameterTypeRequiredDefaultMeaning
frameDeltaFrameYesThe delta to attribute.
axeslist[dimension]YesSegment or time axes to attribute over.
mode"joint" | "hierarchy" | "multiresolution"Multi-axis onlyAdditive/component methods allow joint or hierarchy; non-additive methods allow joint or independent multiresolution. Read the delta admission for the legal pair.
attribution = session.attribute(delta, axes=[region, platform], mode="joint")
attribution.show()

For the canonical single-axis case, omit mode:

attribution = session.attribute(delta, axes=[region])

For joint, contribution rows are additive and sum to the overall change. For hierarchy, parent rows repeat their descendants’ total; sum only the deepest level when reconciling the change. Component-aware ratio and weighted-mean attribution preserves value_effect, mix_effect, and residual in both modes. Multi-axis calls have no default mode; omit mode for a single axis, where a supplied value has no effect. Single-axis output keeps the resolved dimension column name, such as cluster, and can be joined directly to the source DeltaFrame on that column. The namespaced attribution_level, attribution_axis, attribution_driver, and attribution_path columns appear only in multi-axis hierarchy output. Additive single-axis attribution reports attribution_shape="sum". The preserved dimension name must not collide with attribution result, value, or panel bucket columns. A collision fails closed with a structured semantic-authoring repair instead of producing duplicate or ambiguous columns. Evidence protocol fields use explicit metadata mapping and do not reserve user dimension names. attribution.attribution_mode exposes the row layout, while attribution.attribution_shape / meta.method expose the attribution math. Both layouts can therefore report method="weighted_mix". Use marivo.help("analysis.AttributionMode") for the focused distinction.

Count-distinct membership is deduplicated and allocated in the datasource as 1 / membership_degree; raw keys do not enter frames, jobs, lineage, evidence, telemetry, logs, or errors. Quantiles replay an independent unsegmented endpoint instead of summing segmented point estimates. DuckDB uses exact value-frequency evidence with exact Shapley through 8 partitions and 128 deterministic permutations through 64; Trino merges qdigest sketches server-side. Empty intermediate coalitions, more than 64 partitions, more than 250,000 frequency rows, or failure to reproduce the independent endpoint block the call and do not persist an attribution artifact.

mode="multiresolution" recomputes every ordered axis prefix as an independent game. Complete rows are not additive across resolutions. Select one immutable, query-free view by exact semantic-ref prefix before consuming it:

regional = attribution.at_resolution(axes=[region])
regional.to_pandas() # sum once within each comparison bucket

Contribution shares name their denominator explicitly:

  • share_of_total_delta is signed and may exceed 100% when a driver offsets the net movement;
  • share_of_positive_pool is populated only for positive contributions;
  • share_of_negative_pool is populated only for negative contributions and reports a positive within-pool share.

Marivo does not rename either sign pool to “improvement” or “degradation” because metric desirability is not part of the persisted metric contract. Apply that interpretation only when the business direction is known. New and churned segments retain exact one-sided component contributions. AttributionFrame.show() prints a reconciliation line with the total delta, contribution sum, one-sided contribution sum, unattributed contribution sum, and residual; attribution fails closed if a deepest partition does not reconcile within numeric tolerance.

Measure the association between two metrics over aligned buckets.

ParameterTypeRequiredDefaultMeaning
a, bMetricFrameYesThe two frames to associate.
measure_a, measure_bstrNoframe measurePublic value name from each frame’s value_columns.
alignmentAlignmentPolicyNowindow_bucketBucket pairing.
method"pearson", "spearman", "kendall"No"pearson"Correlation method.
lag_rangerange or sequence of signed integersNolag 0Lags to evaluate; k pairs a[t] with b[t+k]. Positive means a leads b; negative means b leads a.

Non-zero lags require time_series or panel inputs. Panel lags shift within each dimension series, never across series boundaries, and null pairs are dropped after shifting so missing buckets do not collapse the time axis. Each lag requires at least two overlapping, non-constant pairs. The result contains one row per lag and records the strongest absolute correlation as meta.best_lag.

Omit measure_a / measure_b to use each frame’s unique metric value, or pass the exact public names returned by a.value_columns and b.value_columns.

Project a time series or panel forward. A history observed with a certified semantic grain keeps its exact SemanticPeriodBindingV1: forecast periods are complete, consecutive ordinal steps, and future keys/bounds come from the same snapshot rather than a fixed-frequency approximation.

ParameterTypeRequiredDefaultMeaning
historyMetricFrame (time_series/panel)YesContinuous history, no NaNs.
horizonintYesPeriods to project (≥ 1); for semantic grains, the count is in certified period ordinals.
model"naive" | "seasonal_naive" | "drift"No"seasonal_naive"Forecast strategy.
seasonality_periodintNoby grainSeasonal ordinal distance. Built-in defaults are day=7, week=52, month=12, quarter=4; semantic grains require an explicit value for seasonal_naive.
interval_levelfloatNo0.95Confidence level for the prediction interval.
measure_columnstrNoframe measurePublic value name from history.value_columns.

Every panel series must cover the same continuous training periods. For semantic grains, an incomplete/mismatched period, unavailable exact snapshot, unsupported model, or future coverage gap raises ForecastShapeUnsupportedError before an artifact is written. Forecast does not silently fill a period, infer seasonality, or manufacture a boundary from average history duration. Built-in panel gaps continue to raise ForecastInputQualityError.

history = session.observe(
revenue,
time_scope=mv.time_scope(start="2026-01-01", end="2026-04-01"),
grain=mv.grain("day"),
)
projection = session.forecast(history, horizon=30, measure_column=history.value_columns[0])

Run quality checks over an artifact and return a separate QualityReport. artifact.quality_summary is only a cheap persisted metadata projection; it is not the same thing as running session.assess_quality(artifact). Read report.overall_status, report.blocking_issue_count, and report.warning_count for the authoritative verdict and counts. report.state remains ArtifactState materialization metadata; it is not a quality status. For a cumulative comparable-period DeltaFrame, the report reads the typed pairing evidence and surfaces matched-null, unpaired, and fallback counts. The report’s contract().issues contains typed data-quality failures. Do not report a window as a complete period fact when its report contains a blocking time_coverage_incomplete issue.

ParameterTypeRequiredDefaultMeaning
framesupported analysis artifactYesThe frame to inspect, including metric DeltaFrames.

Paired test of whether a metric’s mean changed between two periods.

ParameterTypeRequiredDefaultMeaning
a, bMetricFrameYesCurrent and baseline frames.
hypothesis"mean_changed"No"mean_changed"Test type (v1).
value_a, value_bstrNoframe measurePublic value name from each frame’s value_columns.
alignmentAlignmentPolicyNowindow_bucketPairing for the test.
samplingSamplingPolicyNoinferredPairing/min-sample rules.
alphafloatNo0.05Significance level.

Discovery — session.discover.*CandidateSet

Section titled “Discovery — session.discover.* → CandidateSet”

Discovery operators search an artifact for deterministic candidate rows and return a CandidateSet. Scored objectives use deterministic score order; ontology hypotheses are unscored and use deterministic semantic identity order. Neither order is a recommendation from Marivo.

HelperSource shapeRequiredKey options
point_anomaliesMetricFrame time_series/panelvalue, threshold=3.0
period_shiftsDeltaFrame time_series/panel≥ 7 bucketsvalue, threshold=2.0
driver_axesDeltaFramesearch_spacevalue, limit
interesting_slicesMetricFrame or DeltaFramesearch_space, value, threshold=2.0, limit
interesting_windowstime_series/panel framevalue, threshold=2.0
cross_sectional_outliersMetricFrame segmented/panelpeer_scope, value, threshold=3.0
semantic_hypothesesarity-one catalog MetricFrame or same-Metric DeltaFrameready optional ontologylimit=50 (1..200)
series = session.observe(
revenue,
time_scope=mv.time_scope(start="2026-01-01", end="2026-04-01"),
grain=mv.grain("day"),
)
candidates = session.discover.point_anomalies(series, threshold=2.0)
candidates.show()
item_id = str(candidates.to_pandas().iloc[0]["item_id"])
selection = candidates.select(item_id=item_id)
print(selection.kind, selection.window, selection.keys)

select(item_id=...) returns a closed immutable selection variant for the candidate shape. Numeric rank is not accepted. Selection creates no job, artifact, lineage step, or evidence record.

When a source artifact’s contract() advertises the optional ontology continuation, use it as an unscored hypothesis path:

hypotheses = session.discover.semantic_hypotheses(series, limit=50)
hypotheses.show() # edge meaning, guardrails, exclusions, and copyable item_id
candidate = hypotheses.select(item_id="candidate_<full-sha256>")
driver = session.observe(candidate, analysis_purpose="test a reviewed hypothesis")

The selected OntologyMetricCandidate inherits the source observation scope; scope overrides are rejected. Ontology edges and candidates do not seed findings or prove causality. Statistical evidence begins only after the explicit observation and a suitable downstream operator.

Other scored discovery selections are not accepted by session.observe. They identify a row, period, axis, slice, window, or peer already computed in the source artifact rather than a new Metric that still needs materialization.

Transforms reshape a frame while preserving its family (MetricFrameMetricFrame, DeltaFrameDeltaFrame). Each transform is a method on the frame itself — call frame.transform.<op>(...), not a session-level helper.

TransformKey parametersEffect
filterpredicate (callable)Keep rows where the predicate returns true.
sliceslice_by (axis → value/list/range)Keep rows matching exact axis values.
rollupdrop_axes and/or grainDrop axes and re-aggregate, or re-bucket the time axis to a coarser grain. Cumulative frames take the last bucket per period (rollup_fold="last").
topkby, limitKeep the top N rows by a measure.
bottomkby, limitKeep the bottom N rows.
rankby, method, rank_columnAdd a rank column ordered by a measure.
normalizemode, baselineindex / share / pct_change / per_unit / z_score (MetricFrame only).
windowwindowRestrict to a time window.

For deltas, bottomk(by="delta") returns the largest declines because it selects the most-negative values.

Cumulative MetricFrames store running-total values whose semantics depend on the accumulation anchor (all_history, grain_to_date, or trailing).

  • show(), contract(), and transform.window(...) work normally on cumulative frames. contract() and show() surface the anchor-specific caveat, so the allowed path is visible at the frame you are reading.
  • correlate, discover, assess_quality, and hypothesis_test are allowed. Trailing frames are independent windowed aggregations (not running totals), so these intents produce meaningful results. The monotonic-trend caveat applies only to all_history and grain_to_date anchors — keep it in mind when interpreting results.
  • compare is anchor-dispatched:
    • all_history: allowed when both frames share the valid anchor and have paired business coordinates with exact evaluation_end cutoffs. The result is current minus baseline observed level, not asserted interval flow; source revision is unverified.
    • trailing: allowed when both frames have the same canonical fixed-duration span; equivalent units such as 7 day and 1 week compare successfully.
    • grain_to_date: allowed for a single-period, boundary-anchored window. The window must start on a reset boundary and span at most one reset period, and both frames must share the reset grain and query grain.
    • Both comparable-period anchors support ordinal window alignment plus DOW, holiday, and holiday-then-DOW position alignment. For grain_to_date, the calendar policy’s period must equal the reset grain. Workday-position alignment is not a public comparison mode in this version.
  • For derived metrics, the same compare paths are allowed only when every outer component is cumulative and all components share exactly one anchor, including all_history. Mixed anchors, cumulative/non-cumulative mixes, malformed metadata, and current/baseline anchor differences fail closed. All-history component sidecars use the parent’s exact paired business-coordinate set.
  • attribute accepts only a current cumulative DeltaFrame produced by compare. Business dimensions replay the same cumulative observations and explain endpoint level change; requesting exactly the cumulative over axis uses an additive base-flow bridge for direct sum/count structures. Homogeneous ratio and weighted cumulative deltas support business-axis component mix, but their time bridge is blocked. Mixed time/business axes and cumulative count_distinct fail closed; inspect delta.contract().cumulative_attribution for the typed route status.
  • decompose and forecast still reject cumulative frames. Use attribute on the current cumulative delta when one of its typed routes is supported, or observe the base flow metric for a different analysis.
  • transform.rollup(...) re-aggregates cumulative frames with rollup_fold="last": the time axis is re-bucketed to the target grain and each period contributes its complete last row, including evaluation_end. Frames that are neither re-aggregatable nor carrying a rollup_fold are rejected — re-observe at the target grain instead.

Every direct or structurally complete derived cumulative row carries the reserved, UTC-serialized evaluation_end coordinate. For all-history deltas, inspect the exact current_evaluation_end and baseline_evaluation_end columns. One-sided coordinates are dropped and counted in delta.meta.alignment["cumulative_pairs"]; a matched null remains paired and produces a null delta.

Trailing and grain-to-date deltas expose delta.meta.cumulative_alignment, which retains both authored anchors, the canonical anchor used for identity, and exact matched-null, current-only, baseline-only, and fallback counts. Only matched coordinates enter the final delta, including for panel frames.

cum_frame = session.observe(
cumulative_active_users,
time_scope=mv.time_scope(start="2026-01-01", end="2026-04-01"),
grain=mv.grain("day"),
)
cum_frame.contract().show() # shows the anchor-specific caveat
windowed = cum_frame.transform.window(window={"start": "2026-02-01", "end": "2026-03-01"})
# Roll up a cumulative frame to a coarser grain (period-end semantics):
monthly = cum_frame.transform.rollup(grain=mv.grain("month"))
# For attribute/forecast, observe the base metric instead:
base_frame = session.observe(active_users, ...)

First use a closed mv.runtime_metric expression when governed measures or catalog metrics can express the required analysis-only caliber. When the calculation still cannot be expressed through session.observe(...), use md.raw_sql(...) — the sole terminal raw SQL execution path. It enforces a timeout, bounds returned rows, and runs read-only. The result is a RawSqlResult that cannot re-enter typed analysis; call RawSqlResult.to_pandas() for the terminal pandas exit. Its ordered columns, shape, and row_count describe returned bounded rows only: row_count == shape[0] == returned_row_count. Read requested_limit and is_truncated alongside that count. It has no .contract() or typed affordances.

A semantic gap may also use this path without prior approval. The agent may infer a temporary metric, dimension, relationship, or filter definition, but must put those assumptions in the analysis record and keep raw-SQL-supported claims separate from canonical artifact evidence.

import marivo.datasource as md
result = md.raw_sql(
ms.ref.datasource("warehouse"),
"SELECT region, SUM(amount) AS revenue FROM orders GROUP BY region",
reason="custom revenue breakdown",
)
result.show()
df = result.to_pandas()

Use artifact.to_pandas() for terminal local pandas work from a typed frame. Neither md.raw_sql(...) results nor to_pandas() exports can re-enter typed analysis. Raw SQL does not repair missing business semantics: the closeout still requests approval for the smallest semantic change. Typed regression is not supported by the current Marivo operator surface; a regression task therefore remains explicit terminal custom analysis and cannot be promoted back into the typed chain.

The agent read path is layered to keep scripts cheap across loops:

repr(delta) # cheap single-line hint
delta.show() # bounded current-state read
delta.contract().show() # mechanical compatibility and typed issues
delta.to_pandas() # terminal export for custom local analysis

When an operator emits evidence, artifact.show() includes its bounded typed digest before the result preview. The same value is available as artifact.evidence_digest; check artifact.evidence_status for degradation. Use session.evidence for exact findings and derivation traces.

Use artifact.contract().affordances to inspect mechanical compatibility. Each affordance carries a capability_id (stable registry id), public_entrypoint, help_target, role-preserving inputs, preconditions, and expected_output_family. Every input records its exact parameter name, accepted artifact families, and whether the current artifact can bind it. The contract does not rank, recommend, enumerate calls, or decide the next step. artifact.contract().boundary_ports lists typed terminal-exit ports (e.g. boundary.to_pandas) with preserves and does_not_preserve guarantees.

When an operator fails, it raises a typed AnalysisError subclass. Every error carries stable typed fields — expected, received, location, and repair — not a generic details bag. The repair field is a typed AnalysisRepair object with kind (retry, inspect, user_choice, semantic_authoring, environment), action, help_target, optional snippet, and optional candidates drawn from live state. user_choice means several mechanically legal alternatives remain and business judgment must select one. Follow the repair guidance; it points to the exact marivo.help("analysis.<target>") target for the failing capability.

Event continuation errors use the same contract. Matching-policy and step choices are user_choice; stale artifacts and unknown coverage are inspect; unsafe relationship paths or temporal authoring gaps are semantic_authoring.

Marivo maintains a typed analysis chain. The terminal exits are frame.to_pandas() and md.raw_sql(...). frame.to_pandas() returns a defensive copy for custom local analysis; md.raw_sql(...) returns a RawSqlResult with RawSqlResult.to_pandas() as its own terminal pandas exit. Neither preserves lineage or evidence continuity. Terminal results cannot re-enter typed analysis. If business semantics are missing, terminal raw SQL may provide only a provisional result; retain the gap for closeout approval before semantic authoring.

Typed analysis accepts exact current catalog entries or stable semantic refs at the qualifying runtime boundaries, normalizes both forms immediately to refs, and never guesses business meaning. If a required metric, dimension, or time dimension is missing or disputed, the affected typed branch stops. The agent may infer temporary semantics only in a terminal md.raw_sql(...) branch and must disclose the datasource, purpose, assumptions, bounded result, and loss of canonical identity, lineage, and evidence continuity. It proposes the smallest durable semantic change at closeout and follows marivo-semantic only after explicit approval. The semantic layer owns the business-object contract; the analysis layer owns typed computation over those objects.

When a later script needs previous work, recover persisted facts instead of rerunning upstream queries:

summaries = session.frame_summaries(limit=20)
jobs = session.recent_jobs(limit=5)
previous = session.get_frame(ref)
# Only when recovery, a repeated task, or a recurring failure makes history relevant:
history = mv.session.recent(limit=10)
prior = mv.session.inspect(history.items[0].name) if history.items else None

frame_summaries() returns a FrameSummaryPage. Continue with the opaque next_cursor when has_more is true. Pages use ordinary keyset semantics, not snapshot isolation.

Use marivo.help("analysis.recovery") to list these real Session members. recovery and artifacts are help topics, not session.recovery / session.artifacts namespaces.

Every committed operator records deterministic typed evidence into the session. Marivo does not create cross-artifact judgments or choose the next operation.

  • session.evidence.digests(...) — bounded newest-first digest pages.
  • session.evidence.findings(...) — bounded newest-first finding pages.
  • session.evidence.digest(ref), .finding(id), .trace(id) — exact reads and finding derivation audit.

See Evidence for the full model.

import marivo.analysis as mv
session = mv.session.get_or_create(name="revenue-check", question="Why did Q4 drop?")
catalog = session.catalog
revenue = catalog.metrics.get("sales.revenue")
region = catalog.dimensions.get("sales.orders.region")
current = session.observe(
revenue,
time_scope=mv.time_scope(start="2026-10-01", end="2027-01-01"),
grain=mv.grain("month"),
dimensions=[region],
)
baseline = session.observe(
revenue,
time_scope=mv.time_scope(start="2025-10-01", end="2026-01-01"),
grain=mv.grain("month"),
dimensions=[region],
)
delta = session.compare(current, baseline)
attribution = session.attribute(delta, axes=[region])
attribution.show()

From the delta you can branch: session.discover.period_shifts(delta) to find when it moved, or session.forecast(current, horizon=3) to project it forward.

Most questions are answered with metrics and the core operators above. Use the Event and Lifecycle paths when the analysis depends on ordered occurrences, attempt matching, funnel progression, elapsed time, or replayed state.

Event analysis starts from typed participant roles and an ordered EventPattern. Each step gets a stable snake-case key; the role carries its Event and subject Entity, and the subject Entity’s primary key supplies identity.

cart_created = catalog.events.get("commerce.cart_created")
checkout_started = catalog.events.get("commerce.checkout_started")
payment_succeeded = catalog.events.get("commerce.payment_succeeded")
cart_user = ms.participant_role(event=cart_created.ref, name="user")
checkout_user = ms.participant_role(event=checkout_started.ref, name="user")
payment_user = ms.participant_role(event=payment_succeeded.ref, name="user")
cart_step = mv.step(participant=cart_user, key="cart")
checkout_step = mv.step(participant=checkout_user, key="checkout")
payment_step = mv.step(participant=payment_user, key="payment")
pattern = mv.sequence(cart_step, checkout_step, payment_step)
journeys = session.events.match(
pattern=pattern,
cohort_window=mv.time_scope(start=start, end=end),
completion_through=followup_end,
matching=mv.first_per_subject(),
)

The cohort window is half-open: only first-step occurrences in [start, end) anchor journeys. completion_through is inclusive follow-up and must be at least end. An optional ready SubjectSet further restricts the subject membership; it does not replace or infer the cohort window.

Use an explicit repeated-attempt policy when every first-step occurrence should anchor an attempt. exclusive assigns one completion to at most one open attempt; shared permits one completion to close multiple eligible attempts:

exclusive_attempts = session.events.match(
pattern=pattern,
cohort_window=mv.time_scope(start=start, end=end),
completion_through=followup_end,
matching=mv.every_start(completion_assignment="exclusive"),
)
shared_attempts = session.events.match(
pattern=pattern,
cohort_window=mv.time_scope(start=start, end=end),
completion_through=followup_end,
matching=mv.every_start(completion_assignment="shared"),
)

EventFrame[journey] is a dense long table with one row for every journey × pattern step. A missing later step remains present with null event, time, and elapsed values. It is incomplete only when every required Event is known complete through the follow-up bound; otherwise it is coverage_censored.

Inspect the exact Event range before choosing a replay or matching window:

lifecycle = session.catalog.state_models.get("commerce.order_lifecycle")
bounds = session.events.occurrence_bounds(lifecycle)
print(bounds.earliest_occurrence_at, bounds.latest_occurrence_at)

For a StateModel, Marivo infers its distinct inception and transition Events and aggregates only occurrences matching those Event predicates. The EventOccurrenceBounds result is not a Datasource-wide maximum and does not prove that the source is complete through its latest occurrence. Use it to choose a candidate window, then resolve completeness separately. A StateModel with no Event triggers returns event_refs=() and None for both bounds.

A backend may provide authoritative Event completeness through marivo_event_watermark(request). Marivo does not infer that watermark from the maximum observed Event time, query time, or an SLA. A caller reads that authoritative watermark with session.events.watermark(event, through=...), which returns the provider’s EventWatermarkReceipt — or None when no provider exists or the Event has no authoritative watermark. When authoritative coverage is unavailable, the caller may attach an exact, bounded assumption:

coverage = mv.declared_complete_through(
inputs=(cart_created, checkout_started, payment_succeeded),
through=followup_end,
rationale="Warehouse reconciliation completed through followup_end.",
)
journeys = session.events.match(
pattern=pattern,
cohort_window=mv.time_scope(start=start, end=end),
completion_through=followup_end,
matching=mv.first_per_subject(),
completeness=(coverage,),
)

Reduce Event journeys and compose a typed cohort

Section titled “Reduce Event journeys and compose a typed cohort”

A first-per-subject journey has three Phase 2 continuations. Funnel reduction uses persisted occurrence assignment and performs no Event query. With axes, only governed subject-Dimension enrichment at the first-step occurrence time may query a datasource:

funnel = session.events.funnel(
journeys,
axes=[acquisition_channel],
analysis_purpose="Measure checkout conversion by entry channel.",
)
elapsed = session.events.time_to_event(
journeys,
start_step=checkout_step,
end_step=payment_step,
analysis_purpose="Measure checkout-to-payment elapsed time.",
)
dropouts = session.select_subjects(
journeys,
selection=mv.dropped_before(step=payment_step),
)

EventFrame[funnel] keeps additive counts, censoring-aware rates, and grouped reconciliation evidence. EventFrame[time_to_event] keeps the exact source start/end assignment and never rematches an alternative completion. SubjectSet exposes only its governed subject_identity tuples. Raw identities do not appear in cards, jobs, evidence, structured errors, or consumer metadata.

Only a ready SubjectSet can scope observe or a later events.match:

dropout_revenue = session.observe(revenue, cohort=dropouts)
return_journeys = session.events.match(
pattern=return_pattern,
cohort=dropouts,
cohort_window=mv.time_scope(start=start, end=end),
completion_through=followup_end,
matching=mv.first_per_subject(),
)

Subjects whose loss remains coverage_censored are excluded from selection and counted in SubjectSet metadata. Such a SubjectSet remains readable and recoverable, but cohort admission raises event_coverage_unknown. Funnel and dropped_before require first_per_subject; time-to-event also accepts repeated-attempt journeys because it preserves one row per assigned attempt.

Observed watermarks take precedence over declarations. The artifact and its quality/evidence records retain each input’s observed_watermark | declared_complete | unknown basis and the aggregate observed_watermark | declared_complete | mixed | unknown basis.

Funnel comparison and loss-rate attribution

Section titled “Funnel comparison and loss-rate attribution”

Two compatible EventFrame[funnel] artifacts use the same compare entry point, but accept no alignment= argument. Marivo aligns exact persisted PatternStep identity plus the complete axis tuple; one-sided tuples zero-fill additive counts while absent-side rates remain null.

funnel_delta = session.compare(current_funnel, baseline_funnel)
payment_loss = mv.funnel_loss_rate(step=payment_step)
drivers = session.attribute(
funnel_delta,
axes=[acquisition_channel],
target=payment_loss,
)

The attribution input must be an ungrouped funnel delta. It re-aggregates the persisted journey assignments over governed cohort-entry Dimensions and never rematches Events. Each driver emits additive loss and denominator_mix components whose sum reconciles to the target loss-rate delta within 1e-9. Use mode="joint" or mode="hierarchy" for multiple axes. Hierarchy pool shares normalize within each displayed level; artifact metadata retains the deepest joint-partition pools used for reconciliation. Contributions describe arithmetic decomposition of an observed change, not causality.

DeltaFrame[funnel] is a closed artifact family: it exposes only its own funnel fields and never projects Metric Delta facets. Metric-only continuations (components(), transform.*) fail closed with a structured error instead of pretending a nonexistent component graph or metric contract.

Lifecycle analysis applies one exact current StateModel to its modeled Event stream. The model owns states and legal transitions; the analysis call owns the window, explicit seed, optional cohort, and any completeness declaration. Inspect marivo.help("analysis.lifecycle.replay") before first use rather than copying a cached signature.

order_model = catalog.state_models.get("sales.order_lifecycle")
history = session.lifecycle.replay(
order_model,
window=mv.time_scope(start=start, end=end),
seed=mv.from_inception(),
analysis_purpose="Reconstruct governed order state history.",
)

The replay window is timezone-aware and half-open. mv.from_inception() is an explicit required choice: replay starts each subject at its first qualifying modeled inception, evaluates all modeled occurrences before window.end, and emits only intervals overlapping the requested window. A StateModel without an inception can be loaded for future projection seeding but is not ready for this replay path.

Each distinct trigger Event is queried at most once. Event predicates, participant-role resolution, identity validation, deterministic ordering, and completeness use the same Event core as journey matching. Completeness declarations may cover only Events consumed by the selected StateModel, and an authoritative provider watermark takes precedence. Events outside the model are neither queried nor violations.

Legal triggers change state. A modeled trigger that is illegal from the current state leaves the state unchanged and is retained in the replay’s private violation trace. Same-time cross-Event occurrences fail when their order would change the resulting state or violation outcome.

LifecycleFrame[history] contains ordered, non-overlapping state intervals. Its interval status is completed, right_censored, or coverage_censored. Read history.show() for the bounded current state and history.contract() for mechanically valid continuations.

Reduce Lifecycle history and select a state cohort

Section titled “Reduce Lifecycle history and select a state cohort”

Lifecycle reducers consume the committed history without querying Event sources or replaying the StateModel:

weekly_state = session.lifecycle.distribution(
history,
at=(week_1_end, week_2_end),
axes=[account_region],
)
transition_counts = session.lifecycle.transitions(history)
dwell = session.lifecycle.dwell(history)
violations = session.lifecycle.violations(history)

Distribution is dense over every requested instant and declared model state. Governed subject axes resolve at each instant, and grouped counts reconcile to the ungrouped state population. Transitions includes every distinct modeled state pair, even when its count is zero. Dwell includes every declared state; duration statistics use only completed clipped intervals while censored intervals remain explicit counts. Violations is a typed copy of the committed trace, not a policy-breach, quality-failure, or causal label.

Select subjects through an exact typed model-state handle, never a bare state string:

paid_state = ms.model_state(model=order_model.ref, name="paid")
paid_at_end = session.select_subjects(
history,
selection=mv.in_state(paid_state, as_of=end),
)

Subjects whose state is unknown or coverage-censored at the instant are excluded and retained as censoring metadata. The resulting SubjectSet remains readable, but only a ready set can scope a later observe, events.match, or lifecycle.replay. Use focused marivo.help("analysis.in_state") and the source artifact contract for the exact current admission rules.

FrameProduced by
MetricFrameobserve
DeltaFramecompare
AttributionFrameattribute
AssociationResultcorrelate
ForecastFrameforecast
QualityReportassess_quality
HypothesisTestResulthypothesis_test
CandidateSetdiscover.*