Skip to content

Changelog

All notable changes are documented here. The project follows Semantic Versioning; milestone releases are tagged on main.

[Unreleased]

[0.15.1] — Docs refresh for 0.10 to 0.15 — 2026-10-20

This release updates the documentation for v0.10.0 to v0.15.0. It has no code changes.

Changed

  • New page "What's new in 0.10 to 0.15", first in the Home group of the nav, with screenshots from the release checks. The home page, why, use cases, getting started and roadmap pages describe run tracking, the MMM pack, causal models, run comparison, image data and deep learning. The roadmap shows v1.0 (MLflow export, system metrics, retention) as planned.
  • New concept pages: run tracking, image collections and causal effects. Capabilities, representations, bundles, workflows, quotas, security and architecture cover v0.10 to v0.15.
  • Guides, model pages and tutorials agree with the code: tracking, run comparison, image data, deep learning, the explorers, the CLI, the Python client, the HTTP API, MMM, causal, time series, sklearn and Guided mode.
  • Reference and operations pages cover the base-dl image, the GPU class on Docker and Cloud Run (not tested on real hardware), the new settings and the new warning codes. Every package and plugin README is current, including mapi-jax and mapi-plugin-causal.
  • The Guided templates screenshot shows all eight templates.

[0.15.0] — Deep learning and image collections — 2026-10-20

Deep learning and image collections

You can now train neural networks on the platform. The new package mapi-jax has three models: a network for tables, an image classifier and a classifier that starts from pretrained weights. The training shows the training and validation curves while it runs, logs images of the predictions, stops early, saves checkpoints and gives draws. Large image sets upload as image collections: the client uploads only the new images, and an upload that stops can continue. A new resource class gives a run one GPU.

Added

  • Image collections: the dataset layout collection. A version is a manifest (manifest.parquet), a summary (collection.json, CollectionInfo) and packs of images, stored one time for each tenant at t/{tenant}/blobs/packs/{digest}.zip. A version can hold 1 000 000 images of different sizes. The pack address is pack_digest(size, crc32c, md5) over the digests that the store calculated. Each image is identified by its SHA-256, and each read makes sure that the SHA-256 is correct. Contracts in mapi_spec.collection.
  • DatasetVersionCreate.collection (CollectionSource: a manifest upload, up to 64 part uploads, a base version and CollectionOptions). The ingest run reads the parts in ranges, makes sure that each image has the SHA-256 of its name, reads the image headers and writes the explorer views of a sample. The finalizer copies the parts to the shared pack path.
  • Resumable uploads and deduplication: GET /v1/datasets/{dataset_id}/uploads/{upload_id} (UploadSession.received), POST .../uploads/{upload_id}/renew and POST /v1/datasets/{dataset_id}/blobs/missing (BlobQuery, BlobMissing: the images that the base version does not have).
  • Client and CLI: datasets.upload_collection, datasets.missing_images, datasets.get_upload, datasets.renew_upload; mapi datasets upload-images, mapi datasets missing-images and mapi datasets upload-weights. The parts are deterministic zip files, and a state file continues an upload.
  • Web app: the upload step Folder of images (labels from folders or from a manifest column, progress, retry and continue), and the collection summary on the version page.
  • mapi_sdk.collection: Collection, the lazy and seeded CollectionLoader, ArrayImageLoader, ImageLoader, Batch, Augment and to_xarray(max_samples, size). mapi_sdk.images decodes images. mapi_sdk.testing.images makes small test collections.
  • Model hooks fit_batches and score_batches (Capabilities.input = "collection"). The harness gives these models a loader of the rows of the split. Other models get a seeded sample of a collection, as set by data.collection (CollectionRead).
  • Model side tables: ModelManifest.side_table_params, FitContext.side_tables and ResolvedModel.side_tables. A score request never needs a table that only the model reads.
  • The upload format safetensors (WeightsOptions): a file of network weights becomes a weights dataset (weights(param) and the attribute mapi_weights). mapi_sdk.safetensors reads and writes safetensors files with numpy only.
  • mapi-jax (packages/sdk-jax, Apache-2.0) on JAX, Flax (nnx) and Optax: the models dl.mlp, dl.cnn_classifier and dl.finetune_classifier, and the step dl.embed (image features from a frozen pretrained network). The shared trainer logs loss and accuracy (or rmse) with the label split, logs prediction images and the confusion matrix as media, stops early, can be cancelled, is deterministic for a seed, and saves checkpoints every N epochs. The models give draws from a last-layer Laplace approximation or from MC dropout.
  • fit.checkpoint.every_epochs, and file checkpoints (ckpt-<key>.weights.safetensors, ckpt-<key>.opt.safetensors, ckpt-<key>.meta.json) with resume.
  • tabular.image_logistic (model version 1.1.0) takes input: features, for example the output of dl.embed.
  • The resource class GPU (one NVIDIA L4, 8 vCPU, 32 GiB) and the runner image base-dl (mapi-jax[cuda12]). The Cloud Run inventory has the Jobs base-dl × {S, M, L, GPU} (Terraform gpu_jobs, default off). The docker executor asks for a GPU when MAPI_DOCKER_GPUS=true. Otherwise, and with the local-process executor, a GPU run uses the CPU and shows the warning GPU_UNAVAILABLE. The price table has a GPU row with an accelerator price.
  • Plots: ml.training_curves, ml.confusion_epochs and ml.misclassified, with About cards. The fit adds them when the bundle has the training history.
  • About cards for dl.mlp, dl.cnn_classifier, dl.finetune_classifier and dl.embed.
  • Documentation: the guide Deep learning, and the section Large image sets: collections of the guide Image data.

Changed

  • Checkpoint.every_draws is optional. A checkpoint has exactly one of every_draws and every_epochs. Documents with every_draws do not change.
  • Capabilities.input accepts collection. DataLayout and DatasetVersion.layout accept collection. IngestJob takes exactly one of mapping, preset, assembly and collection. IngestFormat has safetensors.
  • ResourceClass has GPU as its last member. next_up() walks the CPU classes only, and quotas with a maximum class below GPU refuse it. ResourceClassSpec has accelerator and accelerators. ClassPrice and ResourceUsage have accelerator fields (left out when zero).
  • validate_spec takes dataset_layout and side_table_layouts: a collection takes no pipeline steps for a model that trains on batches, takes only row splits, and cannot be a side table. Validation refuses a spec that selects GPU for a model that does not use a GPU (the GPU Job runs only these models). It warns when a spec sets every_epochs for a model that cannot save checkpoints.
  • An upload whose file arrived has the status uploaded. It stays usable after its PUT URL expires, so a collection upload that takes more than one hour, or that continues the next day, can make its version. The storage quota counts a pending upload until the housekeeper retires it. The housekeeper deletes an arrived upload that no version used after 7 days. get_upload needs update on dataset, because its answer holds the signed PUT URL. When you renew an expired upload, the server does a check of the storage quota again.
  • The size of a collection version is the size of the packs that it uses, also the images in a part that the manifest does not use.
  • The worker environment allows JAX_PLATFORMS, XLA_PYTHON_CLIENT_PREALLOCATE, XLA_PYTHON_CLIENT_MEM_FRACTION and CUDA_VISIBLE_DEVICES.
  • The images mapi/base-bayes and mapi/base-ml have mapi-jax for the CPU, so that dl.embed works in front of their models. The import-linter contracts forbid mapi_jax, jax, flax and optax in the control plane and in the supervisor.
  • Upgrade the CLI and mapi-client to v0.15 to upload collections and weights. Older clients ignore the new fields.

Known gaps

  • Cloud Run GPU jobs and the base-dl CUDA image are not tested against GCP or on a GPU.
  • Deduplication is only against a base version of the same dataset.
  • The platform keeps packs until the tenant is deleted.
  • There are no thumbnails for each image beyond the gallery sample of the ingest.
  • Collections take no pipeline steps (with a model that trains on batches) and no time splits.
  • Workflows and side tables do not take collections.
  • dl.embed on a collection needs data.collection (a sample). Use dl.finetune_classifier with freeze: backbone for all images.
  • One GPU for each run. There is no training on more than one GPU or more than one machine.
  • Pretrained weights must use the mapi-jax ResNet layout. There is no conversion from PyTorch checkpoints.

[0.14.0] — Run comparison, media and image data — 2026-10-20

Run comparison, media, image data and classification

You can now compare runs and group them. The runs table shows metrics, params and tags as columns, and you can filter, sort and save the search as a view. The compare page shows 2 to 10 runs side by side. An experiment is a named group of runs. A run can also log images, figures and small tables. You can upload a zip of small images as a dataset, look at the images in a gallery, and classify them. The classification pack adds a multiclass classifier, LightGBM and CatBoost, new GLM families and multiclass plots.

Added

  • Experiments: a named group of runs in a project (GET/POST /v1/experiments, GET/PATCH/DELETE /v1/experiments/{experiment_id}, POST /v1/experiments/{experiment_id}/runs). A run is in a maximum of one experiment (RunCreate.experiment_id, Run.experiment_id, RunPatch.experiment_id, WorkflowRunCreate.experiment_id). A workflow node run gets the experiment of its workflow run. A retry gets the experiment of the run that it retries. An archived experiment accepts no new runs and no retries. list_runs has the filter experiment_id.
  • Saved views of the runs table (/v1/run-views): a name and a RunViewQuery (the filters, the sort order, the columns, the tags and the other filters of list_runs), shared in the project. A maximum of 200 views in a project.
  • GET /v1/run-compare: the summaries, the metric histories, the params (with differs) and the media of 2 to 10 runs. GET /v1/run-keys: the metric series, param keys and tag keys of the runs of a project or an experiment, with the number of runs that have each.
  • Run media: ctx.log_image, ctx.log_figure and ctx.log_table on the fit, score and node contexts. The supervisor uploads the files through a create-only POST policy on the media/ prefix of the run (RunSpec.media_prefix, output media) and sends a media event. The server downloads each file, does a check of its SHA-256, size and format, and keeps a copy in the bundles bucket. A run can log 1000 items and 256 MiB. GET /v1/runs/{run_id}/media, /media/{media_id}, /media-keys, and POST /v1/runs/{run_id}/media (from a notebook, a maximum of 4 MiB). The tracking frame has the field media.
  • mapi_spec.media: the media kinds and content types, sniff_image (PNG and JPEG headers, no image library), MediaTable, media_name, check_media and the limits. mapi_sdk.media: MediaItem, encode_png (numpy and zlib only) and the optional MediaTracker protocol.
  • Web app: the runs table has a filter bar, a column picker for tracked columns, sort by a column, saved views, and check boxes to compare runs or to add them to an experiment. New pages: Experiments, the page of an experiment, and the compare page (curves, metric bars, param differences, plots side by side, and images with a step slider). The run page has a Media tab. The Notes tab moves the run to an experiment.
  • Client and CLI: runs.media, runs.media_keys, runs.get_media, runs.download_media, runs.log_image, runs.log_table, runs.keys, runs.compare, client.experiments and client.run_views. mapi experiments ..., mapi runs media, mapi runs log-image, mapi runs compare, mapi runs views ..., mapi runs ls --experiment/--view, mapi fit --experiment and mapi workflows run --experiment.
  • Image data, stage 1: the upload format image_folder (a zip of PNG, JPEG or TIFF images and a manifest table, ImageFolderOptions). The ingest writes pixels(image, y, x, channel) with the new dtype uint8, the target label(image) and the manifest columns as coordinates. It can resize the images (stretch, crop or pad). The ingest refuses unsafe paths, images of more than 4096 × 4096 pixels, images with more than 8 bits, and datasets that are too large, with a message that gives the number of images that fit.
  • Data Explorer: the Gallery section (thumbnails of the images, an even sample for each label and split, and the counts of each facet), and colour (RGB) images in Images and fields. A label variable on the frames gives the classes of the mean images.
  • The split kinds column (hold out the rows with a value of a column, for example split = test) and stratified (hold out the same share of each class, at random), with About cards.
  • Classification: sklearn.multiclass_classifier (the probability of each class in y_hat(..., class)), the estimators sklearn.HistGradientBoostingClassifier, lightgbm.LGBMClassifier and catboost.CatBoostClassifier (the extra mapi-sklearn[gbm]), and the metric auc for multiclass predictions (the mean one-vs-rest AUC).
  • tabular.hierarchical_glm (model version 1.1.0) has the families negative_binomial, zero_inflated_poisson, zero_inflated_negative_binomial, beta, gamma and ordinal.
  • tabular.image_logistic: a Bayesian multinomial logistic regression on the main patterns (PCA) of small images. The fit logs the patterns and the class means as images.
  • Help me choose has the goal "Put items or images into classes" (classify) and the data shape "Images (pictures of the same size)" (images).
  • Documentation: the guides Compare runs and use experiments and Image data.

Changed

  • core.roc_pr and core.confusion accept a binary or a multiclass target. With a class dim on the prediction, or more than two observed classes, core.roc_pr draws one-vs-rest curves for each class with the macro AUC (classes selects the classes), and core.confusion shows a K × K matrix with the accuracy and the macro F1. The binary behaviour does not change.
  • sklearn.tabular_classifier accepts the new classifier estimators.
  • verify_output_objects accepts objects below media/ of the run prefix by default.
  • mapi-runner depends on Pillow. The runner image mapi/base-ml has LightGBM, CatBoost and libgomp1. The other runner images have Pillow, and do not have LightGBM or CatBoost. The mapi/server image has none of them.
  • New audit actions: experiment.create, experiment.update, experiment.delete, run_view.save and run_view.delete. A media write through the API is a run.log event.
  • Upgrade the CLI and mapi-client to v0.14 to use experiments, saved views, comparison and media. Older clients ignore the new fields.

Removed

  • mapi_spec.expand_sweep and the module mapi_spec.sweep. Nothing called them.
  • New Sweep documents: the server refuses them (422 SCHEMA_INVALID at /kind). Stored Sweep documents still load. Use a workflow with a search.grid node.

Known gaps

  • A fit without a run (for example fit_spec in a notebook) keeps no logged media. tracking_media is False, and log_image, log_figure and log_table only check their arguments. To keep the items in memory, give the fit a recorder: FitHooks(tracker=MetricRecorder()). The items are then in MetricRecorder.media.
  • Workflow schedules and triggers start their runs in no experiment. To group such a run, move it with PATCH /v1/runs/{run_id} before it starts its nodes. Node runs that started before the move stay where they are.
  • The platform keeps media until you delete the tenant. There is no retention setting for media.
  • image_folder has no inspect step. Errors about the columns of the manifest come from the ingest run.
  • Images must have 8 bits for each channel and one size for each dataset. A dataset has a maximum of 268 435 456 pixel values. The collection layout of v0.15 removes these limits.
  • Workflow backtests do not make stratified split plans.
  • There is no calibration plot for multiclass predictions.
  • LightGBM and CatBoost are only in the mapi/base-ml runner image.
  • Validation refuses log_score for sklearn.multiclass_classifier, as for the binary classifier, because the plugin gives point predictions. Use brier.

[0.13.0] — Causal models and Bayesian state space — 2026-10-20

Causal inference and Bayesian state space

The platform can now measure the effect of a campaign, a launch or a test. Three new causal models compare the actual values with a prediction of the values without the change: on one series (causal.impact), in test markets against a mix of the other markets (causal.synthetic_control) and in units with and without a change (causal.did). The result of a geo-lift test becomes a lift test for mmm.bayesian. A new Bayesian structural time series (ts.bsts) forecasts one series from its level, trend, seasonal patterns and drivers. Each fit logs its results with the v0.11 tracking API.

Added

  • ts.bsts (mapi-plugin-ts): a Bayesian structural time series on the state-space models of pymc-extras. It has a level, a trend (level, local_linear or smooth), a maximum of three seasonal patterns (one effect for each season, or harmonics) and drivers with one effect for all periods. The fit uses the exact Kalman likelihood. Missing periods are permitted. The bundle keeps a terminal_state group. forecast starts from it, and the filter mode adds new periods without a new fit. The components group shows the level, the seasonal parts and the drivers with core.decomposition.
  • A new plugin package, mapi-plugin-causal (Apache-2.0), with three models. Each model writes the effect to the bundle group impact (actual values, values without the change, the effect in each period and the cumulative effect, in the units of the target):
    • causal.impact: CausalImpact (Brodersen et al. 2015) on the ts.bsts core. The model fits the periods before the change, with optional control series. Without control series, it is an interrupted time series.
    • causal.synthetic_control: a Bayesian synthetic control for geo-lift tests. The weights of the other markets are positive with a sum of 1 (simplex), or have a horseshoe prior. The bundle group donor_weights keeps the weights.
    • causal.did: difference-in-differences and event study (Bayesian two-way fixed effects). The bundle group event_study keeps one coefficient for each period before and after the change. The counterfactual mode sets the treatment to 0.
  • New plot recipes, each with an About card and example data: causal.impact (the actual values against the values without the change, the effect in each period and the cumulative effect), causal.pre_fit (the fit before the change), causal.donor_weights and causal.event_study.
  • The workflow node model.lift_test makes a lift test for mmm.bayesian from the impact group of a causal model version. model.fit has a new parameter calibration.
  • The workflow template geo_lift_mmm: a geo-lift test, its lift test, mmm.bayesian calibrated with the lift test, the plots and a report.
  • Guided templates campaign_impact (Effect of a campaign, causal.impact) and geo_lift (Geo-lift test, causal.synthetic_control), with About cards. The report of geo_lift has a card that makes the lift-test entry for your marketing mix.
  • Help me choose has a new goal: "Measure the effect of a campaign, a launch or a test" (measure_impact). The four new models have About cards.
  • Run tracking: ts.bsts logs its structure (bsts.* params), the noise and the error of the one-step-ahead predictions. The causal models log the total effect with its interval, the relative effect, the probability that the effect is more than zero and the error before the change. Synthetic control also logs the donor weights. DiD also logs the average effect and the event-study coefficients.
  • Conformance fixtures e13_bsts (the plan fixture E13 is no longer deferred), causal_impact, geo_lift and did_event_study.
  • Documentation: the pages Causal models and Bayesian structural time series and Measure the effect of a change.

Changed

  • pymc-extras is now a declared dependency: of mapi-plugin-ts, and of the new extra mapi-pymc[pathfinder] for the pathfinder engine. The error message for a missing engine names the extra. Pathfinder does not write a progress table to the output.
  • The runner image mapi/base-forecast has pymc-extras and mapi-plugin-causal. The causal.* models run in it. mapi/base-bayes has pymc-extras for Pathfinder.
  • GuidedTemplate.family has the new value causal. Upgrade the CLI and mapi-client to v0.13. A v0.12 client cannot read the list of Guided templates from a v0.13 server (mapi guided templates fails for all templates). From v0.13, a client keeps a family that it does not know as text.
  • The causal.* plot recipes are platform recipes: you can embed them, and the Guided share step offers them. Only the platform distributions can register ids in the causal and choice namespaces.
  • A score output can be a difference (the scored array attr difference_of names the reference output). With output.units: original, the harness changes the level and the reference to the output units, then subtracts them. The effect of causal.impact uses this, thus it is y - y_hat in the units of the data also when the pipeline transforms the target.
  • The Guided steps of campaign_impact and geo_lift do not include the scenarios step (the templates have no tasks). The lift-test card of the geo_lift report uses the test markets of the revision that the last fit used.
  • causal.did scores a payload with only some periods: the periods that the payload does not have take the adoption of the fit. causal.impact refuses periods that are not evenly spaced (SCHEMA_INVALID), as ts.bsts does. An impossible date in intervention_start or intervention_end (for example 2024-02-30) is SETTINGS_INVALID. Two units or donors whose names give the same tracking label get different labels (_2, _3).

Known gaps

  • ts.bsts takes one series. Each driver has one effect for all periods. A forecast does not use actual values that you know (use the filter mode). A seasonal pattern with one effect for each season has a maximum period of 24 (use harmonics for longer periods).
  • causal.impact fits the periods before the change only.
  • causal.synthetic_control has no placebo tests and does not design a test (the choice of the markets, the power).
  • A lift test from two or more test markets is national.
  • The time effects of causal.did are fixed, thus the model does not forecast. When the units got the change at different times, the estimates can be wrong.
  • The lift-test card of the Guided report does not write into the MMM spec. Copy the entry.

[0.12.0] — MMM pack — 2026-10-20

MMM pack

The marketing-mix models get two new models, two new kinds of data and the charts that a marketing team asks for. mmm.nested measures effects that go through an intermediate measure, for example visits or awareness. mmm.multivariate measures effects on two or more results at the same time. mmm.bayesian can now have channel effects that change over time, and counts or shares as the result. Each MMM fit logs its return on spend and other summaries with the v0.11 tracking API.

Added

  • mmm.nested (mmm-framework NestedMMM): a national marketing mix with one or more intermediate measures (mediator dim). Each measure can be measured in some periods only, or not at all (latent: true). Each channel has a direct effect and an indirect effect through each measure. The new derived quantity mediation gives the direct, indirect and total effect of each channel and the part that goes through the measures. Lift tests calibrate the total effect of a channel. The output mediator_hat gives the fitted measures.
  • mmm.multivariate (mmm-framework MultivariateMMM): a national marketing mix of two or more results (outcome dim) with correlated noise. Cross-effects between the results can be halo, cannibalization or unconstrained. Scores, response curves and budget plans use the reduced form, thus a channel that moves one result also moves the results that it acts on. The new derived quantity cross_effects gives each cross-effect. outcomes[].weight gives the value of one unit of each result in the budget plan. The KPI of the plan is then "weighted total of the outcomes", and a note of the plan gives the weights.
  • mmm.bayesian 1.1.0: time-varying channel effects. Put the channels in time_varying.channels. Their effect is a random walk over the fit window. time_varying.forecast sets the effect after the fit window: random_walk (default, the range becomes wider with the horizon), hold_last or mean. A forecast tells you which policy it used.
  • mmm.bayesian 1.1.0: the likelihoods poisson and negative_binomial (counts, log link) and beta (shares between 0 and 1, logit link). media_effect_sigma sets the prior of the channel effects of these likelihoods. The contributions of the channels still add up to y_hat. The trend, the seasonality and the controls are then part of the baseline.
  • New derived quantities for every MMM: adstock (the part of the effect of a unit of spend that comes in each later period, and the mean lag). mmm.bayesian and mmm.frequentist also have effectiveness (the effect for each unit of spend in each period).
  • New plot recipes, each with an About card and example data:
    • mmm.roi: the return on spend of each channel with a range, and the marginal return (mROAS), against a break-even line.
    • mmm.adstock: how the effect of spend decays over the next periods, with a range.
    • mmm.waterfall: the contribution of each component to the total result.
    • mmm.spend_effect: the share of spend against the share of the effect of each channel.
    • mmm.lift_tests: the result of each lift test, with its error bar, next to the estimate of the model.
    • mmm.effectiveness: the effect of a unit of spend over time, for time-varying channels.
    • mmm.mediation: the direct and indirect effect of each channel (mmm.nested).
    • mmm.cross_effects: the effects between the results (mmm.multivariate).
    • core.energy: the energy plot of the sampler and the BFMI of each chain.
    • core.prior_posterior: the prior and the posterior of each parameter, with their overlap.
  • A fit with lift tests keeps the bundle group lift_tests (each test and the estimate of the model for each draw).
  • Run tracking for every MMM: each fit logs the params mmm.framework_version, mmm.n_channels, mmm.n_periods, mmm.n_cells and mmm.link, and the metrics roi, roi_p05, roi_p95, mroi and contribution_share for each channel (label channel) and baseline_share. mmm.nested and mmm.multivariate also send the progress of each chain and the step metrics, as mmm.bayesian does.
  • Guided templates mediated_mmm (Marketing mix with an intermediate measure, mmm.nested) and multi_kpi_mmm (Marketing mix for related products, mmm.multivariate), with About cards. Help me choose recommends the two models and templates for "a few related numbers over time".

Changed

  • geo_mmm 2.1.0: the template also keeps adstock and draws mmm.roi.
  • The About cards of mmm.nested and mmm.multivariate now tell what the models can do: national data, no control variables, and cross-effects that are associations. The card of mmm.bayesian tells about time-varying effects, counts and shares.
  • Guided drafts: a question that allows missing values also suggests columns that are mostly empty (for example a monthly survey). The labels of a wide covariate that has its own dim keep their words (site_visits, not site).
  • The Guided report and the shared report show mmm.roi first when the spec draws it.

Fixed

  • Score plots of mmm.decomposition and mmm.waterfall show one outcome when a score renames the outcome dim to its dataset name. Before, they added all outcomes together.
  • The Guided what-if and the scenario presets of the several-results template use the spend question, not the results question.
  • The mediated template does not ask for channel beliefs, because mmm.nested takes lift tests only.
  • The data-shape hint of the national templates names the mediator or result columns.
  • The mmm.multivariate starter spec does not ask for the cross-effects plot when it sets no cross-effects.
  • A budget plan for one weighted outcome names that outcome and its weight.
  • A very long mmm.* parameter value is cut to 6000 characters, not refused after sampling.

Known gaps

  • mmm.nested and mmm.multivariate use national data only and take no control variables.
  • mmm.multivariate has no lift-test calibration and no promotion modulation.
  • The Guided template multi_kpi_mmm sets no weights and no cross-effects. Add them in the spec editor.
  • The likelihoods binomial, beta-binomial and lognormal are not available.
  • A budget plan of a count model (log link) adds the increments of the channels. The removal-based response curves are the correct view of each channel.
  • The approximate fits of mmm.nested and mmm.multivariate do not send their loss while they fit.

[0.11.0] — Run tracking — 2026-10-20

Run tracking: metrics, parameters, tags and notes

Each run now keeps a log of what happened in it: metrics (curves and single values), params, tags, a name, a note and structured log lines. Model code, workflow nodes and you (from a notebook, a script or the command line) can add to this log. You can search runs by their metrics, params and tags. The data that a run comparison needs is stored. The compare page comes in a later release.

Added

  • A logging API for model code. FitContext, ScoreContext and the workflow NodeContext have log_metric, log_metrics, log_param, log_params, set_tag, set_tags, log_line and chains. The methods do a check of each key and label when you call them, also without a run. They are thread-safe. The new guide Track metrics, params and tags tells how to use them.
  • Metric keys keep the pattern ^[a-z][a-z0-9_]{0,63}$. Labels (a maximum of 8) give the qualifiers, for example loss with {"split": "val"} or step_size with {"chain": "0"}.
  • mapi_spec.tracking: the key and label rules, TrackingLimits, labels_key, param_text and the run search grammar (parse_run_filter, parse_run_order, parse_run_ref).
  • mapi_sdk.tracking: the Tracker protocol, MetricRecorder, TeeTracker, with_labels and spec_params. A fit writes the logged values into the new bundle group /model/metrics_history, and core.loss_curve draws them.
  • Each fit logs the params of its spec: model, fit.*, data.split and settings.*. scikit-learn fits also log the hyperparameters of the estimator as estimator.*.
  • Live chains: PyMC fits (nutpie and the PyMC NUTS sampler) and mmm.bayesian send the progress of each chain. The run page shows one lane for each chain, with its draws, divergences and step size.
  • Step metrics: nutpie logs step_size, num_steps and divergences. The PyMC NUTS sampler (also in mmm.bayesian with nuts_sampler: pymc) also logs tree_depth. For a chain of more than 5000 draws, it logs each k-th draw. These metrics have the label chain. ADVI sends its loss while it optimises. The evaluation fit of evaluate_then_refit adds the label fit=evaluation and the prefix evaluation. to the keys of its params.
  • search.grid logs each grid point in its search run (the first of the child runs of the node): the params trial.<i>.<pointer> and grid.*, the scores with the point number as the step, best_<metric> and the tag grid.best_point. model.backtest logs the metrics of each fold with the label fold.
  • New endpoints: PATCH /v1/runs/{run_id} (name, note and tags), GET and POST /v1/runs/{run_id}/metrics, GET /v1/runs/{run_id}/metrics/{key} (downsampled with LTTB), GET and POST /v1/runs/{run_id}/params. You can log metrics and params to a run after it finished, for example a business KPI after a deployment.
  • GET /v1/runs search: parent_run_id, name, tag, filter (for example metric.rmse < 0.5 or param.fit.engine = nutpie), order_by and column (Run.columns).
  • The SSE stream of a run sends tracking frames when the metrics, the params or the tags change, and a snapshot frame when the name or the note changes.
  • GET /v1/runs/{run_id}/logs sends the structured user lines only with stream=user or include_user=true, thus older clients continue to read the logs. The v0.11 client, the CLI and the web app send include_user=true.
  • The token scope runs:write and the permission update on run (runner role). The audit actions run.update and run.log.
  • Python client: runs.update, runs.log_metric, runs.log_metrics, runs.log_params, runs.set_tags, runs.track (a context manager that sends points in batches), runs.metrics, runs.metric_history, runs.params, and the search arguments of runs.list.
  • CLI: mapi runs metrics, params, log-metric, log-param, tag, rename and note. mapi runs ls has --filter, --order-by, --column, --tag, --name and --parent. mapi runs watch shows metrics, params, tags and structured log lines.
  • Web app, run page: the tabs Overview, Metrics (a chart for each key, colour by a label, label filters, log scale, live updates), Params, Logs (level filter) and Notes (name, tags and note). The runs list shows the name and the tags of each run.
  • Budgets for each run (RunLimits.tracking): 10 000 points for each series, 500 000 points, 1000 series, 5000 params, 100 tags and 10 000 structured log lines. A run that logs more gets one TRACKING_BUDGET warning. More than 64 MiB of tracking data is a SANDBOX_VIOLATION.
  • The setting MAPI_TRACKING_RETENTION_DAYS. The server deletes the metric points and the structured log lines of old runs. It keeps the summaries, the value of each metric without a step, the params, the tags and the notes. Points that you log later add to the kept summary.
  • Model Explorer: the backtest has the error for each horizon, pooled over the folds (BacktestView.by_horizon), and the chart "Backtest error by horizon". A plan without horizons uses the horizons 1 to the length of the first test window (a maximum of 32).

Changed

  • Run events: the new types metrics, params and tags. The platform writes them to the tracking tables, not to the event list. The log event has the new stream user, with level and fields. ChainProgress has step_size and num_steps.
  • The legacy metric event (for example the holdout rmse at the end of a fit) also becomes a scalar metric series.
  • GET /v1/runs returns 422 SCHEMA_INVALID for a filter, order_by or column that is not correct. GET /v1/runs/{run_id}/logs has the filter level. Run has name, note and tags. LogLine has level and fields.
  • mmm.bayesian reports the progress of the sampler from 10% to 90%, and you can cancel it while it samples. A cancelled fit makes no model version.

Known gaps

  • The page that compares runs comes in v0.14. v0.11 stores the data and gives the summaries and the search filters in the API.
  • Runs cannot log images or other media.
  • numpyro and blackjax samplers do not send the progress of each chain.
  • The approximate methods of mmm-framework (ADVI, Pathfinder) do not send their loss while they fit.

[0.10.0] — Chart wiring and score plots by output shape — 2026-10-20

Chart wiring and metric correctness

More models now get the plots that apply to them, and the metrics that a spec requests are calculated. A classifier gets classifier plots and metrics. A state model gets a state plot. An ADVI fit shows its loss. The run page shows the plots of a run.

Added

  • New plot recipes in the core pack, each with an About card and example data:
    • core.roc_pr: the ROC curve and the precision-recall curve of a 0/1 target, with the AUC and the average precision.
    • core.confusion: the 2 × 2 table of actual and predicted classes at a threshold (0.5, or auto), with accuracy, precision, recall, specificity and F1.
    • core.loss_curve: the loss of each step of a fit, raw and smoothed, with a note when the loss still goes down at the end.
    • core.loo_pit: the LOO-PIT check of calibration and the PSIS Pareto k of each observation. It is not a default plot. Add it in the plot builder.
  • Metrics: auc (the Mann-Whitney AUC) and binary brier, log_score and accuracy for a target with two values. mase for a fit with a time holdout: the holdout mae divided by the in-sample error of a seasonal naive forecast.
  • metrics.json keeps the PSIS Pareto k of each observation of elpd_loo (ElpdDetail.pareto_k).
  • An ADVI fit keeps the loss of each step in the bundle group fit_history (loss(step), at most 5,000 steps). The fit run adds core.loss_curve to its plots.
  • Standard plots for more models. The model declares them, and the Insert panel of the spec editor shows them first. A fit draws only the plots in outputs.plots:
    • sklearn regressors (and the quantile, bootstrap and conformal plugins): core.fit_vs_actual, core.residuals_over_time, ml.feature_importance.
    • sklearn.tabular_classifier: core.calibration, core.roc_pr, core.confusion, ml.feature_importance.
    • ts.gaussian_hmm and the statsmodels Markov switching model: core.state_sequence.
    • The unobserved components model, ETS and ts.hsgp_trend: core.decomposition. ETS and ts.hsgp_trend keep a new components group for it.
  • Default score plots: an areal output gets sci.area_map, an output of probabilities over a state or regime dim gets core.state_sequence, and an evaluate run with a 0/1 target gets core.calibration, core.roc_pr and core.confusion.
  • ScoreSummaryTable.plots: the ids of the rendered plots that draw the output.
  • Score, Results by output: a map of areas and a classification view, made from the plots of the run.
  • Run detail: a Plots card with the plots of a finished run.
  • Compare in the Model Explorer: the card Which version predicts new data best? with the core.model_comparison plot, when two or more versions have elpd_loo (or elpd_waic).
  • Score, quick chart: a Cell picker for a table with more dims than the axis and the colour, for example a grid. The chart shows one cell at a time.

Changed

  • core.calibration accepts one predicted probability for each row (no draws) for a 0/1 target. Validation now accepts it for a model with point predictions.
  • core.state_sequence reads the hidden_states group (HMM), the regimes group (Markov switching) and a score summary. It finds the state dim (state or regime) itself. When the data has more than one series, it shows the first one and names it in the caption.
  • core.decomposition reads the components group. It does not stack a variable with the attr decomposition: exclude (the trend slope of the UC and ETS models).
  • GET /v1/runs/{run_id}/plots returns the plots of the newest attempt that has them. Thus partial runs, and failed or cancelled runs with an earlier partial attempt, also show plots.
  • One implementation of the MASE scale (mapi_sdk.metrics.mase_scale) for the SDK, the workflows, the explorer and governance.

Fixed

  • The fit calculated no metric for a classifier: metrics.json was empty. The harness did not pair a probability output with its target, because the output declares units. It now pairs an output with meaning: probability with the target.
  • An evaluate run from the Score page did not draw its plots: the runner skipped core.fit_vs_actual because the result had no observed values. The runner now gives the plots the full predictions and the observed values.
  • auc and mase passed validation but the fit did not calculate them.
  • Compare in the Model Explorer showed no holdout metrics of the versions (only the number of parameters). The explore run did not get the metrics.json of each version. The server now gives it to the run with each bundle.
  • Score: the quick chart of a large grid table (for example 864 rows of the LGCP summary) looked blank. The chart drew one line through all the rows, so all the cells of a period were at the same x position: each period was a vertical stroke and the band had no width. Empty cells (outside the study area) also broke the line between the periods. The chart now draws one cell of the grid at a time.

Known gaps

  • Validation refuses log_score in evaluate.metrics for a model with point predictions, so a point classifier cannot request it. Use brier.
  • mase is calculated only in the evaluation of a fit. An evaluate run from the Score page skips it, because it does not have the training data.
  • A regression target that has only the values 0 and 1 gets the classifier plots on evaluate.
  • The sci.* models that run the PyMC engine themselves do not keep the ADVI loss history. PyMCModel plugins and ts.gaussian_hmm keep it.
  • sci.area_map on the Score page draws an output with only an area dim. An areal output with more dims is skipped with a warning.

Score results by output shape

The plots of a score run now follow the shape of each output. A gridded output, for example the intensity of the earthquake model sci.lgcp_grid, shows as a map with a slider over the periods of the forecast, not as a forecast fan of the sum over the grid.

Added

  • The runner picks the default score plots from the shape of the outputs (predict, forecast and evaluate). A grid output gets sci.field_map, or sci.vector_field when it has a component of size 2, with the frame dim (quarter) as the slider. A time series output that is not a grid (total[period]) gets core.ppc_fan. A model with grid outputs and no such time series gets no fan. A model with no grid outputs keeps the plots of before, and job.plots still replaces the defaults.
  • A predict, forecast or evaluate run of a model with grid outputs writes ui/fields.json (FieldsView, mapi_spec.views.SCORE_FIELDS_VIEW): the same field view as the Model Explorer, made from the predictions.
  • The output variables of a grid in score.zarr.zip carry the attr grid (the GridDecl as JSON, with the dataset names of the dims). Thus a download can be plotted as a map.
  • ScoreSummaryTable has two optional fields: dims (the coordinate columns) and time_dim (the dim that is time). A server that predates them leaves both out.
  • Score in the web app shows a view for each output by its shape: a map with a frame slider for a grid, a line with a band for a time series, intervals for a categorical output and tiles for a scalar. When ui/fields.json is there, the map replaces the sci.field_map and sci.vector_field plots in the plot list.

Changed

  • The KPI of a guided forecast (ui/guided/forecast.json) is an output that is not a grid when there is one, for example total and not the sum of rate over the grid.
  • The summary tables are in the order of their size, so a truncated summary drops a large grid table and keeps total.
  • A model's field map (Score page and Model Explorer) colours the 1% to 99% range of the posterior means when the quantiles and draws reach much wider, so a log-scale intensity does not look flat; the mean, quantiles and draws still share the scale, and "Colours for the full range" shows all of it.

Data Explorer for scientific data

The Data Explorer now shows graphs of scientific data, not only time series. It finds the structure of the data (images, profiles, maps, networks, trees, directions and points) and shows the sections that apply first.

Added

  • The explorer finds the shapes of each dataset, and of each node of a tree dataset, from the dims, the coords, the roles and the units. The new section Structure shows the shapes, the role of each dim and how the explorer uses each variable.
  • Seven picture sections in the new group Pictures of the data:
    • Images and fields: the field viewer of the Model Explorer, with the statistics of each frame.
    • Profiles and spectra.
    • Map: areas in colour, neighbours and Moran's I.
    • Network: matrix, node-link graph, degree and homophily.
    • Tree: the tree with the traits and the phylogenetic signal.
    • Directions and times of day: roses and the Rayleigh test.
    • Points: lattice, CIE Lab colours and agreement.
  • The ingest writes structure and each picture section that applies. The explorer index gives the new fields reason, shapes and order. A request takes the new field hints to correct the structure.
  • In Expert mode, All series also shows the Plotly plots sci.area_map, net.graph and sci.rose.
  • Mock datasets for eleven pymc-challenges cases and the E80 lens tree, and screenshots of the new sections. make docs-screenshots-explorers records the profiles, the points and the section list from mock mode. make docs-screenshots-science records the images (E58), the map (E18), the network (E21), the tree (E23) and the roses (E81b) from the real exports.
  • Images and fields: when a few cells are much brighter than the others (E58 camera frames), the colours cover 98% of the values. Colours for the full range shows all the values. The axis of a histogram with a log count has ticks at the powers of ten.
  • Tree: a trait with positive values that go across two orders of magnitude or more (E23 body mass) has a logarithmic axis.
  • Each pymc-challenges tutorial has a step "Explore the data". The guide Explore a dataset (docs/guides/data-explorer.md) has a new part "Scientific data".

Changed

  • The list of sections shows only the sections that apply to the data, in the sequence that agrees with the structure. The other sections go in the closed list Not for this data. A link to one of these sections continues to open it. Versions from earlier releases keep the thirteen sections.
  • A dim with policy: extend is time only when its coords are numbers that increase and it is not a list of ids. A week, month, quarter, year, day, date, period or time dim with numbers stays time (an MMM panel on the weeks 1 to 104). A list of ids is a sequence when its name tells a step in time (step, t) or media or spend change along it. If your data uses another list of ids as time, give time_dim in the request.
  • Over time adds or averages the cells of an image to make one series. It does not make a series for each cell. The time patterns need a real time dim and 16 periods or more.
  • Group comparison compares only real groups. Correlations, Distributions and Unusual values do not use geometry (borders, neighbours, tree variables, positions). Data quality gives SHORT_HISTORY only for data with a time dim, and has the new warnings GEOMETRY_INVALID, TREE_INVALID, NETWORK_ASYMMETRIC, ANGLE_RANGE and IMAGE_SATURATED.
  • A request can have all 21 sections (before: 13).
  • Map starts with the ratio of the target and the exposure (E18 cases / expected), when the data has this ratio.
  • Unusual values and the OUTLIERS check do not use an indicator (the values 0 and 1, or two values that each occur in 5 % of the rows or more), or a profile or a spectrum (E79 reference_dl). One spike in a constant series continues to be an unusual value.
  • Distributions says "has only the values 0 and 1" for a variable with only 0 and 1. Correlations names the largest pair when no pair has a correlation of 0.7 or more.
  • The tables of the picture sections show counts with all their digits (10,292, not 10,290).
  • The dataset About counts a pair of dims (district, district_) once, and does not add an "s" to a dim name that ends in "s" ("186 species", not "186 speciess"). A name that ends in "ss", "us", "x", "ch" or "sh" gets "es" ("3 classes", "3 statuses").

Fixed

  • The upload page puts the bytes of a file once. Before, Upload and ingest while Preview structure still sent the file failed with "upload failed with HTTP 412".
  • Time-series and tabular data keep the classic sections:
    • A target named y, or a pair of columns such as sales_x and sales_y, is data, not the positions of points. Points need names of positions and, for x and y, integers, units of length or a dim of points (site).
    • An MMM panel with integer geo or channel codes is not an image. Over time gives one series for each geo and channel.
    • hours_open, months_active, a temperature in degrees, and a latitude or a longitude are not directions.
  • Images and fields reads a few frames at a time. A movie of 1,000 frames of 256 × 256 cells needs about 250 MB more memory, not 1.8 GB. The ingest leaves out the images of more than 50,000,000 cells: Run the deeper checks calculates them.
  • IMAGE_SATURATED looks only for a pile-up at the limit of a detector. A heater setting (E51 heater_w) and a noise map (E79 noise_sd) do not get it.
  • Moran's I of a map variable with frames uses the mean over the frames, not the first frame.
  • The server does not reuse an explore run from before this release: the cache key of a request includes the version of the explorer.
  • Group comparison does not compare the layers of a network (E21 covariate).
  • The roses show the bin in the units of the data when the pointer is on a bar ("13–14 h", not "203°"). The plain summary of the roses does not count the rose of all the records as a group.
  • The export plots of a section have different ids (three roses in E81).
  • "has only the values 0 and 1" shows only for a variable with the values 0 and 1, not for any two values between 0 and 1.
  • The counts of frames, ties and dim sizes show all their digits. For a version from an earlier release, the header names the views for scientific data apart from the list.
  • In Network, the Colour by picker stays when a network has no matrix.

This release updates the documentation for v0.9.0. It has no code changes.

Changed

  • New page "What's new in 0.9", linked from the home page and the nav.
  • The README, home page, why, use cases and getting started pages describe the science models, the new plots, the new workflow nodes and recipes, and the pymc-challenges tutorials. The counts of models, plots, recipes and nodes agree with the code.
  • The roadmap covers v0.5.1 to v0.9. Plugin upload and the MCP server are "planned"; they are no longer said to come in v0.6. Old M4 to M6 labels for planned work are removed.
  • Concepts, guides and reference pages describe circular and grid outputs, custom samplers, start values, export cases, the new SDK and spec fields, the Score page options, the scorecard missing-values rule and the recovery.simulated check.
  • The conformance guide lists all 26 fixtures. Each science model section in the model reference links to its tutorial.
  • Operations pages list the new environment variables and make docs-screenshots-science. The package READMEs describe the new recipes, nodes and fields.

This release adds eleven science models from the pymc-challenges case studies, for images, fields, maps and graphs (15 sci.* models in all), and 14 plot recipes for them: maps, networks, trees, polar charts, image checks, colour swatches and posterior views. New workflow nodes give posterior summaries, posterior predictive checks, stacking weights, score modes and plots inside a workflow. Six workflow recipes use them for model ladders, a convergence-gated Bayesian audit and calibrate-then-design. Each ported case has a tutorial that does the notebook analysis in the web app, and python -m mapi_plugin_science.export turns the pymc-challenges data into datasets to upload.

M5 phase 3 — Workflow recipes and tutorials for science models

Added

  • sci_spatial_ladder in the workflow gallery. It fits sci.areal_bym2 with iid, ICAR and BYM2 area effects and compares them by elpd_loo and stacking weights. It keeps the best model and gives the range of the covariate effect. When the chains of the best model agree and the effect is clear, it fits the best model again with wider and narrower priors. It draws a risk map and writes a report.
  • sci_network_ladder in the workflow gallery. It fits sci.network_latent_space with covariates, sociality and a latent space and compares them by elpd_loo and stacking weights. It gives the pair effects of the best model, a network plot, simulated networks with their statistics and a report.
  • Two sections in the workflow cookbook, with the documents in docs/snippets/cookbook_sci_{spatial,network}_ladder.yaml. A test makes sure that each document is the same as its gallery template, and slow tests run the two documents.
  • Recipe sci_phylo_signal in the workflow gallery. It fits sci.phylo_regression with four residual kinds (no tree, Brownian motion, Pagel's lambda, Ornstein-Uhlenbeck), ranks them by elpd_loo, gives the stacking weights and selects the best. Then it tests if the range of one slope of the winner excludes zero. A clear effect also gets a predictive check. The report has the three tables and the sci.phylogeny plot. On the 186 primates of the primates301 dataset (E23), lambda wins with an elpd_loo of 129.9 (the notebook gives 130).
  • Recipe sci_calibrate_then_design in the workflow gallery. It fits sci.heat_plate with one calibration run held out, then fits all the runs again. It predicts the field of a new heater layout (model.score, sci.field_map) only if the error on the run held out and R-hat are in their limits. The report has the parameters, the holdout metrics and the sci.image_residuals and sci.field_map plots.
  • Both recipes have a section in the workflow cookbook with the CLI and the Python client. Their cookbook documents are tested against the gallery templates and run with the local runner on the simulated data of the models.
  • model.summary has a new parameter focus and a result field focus. A condition cannot build an entry name from a parameter, so focus names one value (beta[north], or only north). The result gives its mean, sd, HDI bounds, p_positive and excludes_zero.
  • Two gallery templates with About cards, cookbook sections (docs/snippets/cookbook_<id>.yaml) and run tests with the local runner:
  • bayes_workflow_audit: the checks of a careful Bayesian workflow for each model that gives draws (the model parameter). The fit keeps prior draws for the prior plot. A gate on R-hat and the effective sample size controls the posterior predictive check, the prior sensitivity check and the recovery check. The report has the prior, pair, predictive and estimate plots.
  • sci_image_model_check: residual maps and a grid of predicted images next to the observed image for an image model (for example sci.strong_lens or sci.cmb_field), with the posterior predictive check and a report.
  • Tutorials for the pymc-challenges case studies in docs/tutorials/pymc-challenges/: an index and one page for each of the 15 ported cases (E07, E18, E19, E21, E23, E31, E50, E51, E58, E60, E61, E77, E79, E80, E81). Each page exports the data, uploads it, builds and fits the spec in the web app, reads the Model Explorer, scores the model and runs the matching recipe, with the CLI and the Python client as tabs. The numbers come from fits of the real data. E51 and E61 use the data that their notebooks simulate. docs/tutorials/complex-workflows.md shows the patterns of the recipes in the workflow editor: model ladders, gates, predictive checks, governance checks, calibrate-then-design, plots in reports, tree datasets and sub-workflows. The pages are in the navigation and have cards in the tutorials index.
  • The web mocks (workflows.gen.json, explorer.gen.json) have the ten gallery templates.

Fixed

  • evaluate.ppc failed for a model whose output has the name of its canonical target when the dataset binds the target to a different name (for example sci.phylo_regression, with trait bound to brain). The score names outputs in the names of the dataset, and the node now looks for that name.
  • sci.area_map of a fitted sci.areal_bym2 showed no Observed panel: the plot looked for rr in the observed data, and the model keeps the raw SMR as smr. A spec cannot give settings to a plot, so the fit now names the observed counterpart of rr in the attribute mapi_observed, and the plot reads it.
  • The table of a score result on the Score page lost the cells of a grid when a dim had numeric labels (for example y_cm and x_cm of sci.heat_plate, or the lawyer numbers of sci.network_latent_space). The page took the numeric dims for variables and kept one row for each run. The Arrow views now name their dims in the schema metadata mapi.dims, and the page keeps these columns as keys.
  • The science tutorials: the build steps now paste the full spec over the starter template (it has a pipeline, a split and MMM plots), open the closed Datasets section of the Insert panel, click Create dataset before the upload, take the spec name from metadata.name, and point to the Events card (the Logs card stays empty). The E18, E21 and E51 tutorials and complex-workflows.md have eight screenshots from a live dev stack (make docs-screenshots-science).
  • sci_spatial_ladder ignored its own convergence check: the branch clear took the then arm when the chains of the best model did not agree. The branch now needs a convergence check that does not fail, and the report says so.
  • The scorecard check "Few missing values" (data.missing) counted each NaN of the raw dataset. Thus it always failed for E18 (the padding of the outlines ring_x and ring_y) and E51 (the cells without a thermocouple). The check now counts only the variables that contain observations. It does not count the roles constant, index and mask, or the variables that the model declares in the new class attribute CustomModel.structural_missing (sci.heat_plate declares temperature). The expert message names the variables that the check does not count. Gaps in a target, a spend or a covariate count as before, also with missing: allow.
  • The gate of sci_calibrate_then_design stopped the run with EXPRESSION_ERROR when R-hat was null (one chain). The condition now uses coalesce, and the recipe needs two or more chains.
  • The Score page offered No new data for Predict, Simulate and What if. The server refuses such a request when it has no forecast horizon and no scenario dataset ("the score request has no data"). The page now shows No new data only for Forecast, or when a scenario dataset gives the values. For the other modes, a note on the page tells why the option is not there. For a model that has no forecast mode, the note says that the model always needs values to score.
  • Some suggested specs of mapi_plugin_science.export needed changes by hand:
  • E50 lists elpd_loo, but the spec did not keep the log-likelihood, so the fit did not calculate elpd_loo. The spec now has fit.keep: {log_likelihood: true} and the sci.profile_bands plot.
  • E60 listed sci.field_map, which cannot draw the vector output traction. The spec now lists sci.vector_field. It also fits on all of the data (mode: train_only), with no metrics.
  • E80 listed sci.field_map, which draws the parameter e_light and not the image. The spec now lists sci.image_residuals and sci.ppc_image_grid, and fits on all of the pixels (mode: train_only).
  • With --synthetic, the E77 spec held out speakers that the simulated data does not have, and the E79 spec had a bandpower bin that the simulated patch is too small for. These specs now fit the simulated data.

New tests make sure that each suggested spec validates against the schema of its exported file. A slow test fits each spec on the simulated data with few draws and makes sure that each plot of the spec shows, that a map shows an output of the model, and that the fit calculates elpd_loo when the spec lists it. - The score summaries of a circular output (OutputDecl.circular) were calculated on a line, not on the circle. For draws on the two sides of north (350 and 10 degrees), the mean was near 180 degrees and hdi_90 covered almost the full circle. Now the summaries of draws use the circular mean, and each interval (hdi, eti) and quantile is centred on that mean. The values are in [0, period), so an interval with lower more than upper crosses the seam. sd is the circular standard deviation. The difference between a scenario and its baseline (delta/summary, delta/draws) is wrapped to [-period/2, period/2). This is the same rule as the evaluate metrics. A circular output that has no valid period attr, or that is aggregated over a dim, keeps the summary on a line, and the result has a warning. - ScoreSummaryTable (summary.json) has a new field period for a circular output, so that a reader knows that the statistics are on the circle. - sci.circular_regression also declares mean_direction circular, with a period attr. - governance.check with recovery.simulated gave error for sci.lgcp_grid and sci.areal_bym2, and skipped for sci.network_latent_space. The check added residuals to the fitted values, so the simulated counts were not whole numbers and the refits failed. Now a Bayesian model with predictive draws simulates each dataset from its posterior predictive (one posterior draw and the noise of the model). The known values are the values of that draw. A count target without predictive draws gives skipped with the reason. A simulated dataset that the model cannot fit is counted in the evidence (failed_simulations). - The governance checks found no predictions for a model whose output has the name of its canonical target when the dataset binds the target to a different name (tie bound to cowork in sci.network_latent_space). They now look for the dataset name too.

Known gaps

  • governance.check with sensitivity.prior cannot find the prior widths of sci.areal_bym2 (intercept_sd, slope_sd, sigma_scale), so the check is skipped. The spatial ladder fits the model again with these widths in its place.
  • The graph check of the network ladder plots the statistics of the simulated networks only. The plot does not mark the statistics of the observed network.
  • core.prior_predictive cannot draw the prior draws of sci.heat_plate (the draws are on the sensor dim, the observed data is on the grid). Some science models (for example sci.cmb_field and sci.phylo_regression) keep no prior draws. The audit then shows a failed prior plot and continues.
  • Only sci_spatial_ladder has run end to end on a live dev stack and in a browser. The six M5 recipes run through the server orchestrator in a slow test (packages/server/tests/test_workflows_m5_templates.py), with small fits.

Added

  • Eleven science models in mapi-plugin-science, ported from the pymc-challenges case studies. Each has an About card, a section in docs/reference/models.md, a conformance fixture, a data export case and tests against the real data of its notebook:
  • sci.heat_plate (E51): the 2-D steady heat equation of a heated plate, a finite-volume system solved in the model graph; predicts the field of new heater layouts.
  • sci.turing_pattern (E61 part A): Schnakenberg rates from early images of a growing Turing pattern, with the exact Fourier-mode likelihood, the dispersion relation, P(Turing) and simulated patterns.
  • sci.storm_localisation (E58): single-molecule localisation in raw EMCCD STORM frames, with camera calibration, spot detection, a batched pixel-integrated Gaussian PSF fit and a super-resolved render kept in the bundle.
  • sci.ibp_features (E31): latent binary features of small images (truncated Indian buffet process with the patterns summed out).
  • sci.colour_naming (E77): colour naming of one World Color Survey language, pooled or with speaker effects.
  • sci.cmb_field (E79): field-level inference of a masked CMB map with a Wandelt-Eriksen Gibbs sampler (sky draws behind the mask and bandpowers).
  • sci.areal_bym2 (E18): disease mapping on a graph of areas with iid, ICAR, proper CAR or BYM2 effects.
  • sci.network_latent_space (E21): ties of a graph from pair covariates, sociality and Procrustes-aligned latent positions.
  • sci.graphical_model (E19): a Gaussian graphical model, dense LKJ or horseshoe neighbourhood selection, with edge probabilities.
  • sci.phylo_regression (E23): regression on a phylogeny with iid, Brownian, Pagel lambda or OU residuals.
  • sci.circular_regression (E81): von Mises, projected normal and regime-mixture regression of angles, and log-Fourier activity densities with overlaps.
  • Export cases e18, e19, e21, e23, e31, e51, e58, e61, e77, e79, e81 (KSBA winds) and e81b (Barro Colorado camera-trap times). A case whose notebook simulates its data (E51, E61) needs no data directory.
  • OutputDecl.circular (mapi-spec): an output that is an angle (a direction, a time of day). The model sets a period attr on the scored array, and the holdout metrics use the circular mean, errors wrapped to half a turn, intervals around the circular mean and an arc-distance CRPS (mape, smape, wape and r2 are skipped). sci.circular_regression declares direction_hat circular.
  • mapi_plugin_science._common: the seeded generators, the quiet PyMC context, the attr cleaning and the draw selection the sci.* modules share (one copy instead of ten).

Changed

  • BindingMap rewrites the dim names in a variable's grid attr when it renames dims, so the sci.* image recipes find the grid of a score tree after a data.bind.
  • EnginePlan (mapi-pymc) has initvals and jitter: a plugin can start every MCMC chain at one given point (pymc, nutpie and the external NUTS bridges). sci.ibp_features uses it to avoid a start where all its features switch off.

  • The SDK harness parses the model's settings before the data is transformed or split, so a cross-field settings rule (a pydantic model_validator) refuses the fit early. sci.storm_localisation, sci.graphical_model and sci.colour_naming check their settings-only rules there, as the phase 1 models do.

Fixed

  • python -m mapi_plugin_science.export --list lists every case (a case registered after the __main__ guard was missing from the command line).
  • sci.circular_regression evaluate metrics treated angles as numbers: draws around north (350° and 10°) had a mean near 180°, and the MAE of a good fit was about that of a constant guess. They are circular now (see OutputDecl.circular).
  • sci.cmb_field simulate drew the mean of each new patch from the vague 100 µK prior of the fitted patch's mean; it now comes from the spectrum.
  • sci.storm_localisation: render_px_nm is at least 1 nm, and the fit refuses (SETTINGS_INVALID) settings whose render of score_draws draws is over 50 million values (a 0.1 nm render could ask for terabytes).
  • score_bundle refuses a mode the model does not support (MODE_NOT_SUPPORTED) before any mode-specific work: a forecast of a model with text labels on the horizon dim raised an untyped UFuncTypeError, and some models answered ACTUALS_NOT_SUPPORTED. A horizon along text labels is COORD_NOT_ALLOWED.
  • sci.graphical_model: score data without a cell dim is SCHEMA_INVALID, not a KeyError (USER_CODE_ERROR).
  • Conformance: e18_areal and e19_ggm pass at --size full (their score expectations fixed the tiny data's district and protein counts; the hooks now check them against the data).

Known gaps

  • sci.turing_pattern: the notebook-sized fit takes about 5 min and mixes slowly (r_hat about 1.02, bulk ESS about 180 for rho and g).
  • sci.network_latent_space: the latent-space fit with covariates (model c) warns at 4 × 1000 draws (max r_hat 1.08, bulk ESS 46); it needs more draws or tuning.
  • sci.network_latent_space: evaluate metrics include the diagonal (self-tie) cells.

Added

  • Core plot recipes (mapi_plots.recipes.extras, Vega-Lite): core.pair (corner plot with divergent draws), core.ridge (one density for each level, with HDI), core.rank_matrix (P(rank = k) and P(row above column)), core.calibration (PIT-ECDF difference with a simultaneous envelope, or a reliability diagram for 0/1 targets) and core.state_sequence (observed series above the state probabilities).
  • Science plot recipes (Plotly): sci.area_map (areas as polygons with mean, sd and exceedance panels), sci.phylogeny (a tree with tip estimates), sci.rose (polar rose or density of angles), sci.image_residuals, sci.ppc_image_grid, sci.swatches (sRGB colour squares, also as a map), sci.power_spectrum and sci.point_pattern (points with error ellipses over a field).
  • Network pack net (entry point mapi.plots: net): net.graph, a node-link drawing and an adjacency matrix of an edge variable (…, node, node_). The catalog shows a "Networks" filter.
  • Each new recipe has an About card, an example in mapi_plots.examples, a gallery image and a row in docs/reference/plots.md.
  • The plot sandbox applies the theme to polar subplots (barpolar, scatterpolar).
  • Workflow nodes: model.summary (posterior summaries, R-hat, ESS, divergences; conditions can read nodes.s.params[...]), evaluate.ppc (coverage, PIT KS distance, Bayesian p-values), compare.stacking (stacking and pseudo-BMA+ weights from the pointwise elpd that model.fit now keeps), model.score (a score mode as a dataset) and plot.render (any catalog recipe as a plot output). The new node kind is plot.
  • model.forecast and evaluate.residuals now give their plot outputs. report.summary has a plots input and plot sections. The web run view draws the plot outputs of nodes and reports.
  • The SDK MetricsReport has an elpd field with the pointwise values (up to 10 000 points). The server reads it from the fit run of a workflow.
  • model.fit and model.backtest take nodes and join, so tree datasets work in workflows. The local runner accepts xr.DataTree inputs. mapi_workflows.testing.science_registry() registers the science plugin.
  • Data export of the science case studies: python -m mapi_plugin_science.export <case> --challenges <data dir> --out <file.nc> (and --list) writes a file that you can upload. Cases e07, e50, e60 and e80.

Changed

  • mapi-workflows[runtime] depends on mapi-plots[recipes].
  • The server puts the plot outputs of workflow nodes through the plot sanitizer when it collects them (an unsafe plot fails its node with PLOT_UNSAFE) and again when it serves node runs, run outputs and report plot sections.
  • Workflow validation resolves the recipe of plot.render against the bundled recipes and the plot catalog (REF_UNRESOLVED).
  • model.summary computes max_rhat and min_ess over all the values of the selected variables (up to 20 000), not only the 200 values of the table.
  • model.score, model.forecast and plot.render (source: predictions) send a tree input whole and give a tree-input model the tree of that input. plot.render with source: data keeps the groups of a tree input.

Fixed

  • sci.phylogeny matched tips by label when a 0-based tip coord shared a label or two with the node positions by chance; most tips were then empty.
  • sci.image_residuals with log divided log10 residuals by a σ in data units. The normalised residual is now in data units.
  • A non-finite elpd_loo (a log-likelihood of -inf) made the fitted bundle impossible to load. Such an estimate is now left out, and bundles that already hold one load.
  • The pseudo-BMA+ bootstrap of compare.stacking no longer holds a (n_bootstrap, N) matrix (800 MB at the limits).

Known gaps

  • A backtest of a tree dataset gives the full tree to each fold. A tree-input model that reads its target from the tree can see the test window. The node shows a warning.
  • search.grid has no nodes parameter.
  • The HTML report does not show plot sections. Only the JSON report and the web run view show them.

[0.8.0] — Tree datasets — 2026-10-18

This release adds datasets that are trees of groups. A NetCDF-4 or HDF5 file keeps its groups, a raw HDF5 file is mapped to variables after an inspection of its arrays, and a version can combine several files: each file as a node, files stacked along a dim, or files merged on shared coords. A spec selects the nodes that its pipeline reads, and a model can also ask for the whole tree. A guard checks every HDF5 file in the sandbox before it is opened. The catalog also shows each plot recipe as a card with an example picture.

Known gaps

  • libhdf5 reads a variable-length text attribute whole before the exact length check. The read is bounded by the run budget, and some rare files with such attributes are refused.
  • The web HDF5 mapper cannot set the description of a variable or the DimSpec of a coord. Use the Python client or the CLI for these.
  • A local mapi fit --data x.tree.zip reads the file as a plain dataset. Only mapi check resolves data.nodes locally.
  • Workflow nodes that fit again on changed flat data, and the ablations of governance checks, do not give a tree to tree-input models.
  • The tree version page shows the preview and profile of the default node only. The Data Explorer shows the other nodes.
  • The web flows for trees ran against the mock API only. The real server and runner path ran in the end-to-end test.

Added

  • Tree datasets (M4). A dataset version can hold an xr.DataTree (layout tree): one node for each group, with node paths such as / and /instrument/psf. A TreeSchema describes it (one DataSchema for each node; mapi_spec.tree). DatasetVersion has layout, tree_schema and sources. The version stores data.tree.zip and tree_schema.json, and its schema.json is the schema of the root node. Migration 0014_tree_datasets adds the columns. Refer to the new guide Tree datasets, HDF5 and multi-file versions (docs/guides/tree-data.md).
  • NetCDF-4 and HDF5 groups. The ingest opens a NetCDF-4 (or xarray-written HDF5) file with groups as a tree, and a .zarr.zip with nested groups too. Empty groups stay as nodes.
  • Raw HDF5 files (format hdf5, .h5/.hdf5/.he5). IngestMapping.hdf5 (Hdf5Mapping) tells which arrays become which variables of which node, with their dims (Hdf5Var) and coords (Hdf5Coord: an array of the file, a range of numbers or dates, or an integer index). One warning lists the arrays that the mapping does not name.
  • Upload inspection. POST /v1/datasets/{dataset_id}/uploads/{upload_id}/inspect starts an inspect run, and GET …/inspection gives the UploadInspection: the groups and arrays (path, shape, dtype, chunks, filters, dims hint, small attributes) from the metadata only. 409 NOT_READY while the run is not complete. MAPI_INSPECT_TIMEOUT_S sets its time limit.
  • Versions made from several files. DatasetVersionCreate.assembly (IngestAssembly) combines a maximum of 64 uploads in one ingest run. Each source goes to a node and joins the other sources of that node by node, concat (along a new dim, one label for each file) or merge (on shared coords, with join exact, inner or outer).
  • data.nodes and data.join in specs. A spec selects nodes of a tree (NodeSelect: path, vars, prefix). The platform merges them into the one flat dataset that the pipeline reads. Static validation resolves the merge from the tree_schema of the version. A side table that is a tree reads one node (side_tables.<name>.node).
  • Tree-input models. Capabilities.input = "tree" gives a model the whole bound tree in ctx.tree (fit, score, evaluate, the Model Explorer, governance checks and workflow scoring), next to the flat input. mapi_sdk.tree has select_nodes, infer_tree_schema, validate_tree and read_tree_zip/write_tree_zip. The contract kit has check_tree_model, TreeModelFixture, tree_dataset and the reference model toy.tree_linear. fit_spec and score_bundle accept tree datasets.
  • The Data Explorer shows one node of a tree at a time. Ingest writes the views of each node under ui/explore/nodes/, and a DataExploreRequest takes a node.
  • Python client: datasets.upload takes an xr.DataTree and raw HDF5 files; datasets.upload_files (with AssemblyFile), stage_upload, inspect_upload, upload_inspection, inspect and inspect_file.
  • CLI: mapi datasets upload takes several files with --node, --concat DIM=LABEL, --merge and --join. New commands mapi datasets inspect and mapi datasets ingest.
  • Web app: the upload page takes several files for one version. Each file has a format (from its extension; .h5/.hdf5/.he5 give hdf5), a node path and a combine rule (node, concat with a dim and a label, or merge with a join). The page checks the assembly and shows every problem before the upload. A raw HDF5 file is inspected first; an array browser (path, shape, dtype, dims, filters, warnings) lets you map arrays to variables, with dims, roles, units, a missing-value rule and a coord for each dim (an array of the file, a start and step, or an index). Each problem shows next to the input it is about, in a live region that a screen reader announces. NetCDF and Zarr files can be inspected to preview their groups.
  • Web app: the version page of a tree dataset has a Nodes tab (the node tree, the schema and coords of each node, and the preview of the default node). The Data Explorer takes ?node= and has a node picker. The spec editor completes data.nodes (node keys, paths and vars) from the tree schema, and the Insert panel has a Tree nodes group.
  • sci.strong_lens declares capabilities.input = "tree". When the tree has a /psf node, the fit uses its PSF; otherwise the flat psf, as before. The new setting psf_source (auto, the default, or tree) lets a spec select only the image node: with tree, the flat input needs no psf and the fit requires the /psf node. mapi_plugin_science.lens_data.to_tree builds the tree form of the lens data.
  • Plot cards with examples. The catalog's Plots tab shows each plot recipe as a card with a picture of the plot, a plain-language summary and what the plot needs. A card opens the plot's full About card (when to use it, how to read it, notes for experts) with a larger, interactive example. Plots are also in Browse and in GET /v1/catalog/abouts (kind=plot).
  • Every built-in recipe has an About card (mapi_spec.plot_abouts) and synthetic example data, the science pack included. PlotCatalogEntry carries summary and about; a recipe can set its own with @recipe(..., about=, example=).
  • A describe run also renders each recipe on its example data (plot_examples.json). The finalizer sanitizes the examples and stores them with the catalog (migration 0013_plot_examples), and GET /v1/catalog/plots/{plot_id}/{version}/example serves them. A broken example never fails the describe run.
  • The docs' plot recipes catalog has a section and a picture for each recipe; render_gallery.py now draws every recipe (Plotly through kaleido).

Changed

  • h5py and h5netcdf are explicit dependencies of mapi-runner. Before, only the MMM plugin brought them. Each runner image now reads NetCDF-4 and HDF5 uploads.
  • Format netcdf now means a self-describing NetCDF-3 or NetCDF-4 file, or an HDF5 file that xarray or h5netcdf wrote. A NetCDF file that has only a root group still gives a plain dataset version, as before.
  • The ingest run of a multi-file version uses the resource class of the sum of the upload sizes. The uploads of one version are a maximum of 5 GB together.
  • make dev describes the catalog again after a code change. In a source checkout (no MAPI_UV_LOCK_PATH), the image digest that keys the catalog also hashes packages/*/src and plugins/*/src. Before, a change that left uv.lock alone (a new recipe or About card) kept the old catalog. The images still use the hash of their baked uv.lock.

Fixed

  • The ingest ignored the groups of a NetCDF-4 file without a warning. Now each group becomes a node of a tree dataset, and the ingest never drops a group.
  • A local mapi check spec.yaml --data lens.tree.zip validates data.nodes against the tree schema of the local tree dataset. Before, it read only the root and refused data.nodes.
  • The plot sandbox refused every sci.* plot: its client-side check did not allow Plotly's <extra> tag in hover templates, which the server's sanitizer keeps.

Security

  • HDF5 guard. Before xarray or h5py opens an HDF5 or NetCDF-4 upload, the runner walks it with the low-level API of h5py, without a link followed. It refuses with INGEST_UNSAFE_FILE (422): external and user-defined links, soft links that do not resolve, loop or point to a group, hard-link loops, external raw storage, virtual datasets, filters other than deflate, shuffle, fletcher32, scaleoffset, nbit and lzf, user blocks larger than 64 KiB, too many objects or dims, deep groups, large attributes, arrays that declare more bytes than the run budget, and data types whose size has no limit. The worker runs with an empty HDF5_PLUGIN_PATH. The guard finds the HDF5 superblock behind a user block at each offset that libhdf5 uses. A file that starts with the NetCDF-3 magic goes only to the NetCDF-3 reader of scipy, which never calls libhdf5. Thus a fake NetCDF-3 header cannot take an HDF5 file past the guard, and a NetCDF-3 file whose values hold the HDF5 signature still ingests. Before the guard reads a variable-length text attribute, the worst case of the read (elements × file size) must fit the budget of the run.
  • The worker returns the typed refusals of the guard as the error of the run, with the names from the file only in untrusted_text.

[0.7.0] — Scientific models — 2026-10-17

This release adds scientific models with data on a map, an image or a grid. Four new plugins come from the pymc-challenges case studies: event counts on a map, a reaction-diffusion PDE, cell forces from gel movement and a strong gravitational lens. A model can now declare that an output is a 2-D field. The Model Explorer then shows the field with its uncertainty, and new Plotly recipes draw it. The Score page now guides each request in steps, and a forecast with actuals shows its predictions next to the actuals.

Known gaps

  • The docs' Score screenshots still show the previous form; regenerate them against a live stack (scripts/docs_screenshots.mjs).
  • The Model Explorer shows the Fields section only for fits made with this release. Fit a model again to see its fields.
  • The sci.* recipes and the field viewer are not in the plot gallery of the docs, which shows only Vega-Lite plots.
  • The real-data loaders of mapi-plugin-science need a local copy of pymc-challenges (MAPI_PYMC_CHALLENGES_DATA). The platform does not ingest the .npz files directly.
  • The corpus checks run e80_lens only at the tiny size. Nobody ran the full size.

Added

  • Scientific models (mapi-plugin-science). Four new plugins from the pymc-challenges case studies. They have data on a map, an image or a grid, and their outputs are fields with a range for each cell:
  • sci.lgcp_grid: event counts on a map over time, with a forecast (E07, Oklahoma earthquakes).
  • sci.fisher_kpp: a reaction-diffusion PDE that the model solves in each step of the fit, with a forecast to later times (E50, scratch assay).
  • sci.traction_force: cell forces from the movement of a gel, with exact draws of the force field (E60).
  • sci.strong_lens: a strong gravitational lens in a telescope image, with the source, the Einstein radius and the critical curves of each draw (E80).

Each plugin has a conformance fixture (e07_lgcp, e50_kpp, e60_traction, e80_lens). The base-bayes runner images contain them (namespace sci). - OutputDecl.grid (GridDecl): a model can declare that an output is a 2-D field. The declaration gives the row and column dims, an optional frame dim, an optional component dim for a vector field, the colour map, the log scale and the origin. - Fields in the Model Explorer. A new fields section shows each field output on a canvas. Select the mean, the sd, mean ÷ sd, the 5% or 95% map, or single draws. Use the frame slider and the draw player (hypothetical outcome plots). Point to a cell to see its value and its 90% range. Vector fields show arrows. - sci.* plot recipes (Plotly): sci.field_map, sci.vector_field, sci.profile_bands and sci.ensemble. A recipe can now return a Plotly figure. - Two new chooser goals ("Make a map or an image from measurements, with its uncertainty" and "Fit a physical model to measurements") and a new data shape ("A map, an image or values on a regular grid"). - Score page: type values instead of uploading a dataset. The page now explains what the chosen model version needs (what it predicts, the inputs it reads and which of them it needs on future periods, the fitted date range and every fitted level) and guides the request in steps: what to do (the version's modes in plain words), how far ahead, the values to score and what-if changes. Values are typed into a grid that starts with the periods to score crossed with every fitted level, with paste from a spreadsheet and "fill blanks" for a column; a forecast says what blank future inputs become. A dataset or no new data are still there, and the rarely changed settings moved under Advanced. A checklist says what will be sent and what is missing. - GET /v1/models/{model_id}/versions/{version}/inputs (ModelInputSchema): the input contract of a version - the one an endpoint serving it would freeze - and its input table with ?periods=N periods after the fit. - ScoreCall.inline (ScoreInline): data/scenario as inline XrJSON (up to 5 MB each) on POST …/versions/{version}:score, checked against the version's inputs and stored as run inputs exactly like an endpoint's inline payloads. The route gets the endpoint score body cap.

  • Predictions against your actuals. A forecast sent actuals returns one-step-ahead predictions on those periods (what the model expected before seeing each actual); the Score page now shows them next to the actuals: the number compared, the typical (MAE) and percentage error, how often the actuals fell inside the band and the average over/under prediction, a chart per series in the plot sandbox (actuals as points, the forecast dashed after the last actual) and a table with the error per period.
  • ScoreSummary.actuals: the actuals a forecast used, as tables named after the prediction variable they compare with (its coordinate columns, then actual), in the output's names and original units (raw-stage payloads; none for model-space output).

Fixed

  • A score payload with one label on an integer dim with a step larger than 1 (for example hours with an 8 h step) no longer fails with a false "gap" error. The validator now puts the payload on the grid of the fit.
  • The harness no longer treats a vector field (an output with a component dim that its grid declares) as an additive decomposition.
  • The Score page asked every model for mean/hdi_90 summaries, which quantile, interval and point models refuse (CAPABILITY_UNSUPPORTED). Summaries now default to the model's representation (median/q5/q95 for quantiles), only the ones it can give are offered, and draws are off for models without them.

[0.6.0] — Spec builder — 2026-10-17

Building a spec no longer means knowing every id and column by heart. The YAML editor now suggests the tenant's real models, steps, datasets, columns and plots as you type, checks each step's and the model's settings against that plugin's own schema, and has an Insert panel to search for anything and add it. Next to the YAML there is a Visual view: the same spec as blocks, with a palette to drag steps, models, datasets and plots from, steps reordered by dragging, and a form for every block. Both views edit the same document, so comments and formatting survive switching between them. Web app only: the API, the spec contract and the runners are unchanged from 0.5.1. Verified with the unit and Playwright smoke suites against the mock API, which now serves the real ModelSpec schema and plugin catalog.

Known gaps

  • Inside a flow mapping (with: {...}) completion offers setting names once part of a key is typed; on a line of its own it offers them straight away.
  • The Visual view edits settings it cannot draw as fields (unions of several shapes, maps of per-channel overrides) as JSON text.
  • evaluate.metrics has no suggestions: the catalog does not list metrics yet.

Added

  • Spec editor hints: completions and hover text now come from what the tenant actually has. model.use and pipeline[].use list the catalog's models and steps; data.dataset lists datasets; data.bind suggests the model's canonical names as keys and the dataset's columns as values; each step's with and the model's settings are checked and completed against that plugin's own settings schema, with dim and variable names the step can see; outputs.plots and outputs.derived list the model's plots and derived outputs. Values are suggestions only: the editor is no stricter than the server.
  • Spec editor Insert panel (button and tab): search datasets, models, pipeline steps, columns, plots and derived outputs and add them to the spec. Steps come with their required settings filled in; edits keep the document's comments and formatting.
  • Spec editor Visual view, next to YAML (the choice is remembered per browser): the spec as a stack of blocks (metadata, data, pipeline steps, model, outputs, execution). Drag steps, models, datasets and plots in from a searchable palette (or pick one and press Add), drag a step's handle to reorder it (or use its arrows), and open a block to edit it in a form built from its schema: each step's and the model's own settings, the data binding as canonical name → dataset column, the split by kind, the fit and execution. Dim and variable names the step can see are offered as one-click suggestions. Both views edit the same YAML, so comments and formatting survive; renaming or removing a step keeps the split's after_step in step. Problems show on their block and open in the YAML view.
  • Schema forms render objects with declared properties as collapsible nested sections (opt-in; the workflow inspector is unchanged).
  • Mock mode serves the real ModelSpec schema and plugin catalog (apps/web/scripts/gen_editor_fixtures.py → src/mocks/editor.gen.json).

[0.5.1] — Forecasts with actuals — 2026-10-17

A forecast endpoint can now be given the actual results that arrived since the model was fitted, together with the dates to forecast. The model updates its state with the actuals using its fitted parameters (nothing is re-estimated) and forecasts the requested dates from the last actual. Rows with a target value are actuals; rows with the target blank are the dates to forecast. All twelve statsmodels models can do this, including models fitted before this release, without a refit. Models that cannot (the PyMC time-series models, mmm-framework and the tabular models) refuse with 422 ACTUALS_NOT_SUPPORTED; before, the values were silently ignored. Google Sheets (an Input sheet), the Python client, the CLI, the web app's Try it panel and every snippet support it. Verified on a laptop against a live local stack, like 0.5.0.

Known gaps

  • Models fitted on transform.log, transform.box_cox or transform.difference output before 0.5.1 carry no data-space label, so they still accept actuals until they are refitted. New fits are labelled and refuse. There is no replayable log, Box-Cox or difference step with an inverse yet, so such models refuse actuals instead of transforming them.
  • The pre-run checks see the union of a panel's series. A problem in only one series (for example a missing driver) is caught in the run: the call returns 200 with run.problem instead of a 422. The same applies to dataset:// payloads whose stored summary predates DataSummary.observed.
  • When a split after the entry stage ends the model stage earlier (mmm train_only), a forecast payload that only covers fitted periods predicts those rows instead of appending the horizon.
  • VARMAX and DynamicFactor refuse series that end on different dates instead of filtering them. The jointly modelled models' own refusal text is reported as untrusted detail only.
  • The "horizon.periods is not used" warning reuses the code SCHEMA_INVALID at warning level.
  • The Guided forecast view and fan chart (ui/guided/forecast.json, sheets_forecast.gs) do not draw the actuals or the new forecast origin yet.
  • The Apps Script sends date cells as yyyy-MM-dd, so time steps shorter than a day lose their hours.
  • The integrations-try-it.png screenshot does not show the actuals editor yet.

Added

  • Forecasts with actuals (contract and score harness): a forecast request can carry the actuals that arrived since the fit (target values for periods after the fitted history, or revising its last periods) and the dates to forecast (target empty). The model updates its state with the actuals using its fitted parameters - nothing is re-estimated - and forecasts from the last actual; when the payload ends with an actual, horizon.periods periods are appended after it. See the new "Forecasts with actuals" concept page.
  • spec: mapi_spec.actuals (the rules, ForecastConditioning and SeriesConditioning); Capabilities.conditions_on_actuals; problem codes ACTUALS_NOT_SUPPORTED and ACTUALS_INVALID (both 422) with ActualsNotSupported and ActualsInvalid; ScoreSummary.conditioning; DataSummary.observed (where targets hold values) and the static checks in validate_score_request (with live_capabilities, the plugin build that scores); xrjson.observed_labels (the JSON null check of inline payloads); EndpointSchemaSnapshot.conditions_on_actuals, EndpointSchema.actuals_example and example_actuals_call; Freq.label.
  • sdk: score_bundle refuses actuals for models that cannot use them, appends the horizon, fills empty future drivers by future_covariates from the fitted history and the actual periods (mapi_sdk.future.fill_future_rows), checks every series (gaps, drivers) and calls the new CustomModel.forecast_with_actuals hook with a typed mapi_sdk.actuals.ForecastActuals (its default refuses). Every forecast result carries the conditioning record (root attribute conditioning). toy.ar1 is the reference plugin and the contract kit checks forecast_actuals.
  • runner: the record is in a score run's summary.json (so the API answer carries it inline) and result.json.
  • statsmodels: all twelve models condition forecasts on actuals with their fitted parameters - ts.sarimax, ts.arima, ts.unobserved_components, ts.varmax and ts.dynamic_factor by the Kalman filter (as results.append(new, refit=False); gaps among the actuals are filtered as missing by the univariate ones), ts.ets by its smoother, ts.markov_switching by the Hamilton filter, ts.ardl/ts.ecm and ts.var/ts.vecm on the supplied lags, ts.theta with its fitted SES weight, drift and season. Revisions replace fitted values, panels update each series from its own actuals, the fitted noise variance is kept and models already fitted (by a spec or the ts_readiness_arimax workflow) condition without a refit; the plugin version stays 1.0.0. See "Forecasting from new actuals" on the statsmodels page.
  • server: endpoint calls with inline data are null-checked for target values (DataSummary.observed; the values themselves are never decoded), so actuals a model cannot use are refused with 422 ACTUALS_NOT_SUPPORTED, and actuals with a gap or without their drivers with 422 ACTUALS_INVALID, before a run starts. Score checks on every path (endpoints, model score, runs, Guided) use the capabilities of the plugin build that will score - the catalog entry of exactly the fitted plugin version - so a model fitted before its plugin learned to condition can do so without a refit. GET /v1/endpoints/{alias}/schema reports snapshot.conditions_on_actuals from that build and returns actuals_example and the new input_table. Promoting a version that cannot use actuals onto an endpoint whose version can is a breaking change for the endpoint's schema_change policy (mapi_spec.endpoints.capability_breaks; blocked by default).
  • Input tables (spec): EndpointSchema.input_table describes an endpoint's inline input as one long table - the coordinate columns (the forecast dim first), the target, the drivers and other inputs, each with what it holds - with the periods after the fit as empty example rows. mapi_spec.xrjson.xrjson_from_rows turns such a table (records, a DataFrame's rows) into XrJSON and mapi_spec.endpoints.table_forecast_call builds the forecast over actual rows and blank-target future rows.
  • Google Sheets with actuals: the generated Apps Script reads an Input sheet in the endpoint's input-table layout even when the example call sends no data (a forecast) - actual rows with the target filled, rows to forecast with it blank, dates as yyyy-MM-dd - sets the horizon to the blank rows, writes the conditioning note above the tables in Result, writes a refusal's message and suggested action there too, and has a second function that creates the Input sheet. The Python, TypeScript, curl and R snippets of a forecasting endpoint add the call over the periods after the fit, to fill with actuals. New tutorial step "Add your latest actuals and future dates" (docs/snippets/sheets_actuals.gs).
  • Python client: Endpoints.forecast(alias, rows, *, actuals=, future=, periods=) and RemoteEndpoint.forecast (sync and async) send actuals and dates to forecast from a DataFrame, row mappings or columns; ScoreResult.conditioning; mapi_client.forecast_call.
  • CLI: mapi endpoints forecast ALIAS input.csv (or --actuals, --future, --periods, --dry-run); endpoints score prints the conditioning note and the inline summary; endpoints schema shows the input table.
  • Web: the endpoint's Try it has an editor for actuals and dates to forecast (add a period, paste from a sheet, Use in the call) and shows what the forecast was conditioned on with the first summary tables; the Google Sheets card previews the Input sheet layout of a forecasting endpoint; the model Score page shows the conditioning note.
  • Live-stack journey tests/e2e/test_forecast_actuals_stack.py (make e2e): the airline series through the ts_readiness_arimax workflow onto an endpoint, then actuals and dates to forecast through the Python client and through the server's generated Apps Script (run in Node, calling the live API with an Input sheet), and the refusal of a model that cannot use actuals (ts.hsgp_trend).

Changed

  • A forecast request whose payload holds target values is refused with ACTUALS_NOT_SUPPORTED by models that cannot condition on them; such values used to be silently ignored (the forecast came from the end of the fit and the horizon was dropped).
  • Empty values of supplied drivers on the periods to forecast are now filled by a future_covariates policy other than supplied; before, only drivers missing from the payload altogether were.
  • The workflow node model.forecast sends only the rows of its data (future drivers) after the fitted history: workflows that wire the fit's own dataset in forecast from the end of the fit again instead of being refused for "actuals" that are the fitted values.
  • Actuals that only revise periods inside the fitted history are forecast from the end of the fit: the horizon follows the later of the fit's end and the last actual (the fitted periods in between keep their values), and the conditioning message says the actuals were inside (or overlapped the end of) the fitted history.
  • Empty periods among the actuals of a model that filters them as missing need their drivers too; the refusal (ACTUALS_INVALID) now names them in its message.
  • The Apps Script snippet's Input sheet: a variable column the sheet does not have is left out of the call instead of failing the script (the server reports missing required inputs), dates are formatted in the spreadsheet's time zone, two rows with different values for one cell are an error, and a refused call is written into Result before the script throws.

Fixed

  • Forecasts with actuals never score values a pipeline step filled as actuals: with a target-imputing step (core.impute with ffill or mean) the blank rows of a payload used to be filled and scored as actuals, so nothing was forecast (or blank future dates were reported as "updated with N actuals").
  • A model a workflow fitted on transform.log, transform.box_cox or transform.difference output (no preprocessing-step equivalent; e.g. the VAR-on-differences branch of var_vecm_cointegration) is labelled mapi.io/data-space: transformed (mapi_spec.actuals.DATA_SPACE_LABEL, ScoreMetadata.data_space): it refuses actuals with ACTUALS_NOT_SUPPORTED instead of reading original-unit values as transformed ones, its endpoints do not offer actuals, and every score warns that its predictions are in the transformed space.
  • Empty targets inside the fitted range are no longer counted as gaps between actuals, so separate revisions of fitted periods are accepted for models that need a complete history (ts.ets, ts.theta, ts.ardl, ts.ecm, the multivariate models).
  • A forecast payload with no period after the fit and no actual (no rows - a header-only Input sheet - or only fitted periods) forecasts horizon.periods after the fit instead of answering with zero forecast periods; the Apps Script and the web editor send data: null when no row is left.
  • One actual on an integer time axis with a step above 1 appends the horizon with the fitted step (it was refused with a misleading gap).
  • predict calls warn that target values after the fitted history are not used (they were ignored silently).
  • A payload that lists the dates to forecast is checked for drivers on exactly those dates (with future_covariates: supplied it was refused against horizon.periods); inline payloads are null-checked for the drivers a forecast needs, so an empty driver on an actual row (ACTUALS_INVALID) or on a supplied future row (MISSING_FUTURE_COVARIATE) is a 422 before any run.
  • Text where a number is needed ("#N/A", a number stored as text, a cell of spaces) is refused with SCHEMA_INVALID naming the variable and the period before a run, instead of failing inside it. xrjson_from_rows/table_forecast_call (the Python client's forecast) and the Apps Script turn numbers stored as text into numbers and blank text into empty cells.
  • The Apps Script matches the Input sheet's headers trimmed and ignoring case, and refuses a sheet whose target column it cannot find but which has unknown columns instead of sending a plain forecast without the actuals.
  • Timezone-aware dates from a DataFrame are sent as dates (local midnight) or naive UTC, not with an offset; an off-grid time coordinate no longer also reports "start before the fitted range".
  • The workflow orchestrator read a dataset node's lineage from the persisted row, which a model node started in the same tick did not see yet.
  • Docs: jointly modelled series (VAR, VECM, VARMAX, DynamicFactor) need every series on each actual period; the Sheets troubleshooting table says which problems are a 422 and which a failed run.

[0.5.0] — M3.5: Workflows, governance, explorers, mmm-framework — 2026-10-16

Analyses can now be automated and models held to written rules before anyone relies on them. A workflow chains statistical tests, data processing, train/test plans, searches and model fits into a versioned, scheduled pipeline; a governance policy says which checks a model must pass (including null tests that show it beats noise) and whether a second person must approve it; and the Data and Model Explorers show what is in the data and in the fit. mmm-framework is now the only marketing-mix engine, with budget optimisation built on its planning module, and twelve statsmodels time-series models join the catalog. Everything was verified on a laptop against a live local stack (the GCS shim, embedded Postgres, the local-process executor); real Google Cloud, Docker and human testers are still to come (see Known gaps). In summary:

  • workflows (mapi-workflows, migration 0011): a declarative WorkflowSpec (a DAG of 51 typed nodes: statistical tests, transforms, splitters, searches, model fit, backtest, evaluate, compare, select, governance, report, branch.if with a safe expression language and plain-language explanations, sub-workflows) run by the server's orchestrator with per-node child runs in the sandbox, server-sent events, caching of unchanged nodes, cost roll-up, cron schedules and new-dataset-version triggers. A gallery of templates: time-series readiness to ARIMAX (seasonal and ordinary differencing decided by ADF, KPSS, Phillips-Perron and Zivot-Andrews with an over-differencing guard, an AIC search of the AR and MA orders, an expanding-window backtest against naive benchmarks, selection and governance), VAR or VECM by the Johansen rank, the governed marketing mix and a forecast bake-off. The web app has a visual workflow builder (React Flow), a live run monitor and Guided recipes.
  • governance (migration 0012): tenant- and project-scoped, versioned policies over 12 checks (shuffled-driver permutation test, noise-placebo drivers, time-shifted drivers, beats naive, holdout accuracy, residual whiteness, rolling parameter stability, sign constraints, prior sensitivity, parameter recovery, convergence, data quality); a validation state machine; second-person approval by a member with the reviewer capability (never the submitter); a promotion gate (NOT_VALIDATED) that checks the validation covered every gating policy as it stands now; HTML and JSON evidence packs; policy editor and review queue in the web app.
  • explorers: the Data Explorer (13 sections: profile, distributions, time plots, missingness, outliers, correlations, prewhitened cross-correlation with the KPI by lag, ACF/PACF, stationarity, STL and seasonal strength, VIF, group comparisons, data-quality warnings) and the Model Explorer (parameters, diagnostics, fit, residual tests, contributions, response curves, IRF/FEVD, forecasts, backtests, side-by-side comparison, governance evidence, lineage), computed in the sandbox (explore run kind).
  • models: mmm.bayesian and mmm.frequentist on mmm-framework==1.5.0 (national and geo/product panels, MFF ingest with mmm-framework's semantics, business priors, lift-test calibration, response grids, counterfactuals, scorecard) and budget plans through POST …:optimize (the optimize run kind: allocation, efficient frontier, chance of beating the current plan) in Expert and Guided mode. mapi-plugin-statsmodels: ts.sarimax, arima, var, vecm, varmax, ecm, ardl, ets, unobserved_components, dynamic_factor, markov_switching and theta, with future exogenous drivers, quantile intervals, IRF/FEVD and cointegration rank selection.
  • About cards and Help me choose: every model, step, split, template, workflow node, governance check and dataset carries a plain-language About card (the catalog refuses items without one); a chooser asks a few questions and ranks what fits.
  • client, CLI and docs: client calls and mapi workflows, mapi governance, mapi validations, mapi explore, mapi optimize, mapi choose and mapi about; the v0.5 documentation site (workflows, governance, null tests, explorers, mmm-framework, the workflow cookbook, generated node and check references); a generated THIRD-PARTY-NOTICES.md shipped in every image.
  • removed (breaking): Atlas (mapi_client.atlas), BayesInsight (importer, plugin, ingest preset, route), mmm.hierarchical_geo and the pymc-marketing adapter; see Removed below.

The M3.5 review found 46 defects (conformance and UX, security, statistics, reliability); all were fixed before the release, each with a regression test (the Fixed sections below).

Added

  • spec: the M3.5 contracts for workflows, governance, explorers, budget optimisation and About cards, shared by the server, runner, plugin, web and client components. The contract version is now 0.2 (the removals below are breaking).
  • mapi_spec.about and mapi_spec.abouts: the plain-language About card (summary, use and avoid when, data needs, assumptions, outputs, difficulty, typical runtime, example, related items, expert notes) on model manifests, step, split and plot catalog entries, guided and workflow templates, workflow node kinds and implementations, governance checks, and DatasetAbout for datasets (the owner's description plus a summary generated from the schema); the catalog rule (ABOUT_INCOMPLETE) and cards for every built-in step, split, model (including the mmm-framework and statsmodels ids of v0.5) and template; the "Help me choose" questions and the deterministic recommend ranking.
  • mapi_spec.workflow, .workflow_nodes, .workflow_templates: versioned WorkflowSpec DAGs of typed nodes (test, transform, branch, split, search, model, evaluate, compare, select, governance, report, subworkflow) with typed ports, parameters, alternatives and branch gating; static validation with JSON pointers (types, references, parameters, cycles, unreachable nodes); the node catalog (51 implementations with parameter schemas, result fields and About cards); runs, node runs, branch decisions, cache keys, cron schedules, dataset triggers and events; four gallery templates including the canonical readiness-to-ARIMAX workflow.
  • mapi_spec.expr: the safe condition language of branches (a hand-written parser and an evaluator over JSON with hard limits; never eval). mapi_spec.cron: schedules.
  • mapi_spec.splitplan: multi-fold split plans (holdout, rolling origin, expanding window, blocked and purged k-fold, geo holdout, nested) with pure fold computation that matches scikit-learn's splitters. mapi_spec.stattests: test results and decisions.
  • mapi_spec.governance: versioned tenant and project policies, the twelve-check catalog (null tests against shuffled targets, noise drivers and time-shifted placebos, naive benchmarks, residual whiteness, stability, signs, prior sensitivity, simulated recovery, convergence, holdout accuracy, data quality), evidence, the pure validation state machine, approvals by a second person with the reviewer capability, and the evidence pack. The promotion gate refuses unvalidated versions when a policy says so (NOT_VALIDATED).
  • Data Explorer and Model Explorer views (mapi_spec.views) and their requests (mapi_spec.explore); OptimizeRequest/OptimizeResult for POST …:optimize mapped onto mmm-framework planning (mapi_spec.optimize).
  • RunKind workflow (a parent), workflow_node and explore; M35_ENDPOINTS (60 operations) merged into ENDPOINTS, with access rules; resources workflow, policy, tenant_policy, actions validate and approve, the reviewer capability on memberships; scopes workflows:write, workflows:run, models:validate, policies:manage (no token can approve); audit actions, webhook events and problem codes for all of it.
  • sdk: Step.about and CustomModel.about/family, and the contract kit's About check (check_about, and an about check in check_step/check_model).
  • plugins: mapi-plugin-statsmodels (Apache-2.0; statsmodels is BSD-3-Clause), twelve ts.* models: ts.sarimax (ARIMAX/SARIMAX), ts.arima, ts.unobserved_components, ts.ets, ts.theta, ts.ardl, ts.ecm (UECM with the Pesaran-Shin-Smith bounds test), ts.var, ts.vecm (Johansen rank selection), ts.varmax (VARX), ts.dynamic_factor and ts.markov_switching. Panels loop over settings.series_dims; outputs are prediction-interval quantiles; forecasts need future drivers (MISSING_FUTURE_COVARIATE). Bundles hold parameters and data only - no pickle - and scoring rebuilds the results (Kalman filter/smoother with the stored parameters, or deterministic re-estimation checked against them). Fits store parameter tables with standard errors and p-values, fit statistics, residual tests, impulse responses with bands, variance decompositions, Granger tests, the Johansen table, ECM bounds tests, smoothed components, factors and regime probabilities; explorer_views() turns them into Model Explorer sections. Reference-value tests against direct statsmodels calls and the statsmodels VAR documentation; conformance fixtures sm_arimax and sm_vecm_macro. The plugin ships in mapi/base-forecast.
  • docs: Workflows, Governance and About cards concept pages; the v0.5 section of the HTTP API reference; the statsmodels time-series models page.
  • plugins/mmm: mmm-framework (mmm-framework==1.5.0) is the MMM engine. mmm.bayesian (mmm-framework's BayesianMMM: national, or partially pooled over geos and products; NUTS on nutpie, PyMC or NumPyro, or MAP/ADVI/Pathfinder/Laplace/SMC) and mmm.frequentist (its ridge estimator with bootstrap intervals). Both own scaling, carry-over and units, score by replaying the fitted model per draw in NumPy (checked against mmm-framework's forecaster), prepend the fitted history so carry-over reaches scored periods, follow new_levels (pooled, hyperprior, zero, error) for unseen geos/products, and cache contributions, response curves and marginal costs. Business priors translate in closed form into log-normal ROI priors (and an intercept prior solved against mmm-framework's own baseline prior); lift tests become mmm-framework experiment likelihoods (marginal ROAS or contribution). MFF panels are handed over in the layout MFFLoader builds.
  • runner, server, sdk: the optimize run kind and POST /v1/models/{model_id}/versions/{version}:optimize with GET /v1/optimizations/{run_id} (Prefer: wait, Idempotency-Key, an optimize.completed webhook): budgets split across channels (or geo x channel) by mmm-framework's planning module from the posterior's response curves, with bounds, groups, risk objectives, a horizon and the efficient frontier; the mmm.budget_plans plot. CustomModel.optimize and mapi_sdk.models.BudgetPlan are the plugin hook.
  • conformance: e33 and guided_mmm fit mmm.bayesian; the mmm_frequentist fixture.
  • docs: "Marketing-mix models on mmm-framework" (data contract, how the spec maps onto mmm-framework, priors, lift tests, optimisation, limits), the rewritten marketing-mix guide and the "Optimise a budget" tutorial.
  • workflows (new distribution mapi-workflows): the workflow engine and its nodes.
  • A JSON-only engine (mapi_workflows.engine) that both the control plane and a local runner drive: topological scheduling with max_parallel, branches evaluated with the safe expression language, input alternatives, skips, retries, on_failure: continue, cancellation, deadlines, cache keys and an event log; orchestrator nodes (compare.models, select.best, select.threshold, report.summary, report.evidence_pack) computed on JSON results only.
  • The data nodes (mapi_workflows.runtime, extra runtime): 17 statistical tests checked against statsmodels, scipy and arch reference values (Phillips-Perron included), transforms, the seven train/test plans, ARMA/SARIMA/VAR-lag/cointegration-rank searches, grid searches, backtests (window plans, masked k-fold, geo hold-outs), forecasts, benchmarks, metrics, residual diagnostics, Diebold-Mariano with the HLN correction and governance nodes over pluggable check runners (mapi.governance_checks).
  • LocalRunner: runs a workflow in-process (threads, timeouts, cancellation, an in-memory cache, sub-workflows); the canonical ts_readiness_arimax template runs end to end.
  • The generated node reference (docs/reference/workflow-nodes.md).
  • runner: the workflow_node run kind executes one node in the sandbox, with model inputs loaded from their bundles.
  • server: workflows (migration 0011_workflows, seven tables under forced row-level security): documents and revisions validated at every save, runs as workflow parent runs driven by the orchestrator's workflow loop, nodes as workflow_node, fit and child workflow runs, derived datasets registered as wf-<workflow>-<node> versions, cache reuse across runs, cancellation through the run state machine, SSE events, output links, cost roll-up, cron schedules, dataset.version.created triggers with debounce, webhooks and audit; the dispatcher and reconciler skip parent runs.
  • docs: the workflows guide (docs/guides/workflows.md) with the canonical document and a cookbook whose fragments are validated by a test.
  • Governance (server, runner): policies, validations and approvals end to end.
  • Every check of the catalog runs in a sandboxed governance run (new run kind, GovernanceJob → governance.json), model-family aware (Bayesian, frequentist, machine learning) under a refit budget: block-permutation null test, placebo noise drivers (new channels or variables, measured by ablation through the score path), time-shift placebos, naive and seasonal-naive benchmarks out of sample, Ljung-Box whiteness, rolling-window stability, effect signs, prior sensitivity, parametric-bootstrap recovery, convergence, holdout accuracy and data quality, each with message_plain, a suggested_action (new on CheckResult) and evidence.
  • Migration 0012_governance: versioned governance_policies, model_validations and validation_approvals (forced row-level security) and memberships.capabilities.
  • Policy CRUD for projects and tenants with versions and retirement; the platform's default policies per model family (DEFAULT_POLICIES, seeded on a tenant's first use; they never gate promotion by themselves); validation requests, resubmission, cancel and deprecation; second-person approval with the reviewer capability (never the submitter, never an API token); the promotion gate refuses unvalidated versions with NOT_VALIDATED when a policy requires it (audited as endpoint.promote_not_validated); evidence packs as JSON and self-contained HTML; audit rows and webhook events for every step.
  • Explorers (runner, plots-core, server): the 13 Data Explorer sections (overview, distributions, time series, missingness, outliers, correlation, cross-correlation, ACF/PACF, stationarity, STL, VIF, group comparisons, quality warnings) and the 11 Model Explorer sections (parameters, diagnostics, fit vs actual, residual tests, decomposition, response curves, IRF, FEVD, forecasts, backtests, comparisons) computed in the sandbox: the quick data sections at every ingest, the model sections at every fit, the rest in explore runs (POST …/explore, cached per request). POST /v1/models:compare compares several versions with Diebold-Mariano tests. Vega-Lite recipes for every view in mapi_plots.explorer (JSON-only). Dataset About (GET/PATCH …/about). Statistical helpers are validated against statsmodels/scipy reference values.
  • docs: guides "Validate models with governance policies", "Explore a dataset" and "Explore a model".
  • integration (M3.5 parts working together):
  • Workflow model nodes fit the real models: mapi_workflows.testing.test_registry() registers the statsmodels plugin (extra mapi-workflows[testing]), and the canonical readiness -> ARIMAX workflow runs end to end on the real ts.sarimax (in-process, through the server and on the real runner). Model nodes bind a model's target, other roles, time dim and categorical dims (a panel's geo, a multivariate model's variable) to the data automatically, and rewrite carried transform steps and holdout splits in the model's names. Multivariate nodes read the series along the data's series dimension (the layout of ts.var/ts.vecm); test.vif expands a channel dimension into columns. The mmm_governed template fits mmm.bayesian on a geo panel.
  • Governance inside workflows: mapi-runner exports every governance check as a workflow check runner (entry point mapi.governance_checks), so governance.policy and governance.check nodes measure models with the same family-aware implementations as a validation request. In the server the policies in force come from the policy store, and submit: true on a passing node records the evidence as a validation (validated, or awaiting_approval when a policy wants a reviewer).
  • Checks and explorers on every family (statsmodels, mmm-framework Bayesian and frequentist, PyMC time series, tabular, scikit-learn, choice), tested on every conformance fixture: the Model Explorer prefers a plugin's own views (explorer_views, e.g. the statsmodels IRF/FEVD and test tables); predictions are compared with the observed target at the model's stage (pipelines that resample or convert currencies); score requests name the spec's supplied side tables.
  • Catalog About cards and Help me choose are served: GET /v1/catalog/abouts, GET /v1/catalog/chooser and GET /v1/catalog/recommendations; every operation of the endpoint table is now served. About cards for the five scikit-learn models; a test holds every selectable item of the installed catalog (models, steps, splits, guided and workflow templates, workflow node kinds and nodes, governance checks) to a complete card.
  • Images assert the workflow runtime, statsmodels and scipy in the runner images (JAX with mmm-framework in base-bayes) and the JSON-only workflow engine in the server image; packages/sdk joins the strict mypy list.
  • web: About cards, Help me choose, the explorers and budget optimisation.
  • About cards (plain language first, expert notes and references collapsed) in the catalog's new Browse tab (every selectable item, filtered by kind, difficulty and text), the model and step details, the spec editor (an About tab, a button on every pipeline step) and the Guided template cards; dataset About (the owner's description, editable, next to the generated summary) on the dataset page and the Data Explorer.
  • Help me choose (/choose, and on Home and at the top of Guided mode): one question at a time, live ranked matches with reasons and cautions, and Start with this into a Guided analysis, a new spec with the model, or a workflow template.
  • The Data Explorer of a dataset version: all thirteen sections from the ingest's views and explore runs (polled, cached, kept in the URL), with variable and series pickers, interactive Vega-Lite plots in the plot sandbox, plain readings first in Guided mode and every statistic (and the platform's export-ready plots) in Expert mode.
  • The Model Explorer of a model version: parameters (sortable, with intervals), diagnostics, fit vs actual, residual analysis, contributions, response curves, IRF and FEVD, forecasts and backtests on demand, governance evidence with the validation status, and lineage; Compare (POST /v1/models:compare) with Diebold-Mariano tests.
  • Optimise budget for marketing-mix models (POST …:optimize): budget, caution, per-channel bounds in multiples or money, groups and the frontier, with impossible bounds explained before the run; the plan with its allocation, expected result, chance of beating today's plan and the efficient frontier; Guided What if… leads to it.
  • Mock-mode fixtures computed by the real runner and plots-core (apps/web/scripts/gen_explorer_fixtures.py, --check in CI) and generated TypeScript types of the explorer views; the guides' screenshots (make docs-screenshots-explorers).
  • web: workflows and governance screens (both personas).
  • Workflows: the list and the template gallery with About cards; a "use this template" wizard (name, parameter defaults, bound data); a Guided "run this recipe on my data" flow. The visual editor (React Flow 12.12.0, MIT, lazy-loaded with the editor and the run graph) has a node palette grouped by kind with About cards, typed ports that refuse an incompatible edge with the reason, branch arms that gate nodes, a node editor whose settings form is generated from each node's JSON Schema (values, workflow parameters or expressions) with catalog pickers for models, checks and sub-workflows, a branch condition editor checked live by the server (never evaluated in the browser), and a YAML/JSON view kept in sync. Problems are pinned to nodes by their JSON pointers; revisions are saved with expected_revision and compared side by side.
  • The run monitor follows the SSE stream with Last-Event-ID resume and shows the graph by status, the steps in plain words, decisions with the values they read, outputs and events, cancel and cost. A node drawer shows test verdicts, cached-vs-ran, outputs (tables, scores, folds, reports, evidence, charts in the plot sandbox, file downloads), child runs and logs. Schedules use a cron editor with presets and a preview of the next firing times that matches mapi_spec.cron; triggers fire on new dataset versions.
  • Governance: the policy editor (project and tenant-wide; covers, checks with About cards, severity, limits checked like the server, parameters; approval and promotion switches; versions with a diff; retire), the check catalog, validations with the status path and history, per-check evidence with plain messages, what to do and the shuffled-result distribution, approve/reject (never the submitter; hidden without the reviewer capability), resubmit, cancel, deprecate and the evidence-pack export. Model versions have a Validation page, Members a reviewer column, and the promotion dialog explains a NOT_VALIDATED refusal with a link to the validation.
  • Mock API (--mode mock, tests): workflows, runs replayed live, schedules, triggers, policies, validations and approvals. apps/web/scripts/gen_workflow_fixtures.py writes their data from mapi_spec and a real local run of the canonical template and two governed fits (checked by make web-check and CI).
  • web: the M3.5 screens work as one app.
  • Navigation per persona: Guided mode lists Guided, Help me choose and Recipes (the workflow gallery as recipes) first, as does a Guided-locked project; Expert mode has Workflows, Governance and Help me choose (G O).
  • Start with this opens a workflow template as a recipe (Guided) or in the template wizard (Expert), and a governance check in the check catalog.
  • A finished workflow run links each model it fitted to the Model Explorer and the version's validations (run summary, fit step, outputs); a workflow run's run page opens its monitor, and lineage links the workflow run that fitted a version.
  • The Model Explorer shows the validation status in its header (a link to the version's validations) and links the latest validation and the policies; a validation links the Model Explorer and the run that computed its checks. Explorer and governance screens share one validation cache and badge.
  • The Data Explorer offers the readiness workflow with the data bound (?data= on the recipe and template wizard pages).
  • One About card component for every screen (framed or plain, compact for side panels).
  • Playwright journeys: Data Explorer → recipe run → Model Explorer → validations, and Guided what-if → budget optimisation and back.
  • client (v0.5 calls): a request builder for every M3.5 operation of mapi_spec.api.ENDPOINTS (checked against the contract; the strict coverage test is no longer an expected failure) and sync and async resources: Client.workflows (documents from a WorkflowSpec, a mapping, YAML text or a file; upsert by metadata.name that adds a revision only when the document's hash changed; runs with wait; stream_events, an SSE iterator of WorkflowSnapshot, WorkflowEventFrame and WorkflowEnd that resumes with Last-Event-ID and never repeats an event; node outputs downloaded and CRC32C-verified; schedules, triggers and the template gallery), Client.governance (the check catalog, project and tenant-wide policies - a fetched policy can be edited and sent back as it is), Client.validations (requests that wait for the checks, the review queue, evidence, resubmission, approve/reject never resent after a lost response, evidence packs as HTML or JSON), Client.explorations, and on catalog (abouts, about, chooser, recommend with the answers checked before sending, workflow nodes, governance checks), datasets (about, describe, explore) and models (explore, compare, optimize with the keyed Prefer: wait long-poll, validate). Failed workflow runs raise the failing node's error. Tested with respx and against the real server (embedded Postgres, the storage front, children executed in-process).
  • cli: mapi workflows (init from a gallery template, validate a file on the server, apply, create, run with --watch - a live node-status board fed by the SSE stream, exit status 1 when the run fails - watch, status, runs, nodes, node, output, cancel, archive, rm, nodes-catalog, schedules, triggers), mapi governance (checks with their About cards, policies per project or tenant with versions and retirement), mapi validate model (waits and prints every check in plain words with the suggested action) and validate workflow, mapi validations (review queue, evidence, resubmit, deprecate, evidence pack), mapi approve (--reject with a comment), mapi explore dataset|model|compare|get (views written as JSON), mapi optimize (the plan, the uplift and the frontier), mapi choose (Help me choose in the terminal, or --answer for scripts) and mapi about. Tested with respx and against a real server over HTTP.
  • docs: "What's new in 0.5"; the workflow cookbook (the canonical unit roots -> differencing -> AR/MA order search -> rolling-origin backtest -> ARIMAX vs naive -> select -> governance document in full, with the client and CLI commands, plus VAR/VECM cointegration and a forecast bake-off); "Can you trust this model?" (governance and null tests for non-technical readers and for statisticians); "Choosing a model" (About cards and the chooser); one section per statsmodels model (About card, minimal spec, forecast with future drivers); budget optimisation end to end with the client; the client and CLI guides and reference for v0.5; a home page that leads with the new capabilities; the roadmap moves plugin upload and MCP to v0.6. The cookbook documents validate and run with the local runner, the client walkthrough runs unchanged against a real server, every command in the CLI walkthrough is checked against the CLI, and the statsmodels page quotes the catalog's About cards (conformance/tests/test_docs_v05.py, packages/server/tests/test_docs_client_v05.py).
  • tests, docs: the M3.5 journeys through the running stack (make dev, then make e2e): tests/e2e/test_m35_stack.py (the canonical readiness workflow on a series with a unit root, governance null tests that block a model fitted on noise and a second-person approval, mmm-framework with its explorer views and a budget plan, the Data Explorer, a VAR's impulse responses, Help me choose, and mapi workflows run --watch on the cookbook document) and apps/web/e2e/m35-live.e2e.spec.ts (the same screens in the browser, Guided and Expert, seeded by tests/e2e/m35_browser_seed.py); docs/operations/m35-verification.md records what they verified, the defects they found and what remains. The M1 and M3 journeys were brought up to date with v0.5 (the E33 spec on mmm-framework, the plan's KPI name, webhook deliveries verified as they arrive).
  • docs: the site for v0.5. The home page shows the new screens at work and leads its measurements with the M3.5 journeys; "Why modeling-api?" covers workflows, governance, About cards and the Data Explorer and compares workflows with general orchestrators; "Use cases" adds a governed forecasting pipeline and macro/VAR analysis; the roadmap marks v0.5 available (with its own page) and M4 next (plugin upload for allow-listed experts, an MCP server). Screenshots of the workflow gallery, editor, run monitor, a step's drawer, decisions, schedules, the Guided recipe, policies, the policy editor, the review queue, a validation and its evidence (make docs-screenshots-workflows, apps/web/e2e/workflow-screenshots.spec.ts). Terminal outputs on the What's new, cookbook, choosing, Python client, CLI, mmm-framework and budget pages are captured from a live stack instead of illustrated. A generated governance-check reference (reference/generated/governance-checks.md, every check with its About card, limits and parameters, and the default policies); the permissions reference groups each resource's actions, marks approve as needing the reviewer capability, lists every scope including the v0.5 ones and has a Capabilities section; Tenancy and authentication lists the v0.5 roles, scopes and capability.

Changed

  • spec: a dataset's generated summary shows dates without a midnight time ("from 2023-01-01", not "from 2023-01-01T00:00:00").
  • workflows: the var_vecm_cointegration template (and test.johansen, search.var_lag_order, search.coint_rank) take the series along the data's series dimension; vars is optional (labels or variable names) and the template's series parameter was dropped.
  • governance: whiteness leaves out the start-up residuals of differencing; driver effects fall back to the coefficient for models whose in-sample predictions follow the stored history (statsmodels); recovery is skipped for autoregressive models; parameter stability is relative to the estimate's own uncertainty near zero and tolerates windows the model cannot be refitted on; checks that compare numeric predictions skip choice models instead of failing.

  • spec: workflow_run_events declares the after and limit query parameters the server already serves for the JSON page of events.

Removed

  • spec: BayesInsight (mapi_spec.bayesinsight, mapi_spec.bayesinsight_model, the bayesinsight ingest preset with BayesInsightMetadata/BayesInsightModelDef, BayesInsightImport, draft_from_bayesinsight, POST /v1/guided/import/bayesinsight) and the atlas snippet language. The geo_mmm guided template (2.0.0) targets mmm.bayesian (mmm-framework). mmm-framework is the only MMM engine.
  • server, runner, client, plugins: the BayesInsight import route, ingest preset, client calls and the mmm.bayesinsight plugin; the "Import a BayesInsight model" tutorial.
  • plugins/mmm (breaking): mmm.hierarchical_geo and the pymc-marketing adapter mmm.pymc_marketing (and the pymc-marketing dependency); specs move to mmm.bayesian and drop their scaling and core.lag_tensor steps. The pymc_marketing conformance fixture is now mmm_frequentist.
  • client, web, docs, images (breaking): Atlas. mapi_client.atlas (RemoteModel) and the atlas extra, the Atlas snippet tab, the "Optimise a budget with Atlas" tutorial and snippet, the Makefile's Atlas targets and the e2e Atlas step; budgets are optimised with POST …:optimize. The base-bayes and base-forecast images warm mmm-framework instead of pymc-marketing.

Fixed

  • web, workflows, spec (M3.5 review): the run monitor explained branch decisions only as raw expressions and "then/else arm" ("Decisions: Difference? -> then", "Took the then arm") and named the winner by its node id ("backtest has the best average error", "Best: backtest"). branch.if now takes plain explain_then / explain_else texts, recorded as BranchDecision.explain_plain and set in every gallery template; the report summary, the Steps table and the Decisions tab lead with them ("The tests found that the series wanders ..., so it was differenced"), list the verdicts of the tests a condition read, and keep the expression under For experts. Steps not needed say why in the same words, and selections and comparisons name candidates by their step labels ("ARIMAX", "Naive forecast"); the gallery templates label their candidates.
  • web (M3.5 review): the budget plan showed Guided users the planning library's own wording ("Goal: expected KPI (posterior mean)", "the greedy allocator's exactness precondition fails", "posterior interval", "Marginal ROI"), and its result tiles read "90% likely 1.3M – 1.3M". In Guided mode the goal and the known notes are now plain sentences, the original wording is under For experts, and the tiles use as many digits as their ranges need ("1.305M – 1.312M").
  • web (M3.5 review): Help me choose scrolled sideways on a 375 px phone: its five suggestion tabs were wider than the screen and "Where to start" wrapped onto three lines. A switch with more options than fit now scrolls inside its own strip, on every page.
  • web (M3.5 review): Start with this on a model in Help me choose or the catalog dropped a Guided user into the Expert YAML spec editor, with placeholder data and an error badge. In Guided mode a model now starts the guided template built on it (tabular.regressor → store demand, mmm.bayesian → geo marketing mix, ...); a model with none says it needs Expert mode and switches only on a click (never in a project locked to Guided mode).
  • licensing, images: LICENSE.md and the licensing page now list packages/workflows (Apache-2.0) with the other Apache-2.0 components. The licensing page now names the third-party libraries v0.5 added: statsmodels (BSD-3-Clause), NumPyro and JAX (Apache-2.0) and nutpie (MIT), which mmm-framework brings in, and @xyflow/react (MIT). A new THIRD-PARTY-NOTICES.md, generated from uv.lock and pnpm-lock.yaml by make notices, lists every Python and JavaScript package the images ship with its version and licence. It reproduces the NOTICE files and the licence texts of the JavaScript packages compiled into the web app. Every image now carries it, with the licence files, in /usr/share/doc/mapi/. make release-check and the test suite fail when it is stale.
  • spec, server (found by the M3.5 live-stack verification): a governance policy with applies_to: {families: [ts]} - the example of every governance page - covered none of the statsmodels models, which declare the family forecast, ts_multivariate or regime; its checks, approval and promotion gate silently never applied. A family now matches a model's declared family or the namespace of its id (ts for ts.sarimax).
  • server (found by the M3.5 live-stack verification): uploading data that was uploaded before - the same file as the next version, or into another dataset or project - failed its ingest with INTEGRITY_FAILURE ("ui/explore/index.json already holds different content"). Ingest outputs are stored under the data's content hash, and the v0.5 Data Explorer views written at ingest name the dataset version and carry plot timestamps. Each ingest's views are now stored under its run (t/<tenant>/datasets/<sha>/runs/<run>/ui/explore/); the data and its sidecars keep the strict same-content check.
  • client, cli (found by the M3.5 live-stack verification): explorations.view (and mapi explore --show) returned a section's plot list instead of its view for every section whose name sorts after plots (residuals, response_curves, stationarity, stl, ...): ui/explore/plots/<section>.json matched first, and the call failed with "the view is not a JSON object". The exact view is now looked up first; plots never match.
  • spec (found by the M3.5 live-stack verification): Help me choose ranked ts.sarimax sixth for "forecast weekly sales with drivers" although it is the only model that fits all four answers: items silent on a question scored the same 1.0 and ties broke on difficulty. Ties now break on the number of answers an item fits explicitly first.
  • web (found by the M3.5 live-stack verification): on the dev server (make dev), the first page to open a workflow graph crashed with "Invalid hook call" in ReactFlowProvider, and a lazy route loading while the editor's YAML worker first started failed with "Failed to fetch dynamically imported module": Vite discovered React Flow and the monaco-yaml worker's dependency late, optimised them in a second pass and reloaded. Both are now pre-bundled at start-up (apps/web/dev-prebundle.ts, with a test that every package a web worker imports is listed); production builds were not affected.
  • runner (found by the M3.5 live-stack verification): the Data Explorer's overview read "2 variables over 156 time" for the most common upload, a time dimension called time; the time dimension is now named by its frequency ("156 weeks", "36 months").
  • web (found by the M3.5 live-stack verification): a Guided user's first run of a recipe in a project - the workflow created from the template in the same click - failed with "The recipe did not start: Not Found": the run was posted to /v1/workflows//runs, with the workflow id the page was rendered with. Starting a run can now name its workflow, and the recipe runs the one it just created.
  • client: members could not be given the reviewer capability (needed to approve validations) without raw HTTP: tenants.add_member(..., capabilities=[...]) and tenants.set_capabilities(...).
  • workflows: compare.diebold_mariano with no more paired errors than the horizon (three folds tested at horizon 4) returned a NaN p-value and failed the node with an internal validation error; it is now a plain data error that says to add folds, and a non-finite long-run variance is refused like a zero one.
  • Web dev server: opening the app as http://localhost:5173 now redirects to http://127.0.0.1:5173. Before, the API's CORS allow-list and SameSite=strict session cookie only matched 127.0.0.1, so every call failed and the home page said the API was not reachable.

Fixed (M3.5 review: modeling and statistics)

  • spec, server: a schedule whose run was missed during the repeated hour of a daylight-saving fall-back (or that was created or edited in that hour) fired on every workflow-loop tick: next_fire compared local times and returned the first occurrence of the hour, which was already in the past. It now returns only times strictly after the given instant (a repeated local time still fires once), and the schedule loop refuses a next_run_at that is not in the future.
  • spec (security): a project policy could weaken a tenant policy's check parameters (for example data.quality max_missing: 1.0, or an empty plausibility.signs expected): parameters of a check named by both were overwritten by the project's. Parameters now merge by strictness (the stricter number, the union of lists and of expected signs, the tenant's value otherwise), so tenant policies are a floor for parameters too.
  • spec, server, web: Help me choose with a goal no starting point was made for ("Check or prepare my data") listed every model and template anyway, the first as "Best match" at 0% with "Not made for: check or prepare my data". An item made for other goals is now never recommended; when no model or template fits the goal, the building blocks that do (preparation steps, workflow nodes, governance checks) are ranked instead; the time-series readiness workflow template is now offered for "Check or prepare my data"; and the web app only calls an item the best match when it fits more answers than it misses.
  • spec, server: the About card of mmm.frequentist said it gives estimates "without the ranges a full Bayesian model provides" and "bootstrap ranges optional", although its bootstrap (at least 20 replicates) always produces ranges; it now says it gives simpler bootstrap ranges instead of full Bayesian uncertainty. The main MMM card listed the models mmm.nested and mmm.multivariate, which are not installed, as related items (shown as bare ids); GET /v1/catalog/abouts now leaves out related items that are not in the catalog.
  • spec: the gallery template Series that move together (VAR or VECM) failed on stationary series (growth rates, changes): Johansen rejects every null, the rank equals the number of series, and the template sent that rank to ts.vecm, which refuses it. Stationary series now get a VAR on their levels (a new stationary branch), and the VECM's rank is kept between 1 and one less than the number of series. The cookbook example and the workflows guide show the same three paths.
  • spec, workflows (statistics): the canonical Time-series readiness to ARIMAX template over-differenced seasonal series: its unit-root tests ran on the raw levels, so a seasonal random walk (no first difference needed) got a first difference on top of the seasonal one in 93% of simulated draws. The seasonal decision now comes first and the unit-root tests run on the seasonally differenced series, with an over-differencing guard (no first difference when it would leave a lag-1 autocorrelation below -0.5); in simulation the seasonal random walk is first-differenced in 1 of 30 draws and plain random walks are still differenced. Its AR/MA order search also scored models without the seasonal difference the final ARIMAX uses and searched every period, including the backtest's test windows: search.arma_order now takes D and period (it fits ARIMA(p,d,q)(0,D,0)s, the model that is fitted) and an optional split plan (it searches only before the plan's first test window), and the template uses both.
  • workflows (statistics): test.granger decided on the smallest p-value over lags 1..maxlag without correction, so an unrelated driver "helped predict" the result about twice as often as the stated alpha (0.11 at 0.05 in simulation). The verdict's p-value is now Holm-corrected for the lags tried (size 0.05 in simulation; correction: holm), or the exact F test at one prespecified lag (new parameter); the per-lag p-values and the uncorrected min_p_value stay as descriptive results.
  • workflows (statistics): compare.diebold_mariano treated one h-step error per backtest fold as errors from consecutive forecast origins: its long-run variance used h - 1 autocovariances in fold units and the small-sample factor of horizon h, which for folds 4 periods apart at horizon 4 shrank the statistic by 0.41 (or left no variance at all), and refused horizon 8 on 6 folds. Folds step periods apart only overlap up to ceil(h / step) - 1 folds; the node now reads the spacing from the folds (backtest and benchmark metrics carry each fold's origin, origins) or a step parameter, and uses that many autocovariances with the matching correction (details.variance_lags).
  • runner (statistics): the Model Explorer's comparison ran Diebold-Mariano tests with horizon 1 on the held-out errors of time-series forecasters, which are 1..H-step errors from one forecast origin (a single, strongly dependent path): equally accurate forecasts were called different in 74% of simulated cases. For such models (statsmodels and the other ts forecasters) the comparison now shows the metrics and rankings without a significance test and says to compare backtests instead; for models that predict from each period's observed drivers the test uses a Newey-West number of autocovariances.
  • spec, runner, web (statistics): the Data Explorer's cross-correlations were raw by default and judged against the white-noise band 1.96 / sqrt(n), valid only when one series is white: for two independent persistent series (AR(1), phi = 0.9) 53% of the lags were flagged significant and every run had a spurious peak. Prewhitening is now on by default (prewhiten: true, also in the web's explore dialog), and the band follows Bartlett's formula for two autocorrelated series (it is the white-noise band when either series is white), so the raw view no longer flags such lags either (under 10% in simulation).
  • workflows (statistics): the residual tests of a workflow (test.ljung_box, test.jarque_bera, test.arch_lm, test.breusch_pagan, test.white, test.cusum, evaluate.residuals) on an integrated or unobserved-components model included its diffuse first residual - the level itself, since the first prediction is 0 - so for ARIMA(0,1,0) on a series around 500 Jarque-Bera gave p = 0.0 instead of 0.47. The fit's own burn-in (statsmodels' loglikelihood_burn, stored per series) is now dropped.
  • runner (statistics): the governance check residuals.whiteness passed (Ljung-Box p = 1.00) on strongly autocorrelated errors of unobserved-components and other diffuse-initialised statsmodels models: it only dropped the start-up periods of an (S)ARIMA order, so the first residual - the level itself - stayed in and swamped every autocorrelation. The fit's own burn-in (stored per series) is now dropped as well.
  • statsmodels plugin, runner (statistics): the Ljung-Box test stored with every statsmodels fit (the RESIDUAL_AUTOCORRELATION diagnostic and the Model Explorer's residual view) reduced its degrees of freedom by every parameter - constant, drivers and the variance - so a correctly specified regression with AR(1) errors was flagged autocorrelated 23% of the time (and the rule silently switched to no correction when a model had many parameters, e.g. ETS). It now counts ARMA terms only (ar.L*, ma.L*, seasonal terms, ARDL/ECM lags of the target), stored per series as model_df, which residuals.whiteness also uses (it previously ignored seasonal AR/MA terms).
  • runner, spec, web (statistics, critical): the governance null test null.permutation_target had the wrong size and no power on time series. It shuffled blocks of the result's levels (which keeps the series' own persistence and adds a level jump at every block edge, including the train/test boundary) and compared raw holdout errors, whose spread came from which levels landed in the test periods: in simulation it rejected 35% of random walks and 33% of real AR alternatives, and a marketing mix model with no media signal passed. It now compares a scale-free statistic - the held-out skill against the null forecast (the last value of an integrated series, else its mean) on each dataset - under a null that matches what is tested: a model with drivers keeps its result and gets its drivers shuffled in time (so trend, season and controls cannot pass on their own; a model that ignores its drivers is tested as one without); a model without drivers gets the d-th differences of its result shuffled and re-integrated from the same start (d from its ARIMA order or ADF/KPSS). In simulation the test keeps its 5% size on random walks and rejects real dynamics far more often; data without a time order keep the classic row permutation of the result. The evidence names the null and the statistic, and the web's null-distribution chart labels its axis from it.
  • runner, spec (statistics): the governance check null.noise_regressors could never pass for a Bayesian marketing mix model: mmm-framework's channel effects are positive, so every posterior draw of a placebo channel's effect is above 0, its 90% interval never covers 0, and the check reported "clearly not zero" and WARN or FAIL even at a 0.5% share. A Bayesian model is now flagged when a placebo's share is above the warn limit with at least 90% posterior probability (practical equivalence); the share is taken of the real drivers' total effect (of the result only when that cannot be measured), so the limit does not depend on the size of the baseline. Placebo channels also inherited the source channel's per-channel priors and calibration (a tight business-prior or lift-test ROI), so a calibrated model failed because of the prior: they now get the model's default prior (carry-over and saturation shapes are still copied). Frequentist placebo p-values are judged with Holm's correction for the number of placebos (the uncorrected 5% rule flagged a good model 14% of the time with three placebos).
  • runner, mmm plugin (statistics): the governance check sensitivity.prior was always skipped for a default mmm.bayesian model ("the model's priors are not set in its settings"), although the default MMM policy lists it: mmm-framework's default priors are not in the spec's settings. The check now writes out every prior the model uses before scaling - the plugin's settings defaults (seasonality.prior_sigma, trend.growth_prior_sigma, hierarchy.media_geo_sigma), the defaults a plugin declares for values its engine fills in (mmm.bayesian: priors.roi_default, LogNormal(0, 1)) and business priors translated at fit time - and no longer multiplies Beta/Gamma beta or b parameters, which moved the prior instead of widening it.
  • workflows (statistics): nested split plans were not nested. search.grid ignored the inner folds of a split.nested plan and picked its winner by the outer folds' score - the optimistic selection bias nested cross-validation exists to remove - and reported that score as best_score; with a k-fold outer plan, search.grid and model.backtest sent the plan to the time-window scorer and failed ("horizon.periods ... -90"). Each outer fold now tunes on its own inner folds, refits the inner winner on its outer training window and scores it on the outer test window: best_score is that honest estimate (new results nested, outer_scores, selected_per_fold), and the outer plan's kind decides how folds are scored.
  • mmm plugin (statistics): a lift test that cut a channel's spend by up to 25% was mapped to mmm-framework's marginal-ROAS estimand for a spend increase of the same size: the experiment measured the slope below today's spend, the model was fitted to the slope above it, and under diminishing returns the backward slope is larger (1.49 times for 1 - exp(-2x) at a 20% cut), so the calibration pushed the saturation curve (and the budget optimiser) away from what the experiment measured. mmm-framework's MROAS takes only increases and its off-panel route has no marginal estimand, so partial cuts are now skipped with the reason (listed in learned.calibration.lift_tests.skipped); increases and full holdouts calibrate as before.
  • web, runner: the Data Explorer overview's missingness chart printed duplicate rounded ticks ("0% 0% 0% 0.1% 0.1% ...") on small ranges - its axis had a fixed precision; Vega now picks the precision from the tick step (the tooltip keeps two decimals). The overview and the quality section also said "No data problems found." above a list of good-to-know items (a little missing spend, extreme values); they now say "No problems to fix; 2 things to know."
  • runner, spec (conformance): MFF ingest disagreed with mmm-framework's MFF semantics, although the two are documented to give the same model. National rows (a blank Geography) became a phantom '' geo - national TV only there, sales missing there - where mmm-framework's MFFLoader spreads a national variable across the geos; and rows that differ only by Campaign, Outlet or Creative became their own dims where mmm-framework sums them. The mff preset now follows mmm-framework: a blank (or National, Total, All) label is the coarser level, and a variable given only there is spread across the levels (geographies equally, products by the KPI's share, as mmm-framework's defaults; new MffOptions.allocation and kpi), a variable may not mix the two levels, and the breakdown columns are summed unless MffOptions.split keeps them as dims. Ingest notes say what was spread. A parity test compares the panel with mmm_framework.load_mff on national media and campaign rows.

Fixed (M3.5 review: platform server)

  • server (security): a submitter could skip the tenant's strict policy - and the second-person approval it requires - by naming a lenient policy (even one for another model family) in policy_ids, and the promotion gate only looked at the latest status. A validation is now always held to every policy in force (tenant floor and project); policy_ids only adds policies. The gate accepts a validated version only when that validation covered every gating policy in force as it stands now (by version, or by checks, limits, settings and reviewers at least as strict); a version validated before a policy was added or made stricter reads "validated under other or older policies" and may be validated again.
  • server: two simultaneous validation requests for one model version (or a request and a workflow's submit: true) both passed the "no open validation" check and created two validations and two governance runs. The version row is now locked while the check runs.
  • server (security): a workflow's governance.policy node with submit: true recorded its validation under the one policy the node named, dropping the tenant floor, and as submitted by system, so the person who started the workflow could approve their own model. The submission is now held to every policy in force (evidence missing a required check fails), is submitted by the run's creator (the schedule's or trigger's creator for automated runs) and the second-person rule applies to them; a validation no person submitted cannot be approved (reject and resubmit it).
  • server, workflows: when a node failed while starting (invalid settings, a missing input), the run's fail-fast did not stop the siblings already started: the list of nodes to stop lived in memory only and was dropped before the next pass, so a long fit ran to the end before the run reported failed. Running siblings are now stopped in the same pass, and a restored failed or cancelled run lists them again.
  • server: cancelling (or failing) a workflow run while a search.* node was between its backtests and the winner's refit started the refit anyway - a fit run nothing cancelled, billed and registered after the run was cancelled. The refit is no longer started once the run is stopping, and a child started in the same pass is stopped by its current run id.
  • server: search.grid (and the other searches with a winner refit) always failed with SPLIT_INVALID "model.fit takes a holdout plan" when its split was a backtest plan - rolling origin, expanding window - which is what a search needs: the refit of the winner reused the backtest plan as a fit split. The winner is now fitted on all the data for a backtest plan (a holdout plan is still kept).
  • spec, server (security): an exception other than MapiError while advancing a workflow run - a 400-digit number parameter multiplied by a float in a $expr (OverflowError), or deeply nested data compared in a condition (RecursionError) - escaped the orchestrator's pass, left the run queued until its deadline (up to 7 days) and, with enough such runs at the head of the loop's queue, stopped every tenant's workflows. The condition language now raises only EXPRESSION_ERROR (arithmetic guarded, equality iterative and bounded to 64 levels), number parameters (and defaults) are limited to 1e15 in magnitude, and a run the orchestrator cannot advance is failed (INTERNAL_ERROR) with its children stopped instead of being retried at the head of the queue.
  • server (security): sub-workflow runs were exempt from MAPI_WORKFLOW_MAX_ACTIVE_RUNS, so one start of a workflow with 32 subworkflow.run nodes (and nesting up to 8 deep) created far more active runs than the limit. Sub-workflow runs now count against it, and a subworkflow.run node over the limit fails with QUOTA_EXCEEDED. (The children's executions were and are held by the dispatcher's per-tenant concurrency and vCPU limits.)
  • server (security): schedules and dataset triggers kept firing - and spending quota - for a member who had been removed from the project, demoted, or whose API token had been revoked: nothing re-checked the creator at fire time. They now pause themselves (active: false, audited as workflow.schedule_update/workflow.update) when the creator may no longer run workflows there, and their runs are created by the person (a token's owner, with the token recorded) rather than by the token id.
  • server: a dataset version that became ready while its trigger was firing could be lost for good: the finalizer wrote the new pending_version over the fired row without its pending_since, so no pass ever selected the trigger again until another version arrived. The pending version and its time are now set together in one atomic update that waits for a firing pass holding the trigger.
  • server: a workflow run's cost left out everything its sub-workflows ran (a subworkflow.run node's child is a workflow run with no attempts of its own); the roll-up now follows sub-workflow runs down to their node runs.
  • server: POST /v1/workflow-runs/{id}:cancel racing the run's completion reopened a finished run as cancelling; the next pass flipped it back and sent workflow.run.succeeded (event and webhook) a second time. The cancel now locks the run and leaves a finished run as it is.
  • server: POST /v1/runs/{id}:retry on a failed workflow run crashed with a 500 (NotImplementedError), and on a failed workflow node or governance run created a run that nothing collected. These are now refused with 409 CONFLICT naming what to do instead: start the workflow again (unchanged nodes come from the cache) or resubmit the validation.
  • server: workflow node runs carried no plugin label, so the Docker and Cloud Run executors ran every node in the default base-bayes image, which does not ship the statsmodels plugin: backtests, forecasts, evaluations and governance checks of ts.* models (the readiness-to-ARIMAX template) could not resolve their model in production images. A node run now goes to the image of its model parameter, or of the plugin of the model version bound to it.

Fixed (M3.5 integration)

  • web, spec: requesting a validation still offered "Policies it is held to" as checkboxes, all ticked, as if unticking one left it out. Since the review the server holds every validation to every policy in force (policy_ids can only add policies), so an unticked policy applied anyway. The form now says every policy in force always applies and its checkboxes, unticked by default, add a policy; the mock and the ValidationRequest description say the same.
  • docs: the governance outputs in What's new, the workflow cookbook and the null-tests guide were captured again on the integrated stack: the shuffled-driver test now passes on the cookbook's ARIMAX (p = 0.048) instead of failing it, and the note about the old null's weakness on random walks is gone.

Known gaps

  • No human testers yet. No non-technical user has tried the Guided recipes, Help me choose or the About cards, and no reviewer has used the approval screens on real models. The M3 tester protocol applies, with the readiness recipe as an extra task.
  • Real Google Cloud and Docker are unverified. Every workflow node, validation, exploration and optimisation ran on the local-process executor against the GCS shim. The images (in particular base-bayes with mmm-framework, JAX and nutpie, and base-forecast with statsmodels and the workflow runtime), the docker and docker-gvisor executors and Cloud Run were never run. Routing a workflow node to the image of its model's plugin is tested with fakes only, and a node whose models live in different images is not refused. The licence files and notices the images copy are checked statically.
  • mmm-framework's extensions are not exposed: mmm.nested (nested and mediated effects) and mmm.multivariate (several outcomes, cross-product effects) are not in the catalog. Partial spend-cut lift tests are skipped with the reason, because mmm-framework's marginal ROAS estimand only takes spend increases.
  • Plotly static rendering still needs kaleido and a system Chrome and has not run in an image (MAPI_RENDER_PLOTLY_ENABLED); Vega-Lite renders out of the box.
  • The null tests' size and power were simulated on statsmodels, regression and a small PyMC channel model, not on mmm.bayesian or mmm.frequentist (minutes per refit); they share the same code paths. The noise-regressor share is now measured against the real drivers' effect, so existing 2%/5% limits are stricter for models with a small media share; tuned policies may want a review.
  • The Data Explorer's prewhitened cross-correlation (the new default) hides a driver whose spend follows a fixed periodic flighting pattern; the raw view is one switch away.
  • Child runs a workflow's orchestrator starts are bounded by the active-run limit and the dispatcher's per-tenant concurrency and vCPU budgets, but not by the per-token admission quotas, which need a request. Schedules and triggers re-check their creator only when they fire (they pause themselves then); the reason is in the audit log, not in their API representation.
  • mapi workflows run --watch and the engine's skip reasons still say "took the then arm"; the web monitor and reports use the plain explanations.
  • Help me choose ranks ts.hsgp_trend first for a beginner forecasting one series with drivers, although it does not use them (ts.sarimax comes first for other experience levels).
  • The licences of a few Python packages with ambiguous metadata come from a hand-kept table in scripts/third_party_notices.py; re-check it when the lock bumps one of them.
  • The documentation's captured terminal outputs are kept in step by hand, and the Model Explorer comparison screenshot still shows a Diebold-Mariano verdict for forecasters.
  • Timing-sensitive tests can fail once on a fully loaded laptop (the canvas drag test of the workflow editor, the runner's checkpointed-chain test) and pass when run again.

[0.4.0] — M3: Guided mode and integrations — 2026-10-16

Analysts who do not write code can now build a model from a spreadsheet, read it and try scenarios on it, and teams can wire a model they trust into the tools they already use. Everything was verified on a laptop against a live local stack (the GCS shim, embedded Postgres, the local-process executor). The real Google Cloud, Docker and the five non-technical testers are still to come (see Known gaps). In summary:

  • Guided mode: a task flow for non-technical analysts. Pick a template (geo marketing mix, weekly forecast, demand by store, customer choice), upload a CSV, Parquet, MFF or Meridian table, confirm the detected column roles, state business beliefs (ROI or contribution-share ranges, a baseline) and lift tests, fit, and read a plain-language report with a scorecard. What-if sliders answer at once from a per-draw response grid (a TypeScript port of the model's own maths), and "Run exact" checks a scenario, a forecast or a counterfactual with a real score run.
  • modelling: business priors translated into model priors (translate_priors runs and a preview chart) and lift tests as calibration in mmm.hierarchical_geo and mmm.pymc_marketing; the mmm.bayesinsight built-in and a BayesInsight importer; a scorecard per version and a promotion gate that needs an audited admin override for a failing or unevaluated version.
  • endpoints and integrations: endpoints with aliases, promotion, history and rollback; generated snippets (Python, TypeScript, curl, R, Apps Script, notebook, Atlas); score answers with the summary inline; webhooks signed with HMAC, behind an SSRF guard, with an outbox, a delivery log and replays; saved plots, static PNG/SVG/PDF rendering with vl-convert and expiring embed links served by the plot sandbox; the Integrations hub in the web app.
  • trust and data lifecycle: self-service sign-up with e-mail verification, identity verification and Sybil flags with a review queue, trusted-proxy client addresses, per-tier draw and egress caps, GDPR export and tenant deletion with a grace period, and a Cloud Armor policy in Terraform.
  • client and CLI: the v0.4 Python client (endpoints, webhooks, plots, Guided, tenants) and Atlas RemoteModel, which lets Atlas optimise a budget against an endpoint; mapi endpoints, webhooks, plots, guided, priors, signup and tenant.
  • docs: a new landing page, Why modeling-api, use cases, a Tutorials section (Google Sheets, embeds, BayesInsight, webhook receivers, a first Guided analysis, Atlas), Guided and Integrations guides, the security model for v0.4, and the M3 verification report.

Every workspace package and both web apps are versioned 0.4.0. Contract v0.1 gains only additive changes. Upgrading from 0.3 runs migrations 0007 to 0010 (Guided drafts, endpoints and webhooks, saved plots and embeds, the trust lifecycle). Multi-tenant deployments must now set MAPI_TRUSTED_PROXIES (none when clients connect directly), or the server refuses to start.

Added

  • web: the Integrations hub (/integrations). Guided cards for Google Sheets (the endpoint's Apps Script and a score:<alias> token), a BI embed link and a webhook; endpoints and aliases with the promotion dialog (the version's scorecard, the expected gate, the server's reasons on 409 and the audited admin override), history and rollback, the frozen input contract with Try it (validate or score the example call), snippets for Python, TypeScript, curl, R, Apps Script and Atlas plus an Open-in-notebook cell; webhooks with an event picker, the secret shown once, rotation, Resend newest (a replay of the newest delivered event; there is no synthetic test event), the delivery log (status, attempts, response code) with replays and the auto-disabled notice; saved plots with PNG/SVG/PDF downloads (Plotly disabled with the server's reason on MODE_NOT_SUPPORTED) and expiring embed links with allowed sites and revocation; a Share button in the plot gallery; /tokens?preset=score:<alias>.
  • web: the trust pages. Public /signup (disposable-address and terms errors at their fields, the "check your inbox" state, the development outbox link) and /verify-email; Settings > Data for organisation admins (GDPR export and download, tenant deletion with the typed confirmation, grace period, cancel and per-store progress); a banner for scheduled deletions; in the admin console, the sign-up review queue and per-tenant identity verification and deletion status with a retry.
  • docs: The Integrations hub guide (with screenshots) and the Integrations section of the web app guide; make docs-screenshots-integrations.
  • spec: the M3 contracts for Guided mode and integrations, shared by the server, runner, web app and client components (contract v0.1, additive only).
  • mapi_spec.guided: four guided templates (geo_mmm, weekly_forecast, store_demand, customer_choice - the last needs a built-in choice model that is not shipped yet), plain-language questions, role detection from column profiles, drafts (ingest mapping + ModelSpec + YAML) and the BayesInsight model_def.json import.
  • mapi_spec.ingest: ingest presets (mff, meridian, bayesinsight), wide variables and the mff format; IngestMapping moves here (still exported by mapi_spec.api).
  • mapi_spec.calibration: business priors (ROI or share ranges with a credibility), lift tests, the ModelSpec.calibration block and the sandboxed translate_priors run kind.
  • mapi_spec.scorecard: the check catalog, items with check ids, categories and docs anchors, and the promotion gate (pass → allowed, warn → admin override with a reason, fail → blocked); Scorecard moves here (still exported by mapi_spec.api).
  • mapi_spec.whatif and ResponseGridView: per-draw response grids, the slider interpolation contract, "Run exact" and forecast/counterfactual presets; ScoreRequest.future_covariates.
  • mapi_spec.endpoints, mapi_spec.xrjson, mapi_spec.snippets: endpoints and aliases, score calls with inline XrJSON, input-schema compatibility, example payloads and pure snippet generators (Python, TypeScript, curl, R, Apps Script, Atlas).
  • mapi_spec.webhooks: events, envelope, Mapi-Signature signing and verification, retries and the SSRF policy; mapi_spec.sharing: saved plots, renders (PNG, SVG, PDF) and embed links for platform recipes; mapi_spec.trust: sign-up, e-mail and identity verification, tenant deletion and GDPR export.
  • mapi_spec.api.M3_ENDPOINTS (45 operations) with their access rules; new permissions (webhook, embed, tenant_deletion, tenant_export, plot delete, endpoint manage for gate overrides), scopes (endpoints:promote, webhooks:manage), audit actions, problem codes and Project.guided_locked.
  • docs: Guided mode and Integrations concept pages; the v0.4 section of the HTTP API reference.
  • server: the Guided data path. GET /v1/guided/templates (paged) and /{template_id}; POST /v1/guided/detect-roles from browser profiles or a stored dataset version; POST /v1/guided/drafts and POST /v1/guided/import/bayesinsight, validated statically against the catalog with the schema the draft's mapping promises. Every operation documents its RFC 7807 problems in the OpenAPI document.
  • Migration 0007_guided: projects.guided_locked and dataset_versions.columns.
  • The persona lock is persisted, audited (project.guided_lock) and enforced: in a locked project, members below admin store Guided specs only, and only their basic fields may change (403 FORBIDDEN with the pointers otherwise).
  • The finalizer validates and stores an ingest's columns.json, which detection reads and …/artifacts/columns.json serves.
  • runner: sandboxed ingest for Guided mode: wide variables (one column per channel or product), the Meridian preset, MFF tables mapped like a CSV after the pivot, MffOptions.combine, and the BayesInsight preset (allowed_* values, period range, necessary variables). Caps on rows, columns, cell and name length, and decompression (gzip CSV streamed under a budget, Parquet, zip and netCDF by their declared sizes) are checked before a parser allocates; every refusal is a plain message with a suggested action. Ingest writes columns.json, the profile of every uploaded column.
  • plugins: choice.multinomial_logit in mapi-plugin-tabular, a conditional logit with household product effects and Bayesian-bootstrap draws, so the customer_choice template is real. The e84_lite conformance fixture replaces the corpus deferral.
  • spec: mapi_spec.bayesinsight.import_bayesinsight maps model_def.json onto mmm.hierarchical_geo - adstock, Hill saturation and pooling with BayesInsight's own priors where they have equivalents, controls, seasonality, trends - and reports every feature without one as an IMPORT_UNSUPPORTED warning (BayesInsightImport.target keeps the mmm.bayesinsight draft). Role detection gains ColumnProfile.monotonic and origin, profiles_from_dataset, draft_schema and guided_lock_issues; GuidedAnswers.format accepts mff; IngestJob gains the ingest caps.
  • docs: the "From a spreadsheet to a Guided draft" guide; the Guided mode concept page, the built-in models and the conformance guide describe the served parts.

  • mmm: mmm.hierarchical_geo declares business_priors and lift_tests calibration.

  • Business priors (ROI or contribution-share ranges at a credibility, a baseline share) become per-channel LogNormal beta_channel priors (and an intercept prior) through a vectorised location/scale bisection over Monte Carlo draws of the other priors, with a prior-predictive preview in business units and the prior mass inside each stated range (mapi_plugin_mmm.calibration; property tests check the round trip).
  • Lift tests add kpi_delta ~ Normal(implied lift, se) over the test window; the extra spend is carried over through the lag tensor and the implied lift is kept as lift_tests__model.
  • response_curves carries the baseline (intercepts and controls), closing the expected KPI.
  • mmm.pymc_marketing takes lift tests through pymc-marketing's add_lift_test_measurements.
  • runner: translate_priors runs (priors.json and the mmm.prior_beliefs / core.prior_predictive plots); every fit writes scorecard.json (the whole check catalog, or the guided template's, each measured or skipped with a reason) and the what-if grid view ui/grids/response_curves.json; forecast and counterfactual score runs write ui/guided/forecast.json and ui/guided/counterfactual.json, and a forecast's fan chart shows the fitted history with the split line. Lift tests are checked against the raw data before a fit.
  • server: POST /v1/guided/prior-translations and GET …/{run_id} (static checks of the beliefs and lift tests against the dataset summary, a pinned translate_priors run, its verified priors.json). The finalizer stores the runner's scorecard on the model version and validates priors.json; spec validation checks a spec's lift tests against the dataset.
  • sdk: FitContext.calibration and FitContext.to_original (the fitted scales); prepare_model_stage for runs that build but do not fit a model; the optional translate_priors plugin hook. sdk-pymc: PyMCModel.prior_overlap_vars keeps cheap prior draws for the scorecard's new plausibility.prior_overlap check.
  • sdk/runner/server: forecasts extend the covariates their payload lacks (ScoreRequest.future_covariates: carry_last, seasonal_naive, zero) from the fitted history (mapi_sdk.future); the server passes the fit dataset as the score run's history input. mapi_server.services.scorecards.promotion_decision derives the promotion gate from a stored model version.
  • plots: mmm.prior_beliefs (each channel's prior ROI or share against the stated range).
  • docs: Guided modelling (the translation math, the lift-test likelihood, how each scorecard check is measured, the what-if, forecast and counterfactual views).
  • server: endpoints and aliases (/v1/endpoints, migration 0008_endpoints_webhooks).
  • PUT /v1/endpoints/{alias} promotes a model version through the promotion gate: the sanity scorecard, the endpoint's evaluation policy (evaluate_then_refit by default, so endpoints serve versions refitted on all data after their holdout evaluation) and input-schema breaks. A warn needs a signed-in project admin's override with a reason (audited endpoint.promote_override; no API token can override); a fail blocks. expected_revision guards concurrent promotions.
  • History, rollback (audited with its reason) and delete; GET …/schema with an example payload, POST …/validate, and GET …/snippets rendered with the server's base URL.
  • POST /v1/endpoints/{alias}/score: inline XrJSON payloads are checked against the endpoint's input contract and the CoordPlan rules, stored as run inputs and decoded only in the sandbox; Prefer: wait, Idempotency-Key (repeat the call to collect a result), score:<alias> tokens, and the per-token rate limit and vCPU budget. Bodies are capped (413) and depth-checked before parsing.
  • server: webhooks (/v1/webhooks).
  • CRUD with the signing secret shown once and stored sealed (AES-GCM under a key derived from the server secret); rotation keeps the old secret signing for 24 hours.
  • A transactional outbox: run.succeeded, run.failed, model.version.ready, score.completed and endpoint.promoted are written in the transaction of the change; emit_tenant_event serves tenant.deletion.completed.
  • Deliveries from the worker on a webhooks procrastinate queue: signed (Mapi-Signature), retried with exponential backoff and jitter, auto-disabled after MAPI_WEBHOOK_AUTO_DISABLE_AFTER consecutive failed events or a 410 (audited), with a delivery log and replays.
  • The SSRF guard: static URL checks, one resolution with every address checked, a pinned connection with the original name as SNI and Host, no redirects, time and size caps; MAPI_WEBHOOK_ALLOW_HTTP and MAPI_WEBHOOK_DEV_ALLOWLIST for development only.
  • Metrics for promotions, endpoint calls, events, delivery tries, SSRF refusals and disabled webhooks; every new operation documents its RFC 7807 problems in OpenAPI.
  • docs: the Endpoints and webhooks guide and the webhook settings.
  • server: saved plots, static rendering and embed links (plan §10.3 items 4-5).
  • POST/GET/DELETE /v1/plots[/{plot_id}]: a model version's plot (after its signed manifest verifies) or a user-authored PlotSpec, sanitized on save and on every read. User-authored plots are recorded with author provenance whatever they claim.
  • POST /v1/plots:render (Prefer: wait, Idempotency-Key) and GET /v1/plots/renders/{run_id}: a render run of one sanitized plot. The API writes its plots.json input and pins each bundle dataset as a plotdata.<n> input. Plotly is refused with MODE_NOT_SUPPORTED unless MAPI_RENDER_PLOTLY_ENABLED.
  • POST/GET/DELETE /v1/plots/{plot_id}/embeds[/{embed_id}]: tokenised links for platform recipes only (EMBED_NOT_ALLOWED otherwise), stored as keyed hashes, with expiry, revocation, view counts and audit events. A PNG fallback is rendered when the caller may start render runs, and copied write-once into the bundles bucket on first use.
  • The public GET /v1/embeds/{token}: no credential is read (cookies and Authorization are dropped), CORS for the plot-sandbox origin only, rate limits per link and per address, every dataset inlined from the verified bundle without decoding it.
  • Migration 0009_plots_embeds: saved_plots and plot_embeds with forced row-level security; the gateway role may read only the embed lookup columns.
  • Every new operation documents its RFC 7807 problems in the OpenAPI document.
  • runner: render runs draw PNG, SVG and PDF with vl-convert (pinned in the new mapi-plots[render] extra) with network access off, refuse anything that could fetch a resource, read plotdata.<n> inputs, apply width/height and cap images at 32 MiB. Plotly needs kaleido and a system Chrome (MODE_NOT_SUPPORTED otherwise; no browser is ever downloaded).
  • plot-sandbox: the embed page /embed/<token>. It fetches the embed view with no credentials under its own CSP (connect-src adds only the API's /v1/embeds/ path), validates and decodes it, honours the link's allowed origins and offers the PNG fallback. The mapi/web image serves it with the API origin filled in at start-up.
  • docs: the "Sharing plots" guide and the new settings in the configuration reference.
  • spec: the sign-up review queue (SignupReview, SignupReviewDecision, the SYBIL_FLAGS catalog), GET /v1/admin/signups, POST /v1/admin/signups/{signup_id}/review and POST /v1/admin/tenants/{tenant_id}/deletion/retry; the auth.signup_flagged, auth.signup_review and tenant.deletion_retry audit actions; the deletion record and platform logs in RETAINED_RECORDS.
  • server: the trust lifecycle (migration 0010_trust_lifecycle).
  • Self-service sign-up (POST /auth/signup, POST /auth/verify-email): single-use e-mail tokens stored as HMACs (24 h), the same answer for new and known addresses, a mailer protocol (SMTP with required TLS, a file outbox read at GET /dev/outbox in the dev profile, console), the disposable-domain blocklist plus configured domains and files, per-address, per-domain and platform-wide sign-up limits, and Sybil heuristics (same address, browser cookie, e-mail, domain burst) that flag sign-ups for the operators' review queue. Verification creates a public tenant, its default project and the admin grant.
  • Identity verification (GET /v1/tenants/{id}/verification, operators' PUT /v1/admin/tenants/{id}/verification, an IdentityVerifier provider protocol with a fake) and cut line A at run admission: uploaded code needs a trusted tier or identity verification (IDENTITY_VERIFICATION_REQUIRED); public tenants run built-ins and declarative specs only.
  • Tenant deletion: a confirmed request suspends the tenant and cancels its runs; after the grace period the orchestrator makes it deleting (tokens revoked, sessions ended, revoker hooks) and a procrastinate job purges the queue, every tenant table, every t/{tenant}/ object prefix (resuming after retention locks), then notifies and writes the tenant.purged tombstone; progress per store; automatic and operator retries.
  • GDPR export: a zip of every tenant record (credentials redacted), dataset objects and bundle manifests, with a short-lived signed link.
  • infra: a Cloud Armor policy on every load-balancer backend (OWASP CRS sqli, xss, lfi, rce and scanner rules in preview-then-enforce, per-IP rate bans on sign-up, /auth/*, embeds and endpoint scoring, Adaptive Protection), and an explicit retention for the control project's _Default log bucket (tenant deletion does not purge logs).
  • docs: Sign-up and abuse controls, Deleting and exporting data, and a Cloud Armor section in Security hardening.
  • integration: the M3 parts work together.
  • Tenant deletion revokes the tenant's embed links and disables its webhooks when it starts, purges every M3 table (endpoints and their history, webhooks, events and deliveries, saved plots, embeds, column profiles) and then delivers a signed tenant.deletion.completed to the webhooks that subscribed to it, through the same SSRF-guarded sender as every delivery (mapi_server.services.tenant_hooks).
  • Webhook events come from the finalizer's terminal transitions (run.*, model.version.ready with the runner's scorecard), promotions and rollbacks (endpoint.promoted), score runs (score.completed) and deletions.
  • Endpoint promotion gates on the scorecard the runner measured at fit (services.scorecards.promotion_decision, the single gate derivation).
  • Sign-up workspaces are public tier: built-ins and declarative specs only, on executors with enforced egress and DNS (cut line A).
  • server: POST /v1/guided/requests:compile turns a what-if scenario, a forecast or an intervention preset into its ScoreRequest, checked against the model version's score metadata ("Run exact").
  • runner: score runs read an endpoint call's inline XrJSON payloads (inline.data, inline.scenario) with the wire-format caps and the model's dim types.
  • mmm: mmm.bayesinsight, a BayesInsight model_def.json as a built-in model (Q8): Normal, log-normal, Poisson, negative binomial and zero-inflated likelihoods; coefficient and intercept priors in the KPI's own units; Hill, linear and S-shaped curves before delayed, geometric, Weibull or no carry-over; enforced sign constraints; fixed and partially pooled effects per row id (new levels pooled); Fourier seasons and local spline trends. Contributions sum to the expected KPI (a log-mean Divisia split for log links); lift tests observe the incremental KPI under the model's own likelihood. It passes the contract kit; mapi_plugin_mmm.bayesinsight_examples simulates a project with known parameters.
  • spec: mapi_spec.bayesinsight_model types model_def.json as the plugin runs it (the mmm.bayesinsight settings); import_bayesinsight(target="mmm.bayesinsight") now drafts a runnable spec - every row id a panel dim, the KPI bound to y, lagged media - falling back to BayesInsight's defaults with IMPORT_UNSUPPORTED warnings (and info issues for faithful but surprising behaviour).
  • mmm: business-prior translation for mmm.pymc_marketing (1.1.0): ROI and share ranges become per-channel log-normal saturation_beta priors and a baseline share an intercept prior (narrowing the control and seasonality priors when needed), solved on prior draws of pymc-marketing's own model; settings.priors hands them to pymc-marketing's model_config; a translate_priors hook previews them and a fit without a translation run translates them itself.
  • sdk: categorical holdout metrics - accuracy, accuracy_naive, accuracy_lift, log_score and brier - for a text target scored as one probability per category.
  • spec/runner: the holdout.hit_rate scorecard check (the holdout hit rate against always guessing the most popular product, in plain words) for the customer_choice template.
  • conformance: the guided_mmm fixture (the geo_mmm Guided draft with business priors and a lift test, true to its simulation); e84_lite evaluates the categorical metrics; the dedicated BayesInsight import ingests, validates and fits end to end.
  • docs: the built-in models reference covers mmm.bayesinsight and every plugin's calibration; Guided modelling covers business priors for the pymc-marketing adapter and the BayesInsight lift likelihood.
  • docs: the v0.4 site. A new landing page (both personas, how it works, the Guided quick path, the v0.4 feature grid and the numbers measured in the M3 verification), "Why modeling-api?" (compared with notebooks and ad-hoc apps, MMM vendors and libraries, and generic model serving, with when to use something else) and "Use cases"; a Tutorials section with new tutorials "Connect Google Sheets" (an Apps Script that writes an endpoint's forecast into a sheet, run in the tests against stand-ins for Apps Script), "Embed a chart in Notion, Slack or BI" and "Import a BayesInsight model" (import, check and a local fit that recovers the simulated parameters), and "Receive webhooks" as a tutorial with a tested Flask receiver; the roadmap with v0.4 released and v0.5 (expert plugins, MCP, optimise jobs) next; the security model and architecture pages for v0.4; make docs-screenshots-tutorials.
  • cli: mapi tokens create --scope lists the endpoint, endpoints:promote and webhooks:manage scopes.
  • dev: make outbox (dev.py outbox) prints the dev mailer's newest e-mails and verification links; make dev sets MAPI_OUTBOX_DIR and the plot sandbox's VITE_API_URL; compose keeps the outbox on a volume. make api-types and make snapshots regenerate the web API types and the snapshots.
  • web: Guided mode, the task flow for non-technical analysts (plan §11 Guided 1-7), replacing the placeholder page.
  • "What do you want to learn?": the templates as cards, and the analyses in progress. A session is a stored spec (metadata.labels.guided); every step has its own URL (/guided/<spec>/<step>) and is rebuilt from server state, so it can be resumed or shared.
  • Your data: the upload (CSV, Parquet, MFF; Google Meridian tables recognised) is profiled in the browser and only the column profiles go to role detection; questions with suggested answers, confidence and reasons; a live preview with a sparkline per chosen series; plain validation messages; then the dataset, the signed upload, the sandboxed ingest with the draft's mapping and the spec.
  • What you believe: ROI or contribution-share ranges per channel with a confidence, the baseline share and lift tests, a translate_priors preview (prior ROI/share distributions and a KPI fan in the plot sandbox) and the translated priors applied when they match the beliefs saved.
  • Fit the model: plain-language phases, progress, time left and cancel, live over SSE.
  • Results: headline numbers, ROI and share per channel with 90% ranges, the fit and response-curve plots, and the sanity scorecard grouped by question with what to do.
  • What if…: per-channel spend sliders answered instantly from the per-draw response grid (a TypeScript port of mapi_spec.whatif.evaluate_scenario, checked against golden cases the spec tests export), "Run exact" next to the instant answer, scenario presets (channel off, budget × k) and a forecast fan chart with its split line.
  • Share: promotion to an endpoint with the gate explained (a warning needs a project admin's reason), an embed link for a platform chart, and the way to the Integrations hub.
  • "View as YAML" on every step (read-only Monaco) and "Edit in Expert" with "Back to Guided mode"; settings Expert mode changed are kept and marked "advanced settings applied". The Guided/Expert choice is remembered per user, and in a project locked to Guided mode members below admin see Guided screens only.
  • Mock-mode handlers for every Guided call (src/mocks/guided.ts), Vitest unit and route tests and the Playwright smoke spec e2e/guided.spec.ts (the whole flow, the lock, the persona and a 375 px phone).
  • spec: tests/snapshots/whatif_golden.json, golden what-if cases shared with the web app (make snapshots regenerates it).
  • docs: "Your first analysis in Guided mode", a step-by-step tutorial with screenshots (make docs-screenshots-guided recaptures them from mock mode).
  • client: the v0.4 Python client. Every operation of ENDPOINTS has a request builder (the coverage test now checks the whole v0.4 table), sync and async.
  • api.guided (templates, role detection, drafts, the BayesInsight import, "Run exact" compilation, preview_priors), api.endpoints (promote, history, rollback, schema, validate, keyed Prefer: wait scoring, snippets), api.webhooks (secrets returned once; deliveries, replay, rotation), saved plots, renders (waited for, CRC32C-checked download) and embeds on api.plots, api.signup/api.verify_email (sent without credentials), verification, deletion and the GDPR export (size-checked atomic download) on api.tenants, and the sign-up review queue on api.admin.
  • Client().endpoint(alias) (RemoteEndpoint): schema(), validate(ds) and score(ds) -> xr.Dataset with inline XrJSON (mapi_client.xrjson, the SDK wire format); the generated Python snippet runs as-is.
  • mapi_client.webhooks.verify(headers, body, secret) for receivers.
  • mapi_client.atlas.RemoteModel: Atlas's model interface, structurally (Atlas is never imported at import time): KPI at the mean or as draws, batching along an independent dim into one score request, an LRU cache, backoff on RATE_LIMITED; register_with_atlas(); the atlas extra; make test-atlas runs the tests with Atlas installed in a throwaway overlay.
  • cli: mapi endpoints ls|get|history|promote|rollback|schema|score|snippets, mapi webhooks ls|get|create|update|rotate|deliveries|replay|rm, mapi plots render|embed|embeds|revoke, mapi guided templates|detect|draft (CSV files are profiled locally), mapi priors preview, mapi tenant status|export|delete, mapi signup and mapi verify-email. Promotions, rollbacks, rotations, deletions and revocations ask first; webhook secrets and embed links are printed once, alone on stdout.
  • docs: the v0.4 sections of the Python client and command-line guides, "Optimise a budget with Atlas" and "Receiving webhooks" (FastAPI and Flask), with offline, tested snippets.

  • integration (web): Guided mode and the Integrations hub work as one.

  • The Share step links into the hub: the promoted endpoint's snippets, a score:<alias> token, the chart link under Plots and links, and the hub itself (typed routes). A project locked to Guided mode keeps Integrations in the navigation.
  • Both use the same endpoint and saved-plot queries and the same promotion-gate preview (the port of promotion_gate), so a promotion or link made in one shows in the other at once; in mock mode they share one endpoint, history, plot and embed state.
  • Every snippet the app shows and copies is the server's (mapi_spec.snippets), the notebook cell included; the mock's fixture endpoint, example call and snippets are generated from mapi_spec (apps/web/scripts/gen_integration_fixtures.py, checked by make web-check and CI, rewritten by make snapshots), and a test checks each tab against them.
  • spec: a notebook snippet language: a Jupyter or marimo cell that scores the endpoint with Client().endpoint(alias).score(...) and shows an xarray.Dataset; the client's tests run it as generated.

  • e2e: the M3 exit criteria through the running stack. tests/e2e/test_m3_guided_stack.py (in make e2e) follows one analyst from sign-up and e-mail verification through the geo_mmm Guided flow (CSV and MFF), business priors and a lift test, the scorecard and its gate, what-if vs "Run exact", forecast and channel-off, an endpoint scored with a score:<alias> token, signed webhooks to a local HTTPS receiver with a replay, an embed link and its revocation, Atlas optimising a budget through RemoteModel against the live endpoint, the GDPR export and a tenant purge checked in Postgres and the GCS shim. apps/web/e2e/m3-guided.e2e.spec.ts is the timed "unaided tester" script (upload → report → scenario) and the Integrations hub flow. make e2e overlays Atlas from ATLAS_DIR when it is there.

  • dev: the dev profile trusts a local webhook receiver: a throwaway CA and certificate for 127.0.0.1/localhost in $MAPI_DATA_DIR/dev/webhook-tls/, with MAPI_WEBHOOK_CA_FILE and MAPI_WEBHOOK_DEV_ALLOWLIST set unless given.
  • gcs-shim: --bundle-retention-days (default $MAPI_BUNDLE_RETENTION_DAYS) chooses the bundles bucket's locked retention when a root is created (0: none), so a test stack's tenant deletions can finish.
  • web: "Fit again, more carefully" on the Guided report when a convergence check warns or fails (twice the tuning and draws and a higher target_accept, up to a ceiling); a blocked Share step links to it.
  • spec: mapi_spec.guided.freq_plain: role detection explains a detected date frequency in words ("weekly, on Sundays").
  • docs: M3 verification: what was verified of the M3 exit criteria, the timings, the defects found, and the protocol and observation sheet for the five non-technical testers.

Changed

  • spec: the geo_mmm template (1.1.0) fits with 1000 tuning steps and draws and target_accept 0.95: the 500-draw fit failed the scorecard's convergence checks on two years of weekly data, which Guided users cannot act on, so the analysis could never go live.
  • web: in What you believe, channels start without a belief (unticked); ticking one starts from the sensible range. Untouched channels no longer become ROI beliefs.
  • server: GET /v1/embeds/{token} answers Cache-Control: private, no-cache (was public, max-age=60), so a revoked link stops rendering on the next load.
  • spec: mapi_spec.api.ENDPOINTS is the v0.4 surface (M1 + M2 + M3, every row served); V03_ENDPOINTS keeps the v0.3 table and ALL_ENDPOINTS is an alias of ENDPOINTS.
  • server: the M3 migrations run in one chain after 0006_executor_disable: 0007_guided, 0008_endpoints_webhooks, 0009_plots_embeds, 0010_trust_lifecycle.
  • spec: the customer_choice template (1.1.0) targets the built-in choice.multinomial_logit; wide column labels drop the question's keywords (price_cola → cola); a draft refuses answers that would give two columns the same dataset name.
  • spec: the customer_choice template (1.2.0) evaluates the categorical metrics and runs holdout.hit_rate; BayesInsightImport(target="mmm.bayesinsight") drafts the shipped plugin's settings (variables maps analytic names to variables; the KPI is y) instead of keeping model_def verbatim.
  • server: POST /v1/guided/import/bayesinsight validates mmm.bayesinsight drafts against the catalog too.
  • conformance: the corpus runner compares fit-time and evaluate-run metrics only when it rebuilt the holdout (a last_per_group split is scored in sample).

Fixed

  • cli: mapi webhooks update ID [--enable|--disable] [-e EVENT ...] [--url URL] [--description TEXT]. A webhook created with --inactive, or disabled automatically after repeated failures, could only be switched back on in the browser; --enable does it from the command line and resets the failure count.
  • docs: the Guided tutorial's report and scorecard screenshots contradicted each other (media drove 23% of results, yet "about 80%" would happen without media). The mock-mode scorecard now reads its baseline, largest-channel and best-return checks from the same response grid as the report, and the screenshots were recaptured.
  • server: "Run exact" failed on every live stack: POST /v1/guided/requests:compile left what-ifs and presets over the fit window without data. The compiled request now names the fit dataset version, and a score request without data, scenario or horizon is refused with 422 SCHEMA_INVALID before a run starts.
  • web: the Guided report showed MAPE in percent as a fraction ("83 %" for 0.83 %); it reads the scorecard's measured value. The beliefs preview no longer prints impossible baseline shares ("between -164 % and 271 %"); Share a chart offers result charts before the sampler's diagnostics.
  • plots: prediction plots name the predicted quantity as the dataset does (revenue, not y_hat).
  • client: datasets.upload accepts an mff mapping for a .csv MFF table.
  • server: an unresolvable webhook host is explained in plain words (no gaierror).
  • web: the New token dialog's budget period showed "pe" instead of "per day"/"per month": native selects no longer shrink below their width in a flex row (filters, the cap window, the intervention editor and the upload mapper had the same risk).
  • spec: index dims extend contiguously (their coordinates have ordinals), so scoring an index dim with an extend policy no longer fails with NON_CONTIGUOUS_EXTENSION.
  • sdk: cell-level splits treat an empty text target as missing and hold text targets out with '', so a choice panel's chosen product can be held out.
  • runner: the holdout.mape scorecard check read mapi_sdk's MAPE, which is in percent, against thresholds in fractions, so a 1.4% holdout error showed as 140% and blocked the promotion of almost every model; percent metrics are now divided by 100 first.
  • runner: inline endpoint payloads named like the endpoint's input contract (canonical names such as time) failed to score when the dataset's own name differed (week); the score run renames them to the dataset's names before typing coordinates. The snapshot's documentation says which names a payload uses.
  • spec: an endpoint's example call (GET …/schema, the snippets, mapi endpoints score --example) asked quantile models for a mean they cannot serve; it now asks for the representation's default summary.

Fixed (M3 review: Guided modeling)

  • spec/server/web: the promotion gate let through an evaluate_then_refit version that was never evaluated on a holdout (a spec without data.split skips every holdout check and its scorecard can still pass). Such a version now needs an administrator override with the new gate reason NOT_EVALUATED, as does a guided version whose template's scorecard checks did not all run (Q13). The web app's gate preview follows.
  • mmm: a contribution-share belief for a channel with no spend in the fit window translated to an absurd prior (LogNormal(mu≈692), so any new spend on the channel predicted ~1e300) with no issue. Both translators (mmm.hierarchical_geo, mmm.pymc_marketing) now refuse it with an error, as they do for an ROI belief, and refuse any solved prior that is not finite or has |mu| > 50, keeping the channel's default.
  • mmm/runner: a response grid whose spacing skipped multiplier 1 (grid.max_multiplier: 3, grid.points: 20, or a maximum below 1) lost its what-if sliders, and the scorecard's ROI, share and baseline checks were read at the nearest point (1.05x or 0.5x spend). The grid now always holds 1 (inserted when the spacing skips it; max_multiplier must be at least 1), the curves are read at exactly 1, and a what-if grid the view contract refuses is reported as VIEW_INVALID instead of VIEW_TOO_LARGE.
  • runner: a model with more than 256 geos lost its whole what-if grid (the per-geo view failed its geo cap and the national grid was dropped with it); it now gets the national grid (mapi_spec.views.MAX_GRID_GEOS).
  • server: POST /v1/guided/requests:compile answered 404 for an endpoint:// model target, and POST /v1/specs:validate reported a score request aimed at an endpoint as REF_UNRESOLVED ("endpoint aliases arrive in v0.4"). An endpoint ref now resolves to the version the endpoint serves.
  • runner: a dense ingest (and the MFF pivot) built every combination of the dim labels with no cap, so a 2 MB CSV whose keys rarely repeat asked for ~80 GB and the run was OOM-killed. The cube is now checked before it is built (at most 250 million values, and within the class's scratch budget) and refused as PAYLOAD_TOO_LARGE with a hint.
  • spec/server/runner: static renders had no pixel cap, so scale: 8 with a 4096 x 4096 size (or a plot that sets its own huge size) asked vl-convert for a ~4 GiB raster and the run was OOM-killed. A PNG or PDF now holds at most 50 megapixels (mapi_spec.sharing.MAX_RENDER_PIXELS): POST /v1/plots:render refuses a bigger request with 413 PAYLOAD_TOO_LARGE, and the runner checks the laid-out size (composite views included) before it rasterises.
  • spec/runner: the data.history check counted periods (warn below 104, fail below 52) whatever the data's frequency, so 105 days of daily data passed as "enough to learn seasonality". It now measures years (history_years: periods over the periods in a year at the time dim's frequency, or the one its dates follow; warn below 2 years, fail below 1; the weekly_forecast template fails below 0.5).
  • mmm: a lift test cut by the fit window (one that straddles the end of an evaluate_then_refit evaluation fit's training periods, or starts before the data) put all of its spend into the periods left and compared them with the whole test's KPI change, biasing the evaluation fit and the holdout scorecard. Such a test is now skipped and logged, in mmm.hierarchical_geo, mmm.bayesinsight and mmm.pymc_marketing.
  • runner: dates with a UTC offset (2024-01-07T00:00:00+01:00, common in BI exports) or a tz-aware Parquet timestamp became time zone-aware labels that the ingest then refused as "not dates" (and offsets that change with daylight saving time failed outright). They are now read as the local date and time they name, with an info issue saying the offsets were dropped.
  • sdk: future_covariates: seasonal_naive aligned the last season by position, so a horizon that started after a gap (or a payload of later periods only) shifted the seasonal pattern, and a missing value in the last season was copied forward as NaN. It now takes the value whole seasons before each horizon date, stepping back past missing values, and carries the last value forward (with a warning) only where no season has one.
  • plots/runner: a Guided geo MMM's fit chart titled its panels with the spec's internal dim name (geo) instead of the analyst's column (market), and faceted response curves said channel whatever the column was called. Recipes now title facets with the dataset's name for the dim (RecipeInput.dim_label, from the bundle's /binding), and the fit's holdout prediction tree carries the binding.

Fixed (M3 review: integrations and trust)

  • server: every per-address control saw the proxy's address, not the caller's, so the sign-up, verification and embed-view limits and the Sybil same_ip check applied to the whole platform behind a load balancer. New MAPI_TRUSTED_PROXIES (addresses or CIDR ranges): a request from a trusted proxy takes the right-most untrusted X-Forwarded-For hop as the client; any other peer's header is ignored. The multi-tenant profile refuses to start without it (none when clients connect directly); Terraform sets Cloud Run's and the load balancer's hops and make dev sets none for its multi-tenant stack.
  • server: a promoter, an endpoints:promote token included, could switch off the schema-change and train-only gates by sending a laxer evaluation_policy with the promotion. Relaxing the policy (schema_change toward allow, fit_mode to train_only) now needs a signed-in endpoint admin, like an override (403 FORBIDDEN otherwise, audited as a denial), and the promotion's audit event records the policy before and after (mapi_spec.endpoints.policy_relaxations).
  • server: the per-domain (25 a day) and platform (500 an hour) sign-up caps counted unverified sign-ups, so made-up addresses could lock a whole e-mail domain, or everyone, out of sign-up. They now count verified sign-ups only (the per-address limit still counts every request), and large free-mail domains (MAPI_SIGNUP_SHARED_EMAIL_DOMAINS, default mapi_spec.trust.SHARED_EMAIL_DOMAINS) have no per-domain cap and never trip the domain_burst Sybil flag.
  • server: the plan's draw cap (Q2: 300, 500 on paid tiers) was enforced only by the Python client, so any caller could ask a model or endpoint for 10,000 draws. Score requests asking for more than the tenant tier's MAX_SCORE_DRAWS (public 300, other tiers 500) are refused with QUOTA_EXCEEDED at /output/draws, and the score job carries the cap (ScoreJob.max_draws: score runs now store a {request, max_draws} payload) so the runner enforces it too.
  • server: a score:<alias> token could score any dataset of the project by dataset:// reference (and probe which exist through …/validate) without dataset read access. Callers without it now send inline data or name the endpoint's own fit dataset; any other reference, existing or not, gets the same 403 FORBIDDEN.
  • server: a GDPR export whose build process died stayed running forever and blocked every later export of the tenant. A build now holds a lease (MAPI_EXPORT_BUILD_TIMEOUT_S, after which it fails, plus five minutes); a running export past its lease is built once more, then failed, and only the build holding the lease may mark it ready. Metadata rows are capped at MAPI_EXPORT_MAX_METADATA_BYTES (tables cut short are listed under skipped), so the in-memory archive stays bounded.
  • server: an export still building when a tenant purge started wrote the whole archive after the purge, where nothing would ever delete it. The deletion's start cancels the tenant's queued and running exports (revoked.exports in the audit event), the queue store waits for a cancelled build to end (or its lease to pass), and a build that lost its lease never uploads and deletes an archive it uploaded after losing it.
  • server: a purge went on after MAPI_DELETION_RUN_STOP_TIMEOUT_S while cancelled runs that never stopped still held signed upload policies under t/{tenant}/, so they could write objects after the object store was purged. The queue store now also waits until those runs' signed links have expired (attempt start + run timeout + 30 minutes) and says until when.
  • server, web, client: GDPR exports could not be listed, and Settings > Data found the last export only through the browser's localStorage, so another admin or browser could neither see nor download an export that was running or finished (and could not start another one while it ran). New GET /v1/tenants/{tenant_id}/exports (newest first, paged, tenant admins); Settings > Data shows the newest export from it, and the Python client has tenants.exports(tenant_id).
  • server: the storage_bytes quota left out model bundles although the docs count them; a tenant's storage now adds the size of every model version's stored objects.
  • server: the per-tier egress cap (egress_bytes_per_month, 5 GiB on the free tier) was defined but never measured. Every signed download link the API mints (dataset and model artifacts, ui/ views, score and render outputs) and every public embed view (its inline data and PNG link) is now charged to the tenant's monthly egress, which GET /v1/usage reports; past the cap the link is refused with 429 QUOTA_EXCEEDED (with Retry-After until the month resets). GDPR exports are never charged; a purge deletes the counter.
  • server: the embeds' "platform recipes only" rule trusted the provenance in plots.json, which the worker writes while it runs plugin code, so uploaded code could label any spec core.* and get it onto public embed links. Plots of model versions fitted with uploaded code (an overlay in their lineage) are saved with plugin provenance, are never embeddable, and existing links stop serving them.
  • server, runner, spec: endpoint and model scores answered only with signed links to zarr and Arrow files, so the Google Sheets card wrote the word "done" into A1 and the curl, R and TypeScript snippets printed links nobody could open without those libraries. Score runs now write summary.json (the requested summary - mean and 90% HDI by default - as plain tables: coordinate columns, then one column per statistic, at most 100,000 cells), and ScoreCallResult.summary carries it inline once the run succeeded. The Apps Script builds its payload from an Input sheet (long format) when there is one and writes the summary tables to a Result sheet; the other snippets print the tables.
  • web, plot-sandbox, docs: embed links were advertised for Slack, but Slack does not frame pages and the embed page had no unfurl tags, so a pasted link showed a bare "Shared plot". The page now carries static Open Graph tags (a "Shared chart" card, never the plot's name or the token), and the UI and guides recommend posting the rendered PNG to Slack instead of promising a live chart there.
  • docs: webhooks do not attach rendered plots, a deliberate deviation from the plan (bodies carry ids and states only, so no chart or signed link reaches a third-party receiver); the webhooks guide says so and shows how a receiver renders a chart with POST /v1/plots:render when model.version.ready arrives.

Fixed (M3 review: web)

  • web: a new self-service sign-up landed in Expert mode, with no way into Guided mode but the header's Mode switch. Until a user chooses, a public tier workspace now starts in Guided mode (other workspaces and platform operators in Expert; the browser's stored v2 default is forgotten, per-user choices are kept), "Sign in" after the e-mail link opens /guided, / opens the Guided start page in Guided mode, and Expert navigation (g g) and Home (Start a guided analysis) lead to Guided.
  • web: the admin console's Promotions tab was a placeholder. It lists the endpoint promotions that needed attention across tenants (gate overrides with their reason and failing checks, blocked promotions and those refused for want of an override), from the audit log. There is still no queue: the gate decides each promotion at once.
  • web: a project could be locked to Guided mode only through the API. Project admins now have Lock … to Guided mode under Settings > Mode (PATCH …/projects/{project_id}, audited as before).
  • web: a webhook's Send test button only replayed the newest delivery (and was disabled until one existed). It is now Resend newest, and the empty delivery log says how a new webhook gets its first delivery.
  • web: the beliefs preview charted every channel, the unticked ones at the model's wide default prior, so they looked like the analyst's beliefs (ROI near 100, media shares adding up past 100 %) and squashed the ranges actually stated. The charts show the ticked channels only and name the others ("No view on …: … learns them from the data"). The mock engine previews every channel, as the server does.
  • spec, web: a "Results without any media" belief stated without any channel belief was silently dropped (BusinessPriors.channels needed at least one entry, and the page then saved "no business priors"). BusinessPriors now needs at least one belief of either kind (channels defaults to {}; a baseline-only block validates, and the "largest shares" warning no longer fires without channel shares), both MMM plugins translate a baseline alone, and the Guided page previews and saves it.
  • plot-sandbox: in dark mode, rule and text marks without a colour of their own were drawn black on the dark card (Vega-Lite's default): the beliefs preview's 90% line, the forecast's split line and its "Forecast starts" label. The theme colours rules in muted ink and text and ticks in body ink, so a median tick also stands apart from its bar.
  • web: the Guided forecast panel always said "weeks" (daily data too) and accepted horizons up to the template's limit (520), past the horizon a weekly forecast was fitted for, which then failed. It names the data's period (days, weeks, months) and stops at the fitted horizon (max_extension), saying a longer one needs a new fit.
  • web: Guided fan charts (forecast, prior predictive) started their y axis at zero, which squeezed the bands into slivers; the axis now fits the data (zero: false).
  • web: the endpoint page said "Promoted by usr_01…" and its history listed user ids, and saved plots said "of mdl_01…". People are named from the project's members (the id stays in the tooltip, and an unknown id is shown as is), and saved plots name their model.
  • web: sign-up asked people to accept the terms of service and the acceptable-use policy without a way to read them. Both are links (new tab) to VITE_TERMS_URL and VITE_ACCEPTABLE_USE_URL, by default the plain-language summaries the app now publishes at /terms and /acceptable-use.
  • web: the Guided upload box described weekly marketing data by market (and the MFF and Meridian presets) for every template, Customer choice and Demand by store included. It describes the rows each template needs and names the presets for the marketing mix only.
  • web: adding an experiment showed a red error under every field at once, its example values ("-120000") read as prefilled data, and nothing said why Preview and Save were disabled. A field's message now waits until the field is left, the examples read "e.g. -120,000", and a summary beside the actions says what each unfinished experiment still needs.

Fixed (integration)

  • docs: the Google Sheets tutorial showed the Apps Script from before score answers carried their summary (it wrote done into A1). Its snippet is the server's current script again (the summary tables in a Result sheet, an optional Input sheet), the tutorial says what the sheet shows, and the Node test checks the Result sheet.
  • spec: the example of PlotEmbedCreated (in the OpenAPI document) used an /e/<token> link; the server answers with /embed/<token>.
  • docs: M3 verification and the landing page give the unaided-tester timing measured on the integrated branch, with the script starting at / (135 s, was 137 s from /guided).

Known gaps

  • The M3 exit criterion is not met yet. Five non-technical testers must go from upload to report to scenario unaided in under 30 minutes. The protocol and observation sheet are written (the M3 verification page in the operations docs), and a script clicks through the same flow in 135 s of system time, but no tester session has run.
  • Nothing has run against real Google Cloud or a Docker daemon. The Cloud Armor policy, the trusted-proxy ranges Terraform sets for Cloud Run behind the load balancer, the Cloud Run executor, the docker and docker-gvisor executors and the compose stack are checked with fakes, mocked providers and terraform validate only. After the first deploy, check that sign-ups from two networks get different fingerprints.
  • Static rendering covers Vega-Lite only out of the box. Plotly needs kaleido and a system Chrome, so POST /v1/plots:render refuses it with MODE_NOT_SUPPORTED unless MAPI_RENDER_PLOTLY_ENABLED, and that path has not run in an image. Webhook bodies never carry charts (a deliberate deviation), and Slack shows one generic card for every embed link.
  • Webhooks have no synthetic test event: Resend newest replays the newest delivery, and a new webhook gets its first delivery from a real event.
  • The Google Sheets Apps Scripts and the embed page inside Notion, Looker Studio and Power BI are tested only against stand-ins, never in the real tools.
  • Egress is estimated from object size when a link is minted, not measured as bytes served; a caller that polls a score result is charged again on each poll. The GDPR export archive is still built in memory (bounded by two settings) because the object store has no streaming upload.
  • There is no combined cap across all free-tier tenants beyond the global quota scope; the Python client's Atlas RemoteModel caps draws at 300 on every tier (the server allows 500 on paid tiers).
  • The Guided/Expert choice is stored per browser, not per user on the server; there is no mapi command to lock a project to Guided mode (the web switch and the API do it).
  • In the geo_mmm template, the default priors of channels without a stated belief can still imply media shares above 100% in total; the beliefs preview no longer presents them as the analyst's beliefs.
  • The /terms and /acceptable-use pages are plain-language drafts, not reviewed legal text. A public installation should publish its own and set VITE_TERMS_URL and VITE_ACCEPTABLE_USE_URL.
  • Some screenshots predate the M3 review's wording (the Guided beliefs page, the Integrations hub pages including the Sheets card, Settings).
  • A few tests are timing-sensitive under a fully loaded laptop (the 100-fit Cloud Run load test, the Postgres-restart chaos test, Terraform's provider plugin start-up); each passes when run again.

[0.3.0] — M2: cloud & multi-tenant core — 2026-10-15

The platform is now multi-tenant and ready for Google Cloud. Many teams can share one deployment: each tenant has its own projects, members, API tokens, quotas, cost and audit trail, and PostgreSQL row-level security keeps their data apart. Runs can execute as Cloud Run Jobs, and Terraform describes every Google Cloud project. Nothing has been applied to real Google Cloud yet: cloud code is tested against fakes, and the Terraform is validated and tested against mocked providers. In summary:

  • tenancy and auth: tenants with trust tiers and data residency, projects with ordered roles (viewer < editor < runner < admin), browser sign-in through a backend-for-frontend OIDC flow (with a dev identity provider for local work), mapi login for the CLI, and checksummed, scoped, project-bound API tokens.
  • isolation: forced row-level security on every tenant table. The API runs as roles without the bypass (mapi_app for tenant data, mapi_gateway for sign-in and operator lookups); only the orchestrator holds the platform role. The audit log and the cost ledger are append-only.
  • quotas and cost: token, tenant and global quotas with weighted fair-share leasing and 429 back-off; an integer-micros cost ledger with per-run cost, usage reports and cost by tenant for operators; a global spend cap.
  • operations: kill switches (pause or cancel uploaded code, the spend cap), executors that operators can disable, a shared-tier gate that needs recorded conformance and security-suite evidence, audit events for every change, Prometheus metrics, dashboards, alerts and runbooks, plus a security probe suite and chaos tests.
  • cloud: a Cloud Run Jobs executor (orphan adoption, fan-out, 429 handling), Pub/Sub push events, a Cloud KMS bundle signer, Terraform for the control, data and exec projects, and deploy and rollback scripts.
  • web, CLI and client: tenant switcher, members, tokens, usage and an operator console (tenants, quotas, cost, kill switches, executors, audit); mapi admin, mapi tokens and mapi usage; client methods for every new endpoint.
  • docs: Administration and Authentication guides with tested walkthroughs, a security model, deployment to Google Cloud with the [verify M0] checklist, and reference tables generated from the code.

Every workspace package and both web apps are versioned 0.3.0. Contract v0.1 gains only additive changes. Upgrading from 0.2 runs migrations 0003 to 0006, which adopt existing rows into a platform tenant.

Added

  • spec: the M2 contracts. They cover tenants, trust tiers and placement, ordered project roles (viewer < editor < runner < admin) with one permission matrix, and AuthContext as the single authorization decision. They also add checksummed API tokens stored as HMACs, scopes, BFF OIDC helpers (PKCE, cookies, safe return_to) and Pub/Sub push claims; token/tenant/global quotas with fair-share leasing and 429 back-off; integer-micros cost pricing and the ledger (RunCost moves to mapi_spec.cost); audit events; kill switches and executor health; and the 12-case executor conformance suite as data. mapi_spec.api gains the 40 M2 endpoints and ROUTE_ACCESS, the scope and permission of every operation (all 44 M1 ones included). Contract v0.1 gains only additive changes.
  • server: authentication on every route.
  • Bearer API tokens, browser sessions through an OIDC backend-for-frontend (authorization code + PKCE, HttpOnly SameSite=Strict cookie, CSRF token), and ID tokens of the mapi CLI (MAPI_OIDC_CLI_CLIENT_IDS).
  • A credential-less local administrator in the single-tenant profile, and a dev profile with a built-in identity provider and seeded accounts.
  • Tenants, projects, members and invitations.
  • Row-level security: FORCE ROW LEVEL SECURITY on every tenant table, set_config(…, true) per transaction, failing closed, with separate app and platform roles.
  • Quotas and rate limits at admission and at dispatch.
  • A cost ledger written whenever an attempt ends, for every executor.
  • The audit log, the operator endpoints (tenants, quota policies, kill switches, executor health, audit) and usage reports.
  • server: the cloudrun-jobs executor for a fixed inventory of 3 bases × S/M/L.
  • Env-only overrides, orphan adoption by nonce, and a jobs.run token bucket with a shared 429 cool-down.
  • Exit-code classification (OOM, timeout, partial) and taskCount fan-out plumbing.
  • Tested against a deterministic FakeCloudRun.
  • Pub/Sub push authentication: a Google-signed OIDC token, a JWKS cache, a subscription allow-list and decoding of the log entry.
  • A Cloud KMS manifest signer, alongside the local one.
  • MAPI_OBJECT_STORE=gcs: Cloud Storage with keyless IAM signBlob URLs and integrity checks on every write.
  • server: observability.
  • structlog JSON logs carrying the trace and span ids.
  • OpenTelemetry spans, with an optional OTLP exporter.
  • The Prometheus metric set at GET /metrics, behind a bearer (MAPI_METRICS_TOKEN, which the multi-tenant profile requires), plus the worker's own endpoint (MAPI_WORKER_METRICS_PORT).
  • Request ids on every response.
  • tests: the executor conformance suite, run against fake Cloud Run and local-process. It also checks that every attempt that ran is in the cost ledger.
  • A 100-fit load test on the fake backend (make load-test).
  • Chaos tests (make chaos).
  • A route-table test that every non-public route needs credentials and that no M1 or M2 route serves another tenant's project.
  • End-to-end M2 suites against a running dev stack, run by make e2e. Two tenants are created through the dev IdP. The suites cover sign-in, cross-tenant refusal at the API and by row-level security in Postgres, token scopes, QUOTA_EXCEEDED, the spend-cap kill switch held at dispatch, per-run cost and the audit trail (tests/e2e/test_m2_tenancy_stack.py). They also include a browser flow: sign-in, tenant switch, fit with metered cost, tokens, and the admin kill switch (apps/web/e2e/m2-flow.e2e.spec.ts).
  • An opt-in burst of concurrent real fits, against the dev stack or a deployment (MAPI_E2E_BURST_RUNS).
  • A nightly workflow: chaos, executor conformance, both load-test modes, and the security suite on local-process and inside gVisor.
  • infra: Terraform modules control, exec, data, build, perimeter and stack, plus the dev, staging and prod environments.
  • The exec projects get zero egress, a restricted VIP, a DNS response policy and VPC-SC.
  • Cloud SQL with three login roles, generated secrets written only through write-only arguments, a Pub/Sub push to an internal-only events service, and KMS.
  • Deploy scripts (deploy/gcp) and a manual Deploy workflow.
  • make tf-validate runs fmt, validate and the mocked-provider module tests; nothing is ever applied.
  • ops and security: Grafana and Cloud Monitoring dashboards, alert rules and runbooks (ops/). A sandbox-escape probe suite with an expected-outcome matrix per isolation level, and a deliberately malicious plugin that is never installed (security/, run with make security-suite).
  • web: sign-in, a tenant and project switcher, and pages for API tokens, members, and usage and cost. Per-run cost, and plain-language quota and permission errors with a Retry-After countdown. An operator console for tenants, quota policies, kill switches, executor health and the audit log.
  • client and cli:
  • mapi login (browser or device code, refreshed and stored with mode 0600, or --with-token), logout, whoami and use, plus X-Mapi-Project selection.
  • Token, usage and admin commands and resources.
  • 429 handling with RateLimited/QuotaExceeded and Retry-After.
  • --discovery-url for providers whose discovery document lives elsewhere (the dev IdP).
  • mapi_client.oidc.dev_idp_login for scripts.
  • docs: tenancy and auth, and quotas and cost concepts; the v0.3 HTTP API; the authentication, Cloud Run, Google Cloud deployment, security hardening, observability and security-suite operations pages; and every new variable in the configuration reference.
  • The M2 verification record (docs/operations/m2-verification.md): what was proven locally, the measured numbers, and the exact steps left for a real Google Cloud project.
  • server: GET /v1/admin/usage (operators) returns cost by tenant for a UTC day or month, summed from the cost ledger with the biggest spender first. It also returns the current spend against the global spend cap (AdminUsageReport, TenantCost and GlobalSpend in mapi_spec.api). The Python client exposes it as admin.usage().
  • web: the admin console has a Cost tab with cost by tenant per period and each tenant's share of spend. The global spend-cap card shows current spend against the cap on a meter that warns at 70% and 90%.
  • docs: the v0.3 site.
  • New Administration and Authentication guides, each driven by a walkthrough that make e2e runs against a dev stack (docs/snippets/admin_tour.sh, auth_tour.sh).
  • Generated from the code at every build (scripts/gen_docs_platform.py): the roles, token-scope and trust-tier matrices (mapi_spec.tenancy.PERMISSIONS), the metric set, the alerts, dashboards and runbooks of ops/, and an index of every MAPI_* variable. Pages embed generated tables with <!-- generated: NAME -->.
  • The landing page tells the multi-tenant story, with screenshots of the new screens (make docs-screenshots-admin); the security model shows what each executor enforces and the threat model in plain terms; Deploying to Google Cloud starts with the [verify M0] checklist and ends at the first tenant; the roadmap moves to M3.

Changed

  • devops: make dev and compose run the servers in the dev profile by default (MAPI_STACK_PROFILE): sign in as admin@dev.local (tenant admin and operator) or one of the other seeded accounts. make seed, make e2e and mapi_conformance run --stack sign in by themselves. MAPI_STACK_PROFILE=single-tenant restores the credential-less API.
  • server: every v0.2 route is project-scoped. Callers name the project with X-Mapi-Project: <tenant>/<project>, a token's binding, or the single-tenant default.
  • web: mutations send the session's CSRF token (X-CSRF-Token). openapi.json and the TypeScript types are generated from the server alone.
  • client: QUOTA_EXCEEDED is no longer retried by default (RetryPolicy.max_quota_wait). RATE_LIMITED has its own retry budget and is retried for every method.

Fixed

  • server: the start-up check that every route has an access rule now covers routes of included routers (FastAPI 0.143 keeps them as branches).
  • server: two finalizations of the same model at once no longer deadlock, and a retried finalization reuses its stored signed manifest.
  • server: /auth/callback sends the browser back to the web origin, and a single-tenant server answers /auth/session as the local administrator.
  • web: costs below $0.0001 keep two significant digits instead of showing $0.00.
  • Usage lists session runs as "Signed-in users" (the server's group key is -).
  • Memory under an hour is shown in GiB-seconds.
  • A finished run without output no longer says "yet".
  • The project switcher no longer truncates names on wide screens.
  • web: a live log tail no longer trips the tenant's rate limit. Before, it sent up to 10 requests every 500 ms while catching up. It now fetches one page per poll, at least 2 s apart (5 s once caught up), and waits out Retry-After on 429.
  • security: the docker and gVisor probe container gets the suite through a read-only mount; before, every isolation probe ended in ERROR on a host with Docker.
  • --executor cloud refuses to run outside a Cloud Run Jobs execution.

Fixed (M2 review: spec, server and auth)

  • server: an API token can no longer administer its tenant. Tokens are bound to one project, so tenant members, project create and delete, the tenant audit log and tenant usage now need a signed-in session (403 for any token). A token may still read its own tenant and list its own project (mapi_spec.api.TOKEN_TENANT_ROUTES).
  • server: signing in again no longer re-activates a disabled account. /auth/callback refuses it with the generic 401 and audits auth.login_failed; only an invitation becomes active at its first sign-in.
  • server: operator status is decided on every request from MAPI_OPERATOR_EMAILS (and the dev profile's first account). Before, a demoted operator kept operator powers through mapi login ID tokens indefinitely and through browser sessions until they expired.
  • spec/server: an API token can no longer mint tokens, whatever its scopes (mapi_spec.tokens.TOKEN_NEVER). A child token dropped its parent's expiry, vCPU budget and rate limit, and outlived its revocation. POST /v1/tokens needs a signed-in session.
  • spec/server: score:<target> tokens can score. They authorized nothing, because :score checks run on the model and the scope covered only endpoints, so integrations fell back to broader runs:create tokens. score:<model id> now covers :score on that model and score:* on every model of the project (RouteAccess.name_param). The web tokens page, the CLI help and the docs say so.
  • server: concurrent uploads can no longer overrun the storage quota. Storage admission now locks the tenant row like run admission does, so six concurrent 800-byte intents against a 1000-byte quota admit exactly one.
  • server: abandoned upload intents no longer hold storage quota forever. Usage counts a pending intent only while its signed PUT is valid, and a new housekeeper loop in the worker (MAPI_HOUSEKEEPING_INTERVAL_S, default 300 s) marks expired intents expired and deletes whatever reached quarantine.
  • server: expired browser sessions are deleted. The delete in load_session was rolled back with the request's 401, and nothing else removed web_sessions rows. The row is now deleted in its own transaction after the 401, and the housekeeper sweeps expired sessions and abandoned sign-ins.
  • server: a FINALIZING run no longer counts its vCPU estimate on top of its metered attempt. Quota usage counts an active run's estimate only while its current attempt has no actual ledger entry, so runs near a daily or monthly limit are not refused by mistake.
  • server: concurrent submissions from different tenants can no longer exceed a global concurrent_runs or vCPU limit. Run admission takes a transaction-scoped advisory lock after the tenant row when the global policy sets such a limit.
  • server: the global spend cap counts work in flight. The ledger only grows when runs finish, so every queued run used to be admitted and dispatched against the same figure. Admission now adds the estimates of admitted, unmetered runs, and the dispatcher adds the estimates of started runs and of each run it leases in the same pass.
  • server: held runs can no longer fill the dispatcher's window of queued runs and stall fair share for every tenant. Runs of suspended tenants, uploaded code paused by the kill switch and non-internal work past the spend cap are left out in SQL, and each tenant takes at most MAPI_LEASE_CANDIDATES_PER_TENANT (50) of the window.
  • server: a retry resumes from the newest checkpoints of any earlier attempt. Before, it only looked at the attempt just before it, so an attempt that died before its first segment threw away all earlier progress. Automatic infra retries are also capped by cost (MAPI_AUTO_RETRY_COST_FACTOR, default 2× the estimate), as plan §8.3 asks, and no longer by count alone.
  • server: an unknown token prefix and a wrong secret for a known prefix get the same answer (the API token is invalid), so the API no longer tells which prefixes exist. Unauthenticated failures that anyone can cause (token.auth_failed, auth.login_failed) are audited at most five times per token prefix or client and minute, so they can no longer flood a tenant's audit log. The housekeeper sweeps old rate counters.
  • server/web: run event streams re-check the caller's credential every MAPI_SSE_REAUTH_S (60 s). A revoked token, an ended session or a lost membership no longer keeps receiving a 24-hour run's progress and logs: the stream closes and the reconnect is refused. The web client stops on 401 (and raises the session-expired prompt) or 403, where it used to retry forever.
  • server: the audit log and the cost ledger are append-only (migration 0004_append_only_records). Both runtime roles lose UPDATE and DELETE on them, and a trigger refuses any change to an existing row, so a request-path bug or injection can no longer rewrite a tenant's audit trail or billing history.
  • server/infra: the default data residency follows the deployment. It used to be us everywhere, so on the documented EU deployment every run of the platform tenant and of tenants created with defaults failed as INFRA_ERROR. MAPI_DEFAULT_RESIDENCY defaults to the multi-region of the Cloud Run region, and Terraform sets it from the dispatch pool's region (default_residency overrides it). It applies to the platform tenant, to rows the tenancy migration adopts and to POST /v1/admin/tenants without data_residency. Submissions that no reporting executor can place are refused at admission with a clear 403 instead of being queued, and the worker warns at start-up about such tenants.
  • server/infra: the API and events services no longer hold the RLS-bypassing database role, which plan §9 reserves for the dispatcher, reconciler and finalizer. Their platform lookups (sign-in, memberships, operator metadata, routing pushed events) now run as a new mapi_gateway role (migration 0005_gateway_role, MAPI_DATABASE_GATEWAY_URL, MAPI_DB_GATEWAY_ROLE). It has no BYPASSRLS, gets explicit policies on identity tables and column privileges on a few run and ledger metadata columns, and cannot read datasets, specs, models, plots, attempts, events or logs. Every API request uses it automatically. Pushed events are written through tenant sessions. Terraform adds the mapi_gateway login and its db-url-gateway secret, and only the orchestrator can read db-url-orchestrator.

Fixed (M2 review: executors, cloud and infrastructure)

  • infra: the deployed stack can run work at all. Terraform creates a create-only url-uploader account (MAPI_URL_UPLOADER), without which every run failed at dispatch; the server refuses MAPI_OBJECT_STORE=gcs without a distinct uploader.
  • infra: checkpoint policies may overwrite their own checkpoints (conditional storage.objects.delete under .../checkpoints/ only), so PARTIAL bundles and resume see every segment on real GCS.
  • server: verified and public tenants are placed only on an executor with passing, unexpired conformance and security-suite reports for its current caps (PUT /v1/admin/executors/{name}/evidence, MAPI_EXECUTOR_EVIDENCE_MAX_AGE_S); the admin console, CLI and web show the real state. Runs that no executor can place are refused at submission (503) instead of failing at lease, and the docs say that verified tenants need DNS enforcement verified first.
  • server: docker-gvisor can qualify for shared tiers on a network declared isolated (MAPI_DOCKER_NETWORK_ISOLATED).
  • server: Cloud Run 429s requeue a run without using up an infrastructure retry, leases are renewed while a batch is submitted (for at most ten lease lengths per submit), and Retry-After is capped. The executor's per-process bookkeeping is bounded.
  • sdk: zip artifacts (skops) are no longer rejected as pickles depending on the second they were written.
  • infra: user log and progress events are excluded from the exec projects' _Default log bucket; the API identity only reads the quarantine bucket.
  • deploy: the deploy workflow is valid (no runner context in job env; CI runs actionlint); rollback.sh --previous picks the release before the deployed one (records are named after their images, rollbacks are marked); dry runs no longer claim what they did not do.

Fixed (M2 review: CLI, operations and docs)

  • server: incident containment can now stop running tenant code. PUT /v1/admin/executors/{name} (audited executor.update, reason required; mapi admin executors disable|enable, a Disable button in Admin → Executors) disables an executor: orchestrators lease nothing onto it and their periodic reports no longer reset enabled to true. The kill-switch update takes cancel_untrusted_runs (with untrusted_execution_enabled=false; mapi admin kill-switches set --cancel-untrusted-runs, "Cancel running uploaded code" in the web app), which moves every unfinished run of uploaded code to cancelling. The sandbox-escape and incident-response runbooks use both.
  • ops: the jobs.run 429 alarm (plan §8.3) watches Cloud Run's 429s. The orchestrator exports mapi_executor_submit_throttled_total{executor} (every 429, retried ones included), and MapiJobsRun429 and the dashboard use it instead of the API's own 429s to tenants on POST /v1/runs. The Cloud Monitoring policy uses serviceruntime.googleapis.com/api/request_count (RunJob, response_code=429). Its old filter named a metric that Cloud Run jobs do not export, and it counted every 4xx.
  • ops: the plan's alert on running executions at 70 % of Cloud Run's 1,000 (§8.8) exists. The orchestrator's report exports mapi_executor_running_executions{executor} from the run table; MapiRunningExecutions70 and a Cloud Monitoring policy on run.googleapis.com/job/running_executions fire above 700.
  • infra: on GCP the mapi_* series reach Cloud Monitoring.
  • The orchestrator serves its own /metrics on 127.0.0.1:9464. Before, the dispatch, reconcile and finalize series had no endpoint in the cloud.
  • api, events and the orchestrator run a collector sidecar (metrics_collector_image, an OpenTelemetry Collector pinned by digest). It scrapes /metrics over loopback with the metrics bearer and writes to Managed Service for Prometheus.
  • Without an image Terraform warns that the alerts and dashboard stay empty.
  • The nightly security jobs push mapi_security_probe_unexpected to the Pushgateway at MAPI_PUSHGATEWAY_URL.
  • gcp-deployment.md has a Metrics section.
  • server: rotating the local bundle-signing key no longer breaks every existing model. MAPI_SIGNING_TRUSTED_KEY_PATHS lists retired keys (public or private PEM) that still verify. Terraform gains control.kms_trusted_key_versions (MAPI_KMS_TRUSTED_KEY_VERSIONS on every server workload). The key-rotation runbook now gives the real local and Cloud KMS procedures and the compromise path. It no longer tells GCP operators to rotate a Pub/Sub "internal token": pushes there are OIDC-authenticated.
  • docs: data residency is enforced only on executors that declare regions (Cloud Run Jobs). The self-hosted executors are not checked, and the tenancy page now says so instead of promising a region match everywhere.
  • server: MAPI_DISPATCH_TIERS restricts an orchestrator to some trust tiers. Two orchestrators serving different exec pools can no longer lease each other's runs (leasing was not pool-aware, so public-tier code could land in the paid pool). gcp-deployment.md no longer suggests a second orchestrator without it, and says that one environment deploys one pool until M5.
  • ops: three Prometheus rule fixes.
  • MapiQuota70 is now MapiQuotaRejections. It fires on rejections, not at 70 %, and it aggregates by (scope), so its summary names the scope.
  • MapiNoProgressIngested also fires when no event was ever accepted (or vector(0)).
  • Clearing the spend cap now resets mapi_global_spend_micros and mapi_global_spend_cap_micros to 0, both at once and at every admission. The spend ratio ignores a cap of 0, so MapiGlobalSpendCap90 stops firing.
  • cli: mapi logout --revoke revokes the token in the project it is bound to (it was refused with 403 when another project was selected) and forgets the local credentials even when the revocation fails, then exits 1.
  • security: the report writers create their directory, so make security-suite (and both nightly security jobs) no longer fail on a fresh checkout without .local/.

Fixed (integration)

  • server: two review branches each added a database role for the API without the row-level-security bypass. One is kept (0005_gateway_role, column-level grants, used for every API request); MAPI_DB_PLATFORM from the other is gone. The executor-disable migration is renumbered 0006_executor_disable and lets the gateway change an executor's enabled and disabled_reason.
  • server: an executor's enabled flag belongs to operators. Missing shared-tier evidence no longer turns it off; it shows in shared_tiers_enabled and the executor's message.
  • server: a submission that no executor can place gets one answer per cause. A residency or resource class no executor serves is refused with 403. A trust tier that no executor can take yet (enforcement not verified, no suite evidence) gets 503.
  • server: as the gateway role, the kill switch's cancel_untrusted_runs cancels each tenant's runs in a session of that tenant. The operators' usage report can read the ledger columns it sums.
  • tests: the end-to-end spend-cap drills use a self-hosted-trusted tenant, which the local executor can still place.

Known gaps

  • Nothing has run against real Google Cloud. The Cloud Run, Pub/Sub, KMS and Cloud SQL code paths, the IAM conditions, the logging exclusion, the metrics collector sidecar and the Cloud Monitoring filters are checked with fakes and mocked providers only.
  • No job posts the nightly conformance and security-suite results to the evidence endpoint yet. Until an operator records them (and the M0 DNS check passes), verified and public tenants cannot run on any executor.
  • There is no endpoint to disable a user account. disabled is set in the database, and sign-in keeps it.
  • The gateway role still reads some tables across tenants for the operator console, quota counters and sign-in.
  • A live log tail polls the logs endpoint instead of using the run's event stream.
  • The API's built-in /docs and /redoc pages load assets from a public CDN.

[0.2.0] — M1: local vertical slice — 2026-10-15

The platform now runs end to end on one machine. From the Expert web app (or the CLI, the Python client or plain HTTP) you can upload data, write and validate a spec, fit an E33-style hierarchical geo MMM, inspect it, and score it: an 8-week forecast, a new market, a weekly EUR plan and counterfactuals. Real Cloud Run and multi-tenancy come in M2 (v0.3.0). In summary:

  • server (mapi-server): a single-tenant FastAPI control plane on PostgreSQL with procrastinate. It includes a dispatcher, a level-triggered reconciler, a metadata-only finalizer with signed bundle manifests, HMAC-verified run events, SSE with resume, and local-process and docker executors. It never imports plugin code or array libraries.
  • runner (mapi-runner): a trusted supervisor and an untrusted worker harness for ingest, describe, validate, fit, score, evaluate, derive and render runs. It produces signed events, checkpoints, partial manifests and the ui/ views the web app reads.
  • gcs-shim (mapi-gcs-shim): a local Cloud Storage emulator. It provides generations, server-side CRC32C/MD5, V4 signed URLs and POST policies, per-principal permissions and a write-once bundles bucket.
  • web and plot-sandbox: the Expert web app (YAML editor with live validation, runs with live progress and logs, model inspection, scoring) and a separate-origin, CSP-locked plot renderer.
  • client and cli: a typed sync/async Python client for every v1 endpoint, and mapi for local and remote work.
  • devops: make dev (the whole stack without Docker), digest-pinned images, docker compose, make seed, make e2e and make release-check.
  • conformance: the corpus also runs through the live stack (run --stack URL), and a sklearn conformal fixture is added (7 fixtures in all).
  • docs: a 10-minute quickstart, guides to the web app, CLI, client and HTTP API, a deployment section, an architecture overview and a security model.

Every workspace package and both web apps are versioned 0.2.0. Contract v0.1 gains only additive changes.

Added

  • spec: the M1 shared contracts, so the API, runner, executors, GCS shim, web app and plot sandbox can be built in parallel against one source of truth:
  • mapi_spec.run: the run state machine (RunState, RunTrigger, a pure transition() with owners, failure classes and attempt bumps), the RunSpec bootstrap object, typed event payloads, canonical HMAC-SHA256 event signing, the {"mapi_event": …} stdout format, OutputManifest with metadata-only verification, the signed BundleManifest, RunnerEnv and exit codes.
  • mapi_spec.storage: ObjectMeta, the async ObjectStore protocol, signed GET/PUT and POST policy shapes, GCS-style CRC32C/MD5 helpers, storage errors and the bucket/key naming scheme.
  • mapi_spec.api: REST DTOs for the v1 surface (each with examples), SSE/Prefer/idempotency helpers and M1_ENDPOINTS, the machine-readable endpoint table.
  • mapi_spec.localdev: local ports, origins and environment variable names.
  • spec (additive, contract v0.1): RunKind gains the fan-out parents (sweep, backtest, sbc, campaign); RunEventType gains checkpoint and metric; RunEvent.ts must be timezone-aware and is normalised to UTC; PlotSpec carries a schema example. The new schemas join export_schemas() and the contract snapshot.
  • docs: the "HTTP API (v1)" and "Run protocol" reference pages (docs/reference/http-api.md, docs/reference/run-protocol.md).
  • gcs-shim: mapi-gcs-shim, a local Cloud Storage emulator. It serves the GCS JSON-API subset the platform uses, with generation and metageneration preconditions, plus V4 signed GET (with Range) and PUT URLs and V4 POST policies (key prefix and size range). It adds IAM-lite principals (control, and exec with bootstrap get and delete only), a write-once bundles bucket, lifecycle sweeps, and server-computed CRC32C and MD5 on a crash-safe filesystem backend. ShimObjectStore and the import-guarded GcsObjectStore implement mapi_spec.storage.ObjectStore, and a pytest11 plugin provides the gcs_shim fixture. See docs/reference/gcs-shim.md.
  • runner: mapi-runner, the sandboxed run harness. A trusted supervisor reads and deletes the bootstrap object, starts the untrusted worker with a scrubbed environment (uid 10001 and no-new-privs as root, dev mode otherwise), signs every stdout event, caps logs, events, scratch and outputs, handles SIGTERM/deadline with partial manifests from checkpoints, and uploads outputs plus a signed OutputManifest. The worker runs ingest (CSV/Parquet with a column mapping or the MFF preset, zarr zip, netCDF), describe, validate (dry run), fit (bundle, sidecars, plots, ui/ views, checkpoints), score, evaluate, derive and render through the SDK harness. See docs/reference/runner.md.
  • server: the M1 control plane (mapi-server api|worker|migrate|dev): FastAPI /v1 API covering every M1_ENDPOINTS row plus /v1/me, spec diff, ui/ redirects and :evaluate; SQLAlchemy 2 + Alembic schema (every row carries tenant_id/project_id) with procrastinate on the same Postgres; dispatcher (token bucket, intent row before submit), level-triggered reconciler (executor status is the liveness source of truth, STALLED is a warning, orphan adoption by nonce), metadata-only finalizer with server-side copy and EC P-256 signed bundle manifests verified at URL minting; HMAC-verified event ingestion from the executor pipe and POST /internal/events; SSE with Last-Event-ID resume over one LISTEN connection per process; local-process and docker/docker-gvisor executors; OpenAPI 3.1 export (python -m mapi_server.openapi).
  • web: apps/web, the Expert-mode web app (React 19, TanStack Router/Query/Table, Tailwind + shadcn/ui, Monaco + monaco-yaml with live :validate diagnostics, SSE run progress per chain, dataset upload via signed URLs, model inspection, scoring) and the Guided-mode shell; a pnpm workspace at the repo root.
  • plot-sandbox: apps/plot-sandbox, the separate-origin Vega-Lite/Plotly renderer with a strict CSP and a validated postMessage protocol.
  • devops: containers, local orchestration and developer tooling for M1:
  • images/: base-bayes (with a worker target), base-ml (no PyMC), base-forecast, server (control plane only, checked with import-linter and package assertions at build time), gcs-shim and web (nginx serving the app and the plot sandbox on separate origins with strict CSPs). All are digest-pinned, run as non-root with a read-only root filesystem, and have health checks. The runner images bake the catalog JSON and a warmed PyTensor/Numba cache.
  • docker-compose.yml (postgres 16, gcs-shim, migrate, api, worker with the local-process executor, web, plot-sandbox), compose.override.example.yml and .env.example, which documents every MAPI_* variable.
  • scripts/dev.py (make dev): runs the whole stack without Docker (pgserver PostgreSQL, GCS shim, migrations, api, worker and both Vite servers) with readiness probes, prefixed logs and clean process-group shutdown. Also stop, status, migrate, db-shell and clean.
  • New make targets: dev/run, stop, status, migrate, db-shell, seed (the E33 demo through the API), clean-local, docker-build, docker-pin, compose-up/down/logs, web-build, web-test, test-all, e2e and release-check.
  • CI: a web job, a build-only docker job for every image, and digest-pin and compose checks.
  • Docs: "Local development" and "Containers and docker compose" (Operations), and deploy/README.md.
  • client: mapi-client, the typed Python client (Client, AsyncClient) for every M1_ENDPOINTS row: DTO-typed resources, RFC 7807 errors as MapiError subclasses, idempotency keys and safe retries, lazy pagination, SSE with Last-Event-ID resume, dataset upload through signed URLs, Prefer: wait scoring, CRC32C-checked transfers without the API token. See docs/reference/cli.md.
  • cli: mapi (new, check, fit, score locally or --remote, runs, datasets, catalog, plugin init|test, spec upgrade, serve, config), with --json output and ANSI-stripped untrusted text.
  • spec: mapi_spec.jobs, the run job contract shared by the dispatcher and the runner (IngestJob, DescribeJob, ScoreJob, EvaluateJob, DeriveJob, RenderJob, input and output object names, MetricsSidecar, DATASET_SCHEMA_ATTR), and mapi_spec.views, the UI views the web app reads (mapi.preview/v1, mapi.profile/v1, mapi.tree-index/v1).
  • gcs-shim: CORS for the web origin (--cors-origin, default $MAPI_WEB_ORIGIN) so browsers can use signed URLs, and a wrap hook on ShimServer.
  • server: mapi-server dev (and so mapi serve) starts an embedded GCS shim when nothing listens at MAPI_GCS_ENDPOINT; the OpenAPI document publishes the run-event payload schemas.
  • docs: "CLI and Python client" and "Web app and plot sandbox" reference pages; the run protocol documents job payloads, object names and UI views.
  • server: GET /v1/runs/{run_id}/plots (list_run_plots) returns the plots a succeeded run rendered, sanitized on every read; the client has runs.plots().
  • web: the score form takes the input dataset and the spec's side tables (defaulting to the versions the fit used), shows the score run's own plots and pivots the summary by statistic.
  • devops: scripts/seed.py --export DIR writes the E33 demo tables, score payloads and spec as files for the web app.
  • tests: the M1 exit criterion end to end against the running stack: tests/e2e (Python client: fit, SSE, signed manifest and bundle generations, the bundle read back with the corpus' checks, five E33 score requests, bootstrap deletion, forged events) and apps/web/e2e/m1-flow.e2e.spec.ts (the same flow in the browser, plots in the sandbox iframe); a server test proves the finalizer refuses tampered output of the real runner.
  • docs: "Your first model in the web app" guide; the local-development page covers the first start, make e2e and the state under .local/.
  • docs: the M1 documentation set. A landing page with screenshots of the running platform (docs/assets/screenshots/, captured by scripts/docs_screenshots.mjs and shrunk by scripts/optimise_screenshots.py, make docs-screenshots); a validated "zero to first model in 10 minutes" quickstart; guides to every page of the Expert web app, the CLI, the Python client and the HTTP API; an OpenAPI explorer page (a vendored, hash-pinned Redoc bundle over the committed openapi.json, scripts/vendor_redoc.py); a Deployment section (local, Docker Compose, container images, a configuration reference naming every MAPI_* variable, troubleshooting); an architecture overview and a security model; an updated roadmap (M2 cloud and multi-tenant, M3 Guided mode). scripts/gen_docs_reference.py (run by make docs-build and the MkDocs hook) generates the CLI reference from the Typer app, the server settings table from Settings, and copies the OpenAPI document. conformance/tests/test_docs_platform.py runs the CLI walkthrough, checks the generated pages, the configuration reference, the Redoc pin and the screenshots, and (under make e2e) runs the client, HTTP and remote-CLI walkthroughs against the live stack.

Changed

  • docs: anchors are validated in strict builds; mermaid is pinned to 11.17.2 instead of Material's moving mermaid@11.
  • server: the object store is ShimObjectStore built from the shim's control key file (MAPI_GCS_ROOT, default $MAPI_DATA_DIR/gcs, or MAPI_GCS_KEY_FILE); local executions get the exec token from the same directory unless MAPI_EXEC_TOKEN is set. RunSpec payloads and input names follow mapi_spec.jobs (score data under dataset, side tables under side.<name>, bundle.manifest passed to score runs); evaluate/derive default to the fit dataset and render gets the bundle's plots.json. metrics.json is read as MetricsSidecar.
  • runner: fit writes coords.json and ingest writes summary.json (the control plane's CoordPlan metadata); preview.json, profile.json and ui/tree_index.json follow mapi_spec.views. The runner tests run against the real GCS shim.
  • web: openapi.json and schema.d.ts are generated from the real server (apps/web/scripts/gen_openapi.py, checked in CI); the @e2e specs can reuse a running stack on any ports.
  • devops: make dev starts mapi-gcs-shim serve --root … with CORS and passes the resolved URLs to the Vite servers; compose mounts the shim volume read-only into api and worker (MAPI_GCS_ROOT) so they can load the principal keys.
  • sdk: zarr zips record their data-variable order (group attribute _mapi_var_order, removed again on read).
  • devops: make e2e runs the browser @e2e suite (it ran the mocked smoke suite).

Fixed

  • spec: static score validation binds dataset names to canonical names at every inputs_stage, as the runtime does (a weekly EUR plan entering at weekly was refused).
  • server: the dry-run and evaluate endpoints declare their Run response in OpenAPI.
  • seed: make seed ships E33's raw schema with the data, so the fit keeps market: new_levels and new-market scoring works.
  • client: the run event stream decodes the live progress and log frames (sent without an id) as RunLiveFrame; it aborted on the first progress frame of a real fit.
  • server: model lineage records the side-table versions a fit read (it was always empty).
  • sdk: variables come back from a zarr zip in their written order; plot recipes that default to the first variable chose a random one on a bundle read back from storage (mmm.response_curves failed). The response-curve recipe also prefers variables with a channel dim.
  • plot-sandbox: composite Vega-Lite specs (concat, facet) rendered at zero width; they now keep their own size and scroll when wider than the frame.
  • plots: the convergence table's columns no longer overlap; the fan chart draws and captions only the intervals a summary holds.
  • web: a spec opened right after "Save spec" no longer shows unsaved changes (the editor used CRLF for models created empty on Windows user agents), so Fit is enabled; the run page's result column no longer overflows; result tables show dates as dates.
  • devops: make seed ignores a SIZE exported by other toolchains (conda).
  • devops: the compose api and worker containers start. The mapi/server and mapi/worker images bake uv.lock and set MAPI_UV_LOCK_PATH, so both resolve the same image digest. Before, neither could resolve one and both failed at start-up.
  • devops: compose keeps the bundle-signing key on a keys volume that migrate writes and api/worker mount read-only (MAPI_SIGNING_KEY_PATH, MAPI_SIGNING_KEY_CREATE=false). Each container used to mint its own key on its tmpfs, so the API rejected every bundle the worker signed, and a restart orphaned every model. mapi-server migrate now creates the key, and concurrent first starts converge on one key.
  • server: mapi-server api|worker|dev make themselves non-dumpable on Linux, so local-process runs (same uid) cannot read their environment or memory. The worker's start-up warning and the operations docs state what such runs can still read.
  • server: a Prefer: wait long-poll stops waiting when its client disconnects.
  • client: models.score() extends the read timeout of each long-poll by its wait and treats a read timeout as "still running", so scores slower than about 90 s no longer raise TransportError. stream_events() counts only connections that deliver nothing towards max_reconnects, so it follows long fits behind proxies that cut connections.
  • cli: run progress and state messages are never parsed as rich markup (no terminal hyperlink injection, no MarkupError aborting mapi fit).
  • devops: .env.example documents every mapi-server setting, and the devops test now derives the list from Settings.

Fixed (M1 review: spec, runner, shim and plots)

  • runner: fit runs write ui/preprocess.json (mapi_spec.views.PreprocessView, mapi.preprocess/v1): each step's resolved plugin, learned JSON and small numeric state arrays, so the web app's Preprocessing tab is no longer empty.
  • runner: an INFRA_ERROR reported by the worker fails the run as a user error. Only failures the supervisor sees itself are retried automatically as infrastructure failures.
  • runner: every string the worker sends has ANSI, control and bidi characters stripped. The CLI's strip_control also removes the bidi ranges.
  • server, gcs-shim: the artifacts upload policy is create-only (post_policy(create_only=True) signs x-goog-if-generation-match: 0, and the shim answers 412 for an existing key).
  • plots: recipes draw from mapi_plots.palette, whose dark variants keep at least 3:1 contrast, and the plot sandbox swaps them in dark mode. The decomposition legend is titled "component" when baseline or controls join the channels. Faceted fans and MMM curves give each panel its own axes, in coordinate order.
  • conformance: new sklearn_store_demand fixture. Fixtures not yet ported are listed in DEFERRED with reasons (list --deferred). run --stack URL drives fixtures through the API.

Fixed (M1 review: server)

  • GET /v1/runs/{id}/plots and score results are scoped to the caller's tenant and project.
  • Runner JSON is parsed with a nesting limit, and deep nesting is reported as INTEGRITY_FAILURE.
  • Bundle inputs are verified again at dispatch and on :retry.
  • The API refuses unexpected Host headers (MAPI_ALLOWED_HOSTS), nginx answers only MAPI_WEB_SERVER_NAMES and the GCS shim checks hosts too, which guards against DNS rebinding.
  • Ingest and spec validation enforce the per-resource-class data caps (PAYLOAD_TOO_LARGE).
  • An omitted fit seed is pinned to 0 at submission, so lineage records the seed actually used.
  • Run lifecycle:
  • A cancelled checkpointed fit ends PARTIAL and can be resumed (new CANCELLING --complete--> FINALIZING edge).
  • :cancel on a finalizing run is a no-op.
  • A run stopped at its deadline fails as TIMEOUT or finalizes as PARTIAL.
  • An exit counts only after its event backlog is ingested.
  • A restarted reconciler re-adopts live executions.
  • Stalled finalize jobs are re-queued.
  • Executor cancels no longer block the reconcile pass.
  • SSE reads the run state before its events, and a run never shows another attempt's progress.
  • Docker executor:
  • Each plugin namespace gets its own versioned mapi/base-* image (MAPI_DOCKER_IMAGES).
  • Run containers join the shim's network namespace; none is refused.
  • The supervisor and the worker run under separate uids (10001 for the worker), and MAPI_REQUIRE_ISOLATION=1 fails a run closed rather than falling back to dev mode.
  • The import-linter contract also forbids plugin packages, plot recipes and columnar libraries in mapi_server.
  • Docs: invalid submissions are refused without a run, and REJECTED is reserved.

Fixed (M1 review: web)

  • Plots:
  • The plot host accepts a sandbox exported reply only for an export the app requested.
  • Downloading a score output saves a checksum-verified file instead of leaving the app.
  • Run pages:
  • Live progress and log SSE frames, which the server sends without an id, are delivered.
  • The live log view follows the tail.
  • Performance: Monaco and Arrow are no longer in first paint, and routes are lazy-loaded. A build-time budget keeps first-paint JavaScript under 640 KiB (it was 4.26 MB; now about 515 KB).
  • Spec editor:
  • It never replaces unsaved edits when a newer revision appears.
  • Save sends the revision the text came from as parent_revision.
  • Accessibility:
  • No sideways scroll at phone width, and the compact nav shows every page.
  • Status colours and form borders meet WCAG AA.
  • Score form errors are marked on their fields and announced.
  • The Score page resets its form and results when the version changes, and shows a loading state while the version loads.

Fixed (integration)

  • tests: a devops docstring no longer names a fake MAPI_ variable that the configuration reference test picked up.
  • web: openapi.json and schema.d.ts are regenerated (the cancel endpoint documents the finalizing no-op).
  • docs: pages written before the review fixes landed now describe the shared compose signing key, ui/preprocess.json, the docker executor's network and images, and the new MAPI_ALLOWED_HOSTS, MAPI_WEB_SERVER_NAMES, MAPI_DOCKER_IMAGES, MAPI_SIGNING_KEY_CREATE and MAPI_REQUIRE_ISOLATION settings.

Known gaps

  • The docker executor, the compose stack and the new compose smoke test have not run against a real Docker daemon yet.
  • Under the local-process executor, run code shares the orchestrator's uid and can read its key files. The uid split is M2.
  • MAPI_PROFILE means a config profile to the CLI and client, but the deployment profile to the server.
  • The API's built-in /docs and /redoc pages load assets from a public CDN.
  • Conformance fixtures E13, E84-lite, E82-slim and E45 are deferred: they need new plugins or run kinds.

[0.1.0] — M0: contract layer — 2026-10-15

First milestone release: the typed contract layer and the in-process xarray runtime, with no platform services yet. In summary:

  • spec (mapi-spec): pure-Pydantic contracts (DataSchema, SchemaEffect, ModelSpec, ScoreRequest, capabilities, representations, PlotSpec envelope, error codes) with static validation and JSON Schema export, frozen as contract v0.1.
  • sdk (mapi-sdk): wire formats, the stage/split/context/replay/inverse pipeline engine, CoordPlan, bundles without pickle, the fit_spec/score_bundle harness, the in-process executor and the mapi_sdk.testing contract kit.
  • steps (mapi-steps): 28 built-in core.* preprocessing steps and five split kinds, each with exported static effects.
  • pymc (mapi-pymc) and sklearn (mapi-sklearn): model adapters where an author writes only build() or estimator().
  • plots (mapi-plots): JSON-only Vega-Lite/Plotly sanitizer and the core, mmm and ml recipe catalogs.
  • plugins: mmm.hierarchical_geo, mmm.pymc_marketing, ts.hsgp_trend, ts.gaussian_hmm, tabular.hierarchical_glm and tabular.regressor.
  • conformance (mapi-conformance): the corpus runner and six fixtures (E02, C08, E24, store demand, E33, pymc-marketing), all passing at --size tiny.
  • docs: the MkDocs Material site with tested snippets and generated catalogs.

Every workspace package is versioned 0.1.0. The server, runner, CLI, client and GCS shim packages are still skeletons.

  • Repository scaffold: uv workspace, package skeletons, split Apache-2.0 / AGPLv3+commercial licensing.

Contract v0.1 (frozen)

  • mapi_spec.CONTRACT_VERSION = "0.1" names the contract the exported JSON Schemas describe; every exported schema carries it as x-mapi-contract-version.
  • The contract is frozen for the v0.1.x line: packages/spec/tests/snapshots/contract.json pins a digest of every exported schema and the drift test fails on any change. Additive changes (new optional fields, new error codes) regenerate the snapshot (MAPI_UPDATE_SNAPSHOTS=1); breaking changes bump CONTRACT_VERSION first.

Added

  • mapi-spec: the pure-Pydantic contract layer.
  • DataSchema, DimSpec and VarSpec, with schema templates.
  • SchemaEffect propagation; ModelSpec, ScoreRequest and the Sweep, Backtest, SBC and Campaign wrappers.
  • A strict YAML 1.2 loader, canonical JSON and spec hashes.
  • Static validation: split stage, LEAKAGE_RISK, dim policies, recursion and the CoordPlan checks.
  • Capabilities, representation rules, the PlotSpec envelope, stable RFC 7807 error codes, the Executor protocol shapes, JSON Schema export and the mapi-spec CLI.
  • mapi-sdk: the xarray runtime.
  • Wire formats: XrJSON, single-object zarr v3 zips, DataTree zips and Arrow IPC.
  • Array-level schema validation; the step contract, pipeline engine and stages.
  • Split and per-group context replay; inverse chains with output-space labels.
  • CoordPlan and coordinate canonicalisation, the representation-aware summariser and metrics.
  • Bundle IO with integrity sidecars and no pickle; the fit_spec/score_bundle harness with evaluate_then_refit.
  • The entry-point registry, the in-process executor, and the mapi_sdk.testing contract kit (check_step, check_model, pytest plugin).
  • mapi-steps: 28 built-in core.* steps (reshape, value, feature, formula, adstock and saturation).
  • Each exports a templated SchemaEffect and a shipped catalog JSON.
  • Catalog entries for the five split kinds.
  • mapi-pymc: PyMCModel, where an author writes only build().
  • BuildContext with hier_effect new levels.
  • Engines: nutpie, pymc, MAP and ADVI, plus guarded numpyro, blackjax and pathfinder.
  • Segmented, resumable sampling with checkpoints; CRN counterfactuals.
  • Convergence diagnostics, FrozenPredictor fast scoring and check_fast_score.
  • mapi-sklearn: SklearnModel with an allow-listed estimator registry (sklearn and xgboost).
  • skops and xgboost-JSON persistence.
  • Point, quantile, bootstrap, conformal and classifier plugins (sklearn.tabular_*).
  • Pooled new levels; exact or zeroing contributions.
  • mapi-plots: a JSON-only PlotSpec sanitizer (pinned Vega-Lite 6.4.1 and plotly.js 4.1.1 schemas, an expression allow-list, HTML stripping) and role-bound templates.
  • The standard core.* recipe catalog, plus the mmm.* and ml.* domain packs.
  • Altair and Plotly converters with named Arrow datasets, and the render_plots batch entry point.
  • mapi-plugin-mmm: marketing-mix model plugins (entry point mapi.models: mmm).
  • mmm.hierarchical_geo 1.3.0, the E33 hierarchical geo MMM: adstock over core.lag_tensor features, logistic/Hill saturation, channel effects partially pooled across geos, new geos from the hyperprior, additive contributions.
  • Fit-cached derived quantities in original units: contributions, per-draw response-curve grids for Guided sliders, marginal cost/ROI; a decision objective for budgets; fast scoring.
  • mmm.pymc_marketing 1.0.0, an adapter around pymc-marketing 1.2's MMM (verified on PyMC 6.3) that owns scaling, carry-over and units and never pickles.
  • Declarative priors in pymc-marketing's dict form, typed per parameter so wrong priors fail static validation; a synthetic E33 simulator with known parameters and a parameter-recovery test.
  • mapi-sdk: outputs.derived with cache: fit is computed on the production fit through the plugin's derive hook; ScoreContext.to_original maps model-stage arrays to the output space.
  • Cross-package conformance suites:
  • the plan's xgboost spec through real steps, models and plots;
  • an E33-shaped core.* pipeline in front of a PyMC model.
  • mapi-plugin-ts: ts.hsgp_trend (C08: HSGP trend + seasonality whose domain comes from the anticipated coords; HORIZON_EXCEEDS_FIT past max_extension; sentinel months left out of the likelihood) and ts.gaussian_hmm (E24: forward-algorithm HMM over padded tracks with a terminal_state group, forecast and filter modes, relabelling postprocess, LOO declared invalid).
  • mapi-plugin-tabular: tabular.hierarchical_glm (E02: partially pooled GLM in long layout, hyperprior new groups, group-level covariates, four families) and tabular.regressor (plan §6.2 store demand: quantile regression restricted to the registry's quantile-capable estimators).
  • mapi-conformance: the corpus runner python -m mapi_conformance run [--size tiny|full] [--fixture id] (validate, fit, score requests, evaluate, contract kit, plots, expectations; summary table and JSON) with the e02, c08, e24, xgb_store_demand, e33 and pymc_marketing fixtures; pytest -m conformance, make conformance (tiny) and make conformance-full.
  • Score cases can opt out of side tables (side_tables: false, e.g. a weekly EUR plan entering after the FX step).
  • Expectation hooks that declare ctx receive a FixtureContext (seed, size, registry, side tables and earlier score results).
  • Docs: spec format, SDK, built-in steps and plots references; guides for plugins, PyMC, tabular models and plots.
  • Docs: built-in models reference, marketing-mix models guide, forecasting and sequential models guide, conformance corpus guide.
  • Docs: the MkDocs Material site.
  • A landing page, getting started (install, first model, runs and executors), concepts (labelled data, pipelines and stages, representations, bundles, coord plans, capabilities), the "port a model in 15 minutes" guide, licensing, contributing and roadmap pages for v0.2–v0.4.
  • Generated reference pages: steps, models and plot-recipe catalogs, the ModelSpec/ScoreRequest schema and error codes (scripts/gen_docs_catalog.py, run by an MkDocs hook and make docs-catalog); API reference via mkdocstrings.
  • Every Python example lives in docs/snippets/ and is executed by conformance/tests/test_docs_snippets.py (make docs-test).

Changed

  • The workspace runs numpy 2.4 (pymc-marketing 1.2 requires numpy < 2.5).
  • make typecheck also covers plugins/mmm, plugins/ts, plugins/tabular and conformance.
  • Score requests that name a pinned side table now fail validation. Score outputs along time and extend dims come back sorted. validate_spec emits an INFO issue when the model's target role overrides a declared role.
  • Plugin-raised error text now appears in untrusted_text, not in detail.

Fixed (integration)

  • The plan's xgboost example names the real quantile plugin (sklearn.tabular_quantiles). A new ml.feature_importance recipe covers its plot list.
  • core.fit_vs_actual and core.residuals_over_time compare quantile and interval summaries cell by cell instead of refusing them.
  • The contract kit flags step settings whose defaults are missing from the exported JSON Schema (default_factory), and finds reverse-bound outputs when checking units.
  • Holdout metrics no longer invert the predictions of models that own inverse_units a second time.
  • Score outputs that declare their own units (e.g. state probabilities) no longer go through the target's inverse transforms.
  • The contract kit runs its shuffled, subset and new-level checks on long layouts (index variables are remapped) and shifts calendar-frequency time coords by whole periods.
  • The in-process executor no longer resolves a score request's side-table refs when the run payload already supplies those tables.
  • The contract kit's score requests name the spec's score_policy: supplied side tables, so models with supplied tables (E33's FX) pass the scoring checks.
  • The E33 and pymc-marketing fixtures use the corpus runner's fixture layout and run in python -m mapi_conformance run.

Fixed (M0 review: spec and SDK)

  • Counterfactuals: score_bundle takes a baseline data payload and a separate scenario payload, and scores the scenario's interventions against the baseline. A scenario without baseline data, or a named scenario that was not supplied, is SCHEMA_INVALID.
  • owns is enforced: catalog steps declare provides (scaling, carryover), and validate_spec rejects such a step on the target, media or spend variables of a model that owns that preprocessing (CAPABILITY_UNSUPPORTED).
  • Target dependence follows the model template's target roles, even with an inferred raw schema, so RECURSIVE_UNSUPPORTED fires reliably. The recursion and future-covariate checks measure how far the payload extends past the fit, not only horizon.
  • No target leakage into evaluation lags for dynamic or cell-level splits. as_of gap periods stay in the holdout with masked targets and are excluded from metrics.
  • Pinned side tables always replay the fit-time version; a request that names one fails with SIDE_TABLE_COVERAGE.
  • Score payloads are sorted along ordered/extend dims before the pipeline; duplicate labels are COORD_NOT_ALLOWED.
  • Chained look-back steps (adstock then lag) replay with fit-time history. A score window that starts inside the fitted range and loses look-back history gets a WARNING.
  • Long-layout per-group context works end to end.
  • Inverses: steps declare additive inverses, and decompositions along component only go through additive steps (otherwise they stay at the blocking stage, with a WARNING). Smearing residuals come only from target-shaped outputs. The moments sd uses the delta method.
  • Model-owned outputs are labelled per variable with their units and output_space.
  • Derived quantities named in output.vars come back from the fit cache. An uncached one is refused.
  • PyMC CRN counterfactuals give identical draws in untouched cells for Poisson, negative binomial and binomial likelihoods (crn_ppf).
  • Wire and bundle hardening: XrJSON budgets decoded bytes (max_decoded_bytes), caps declared dim sizes and accepts only ISO-8601 dates. Zarr-zip decoding is bounded by declared shapes, string lengths and an expansion ratio. Pickle is detected by opcode stream, in object .npy and inside archives. The evaluation sub-bundle is verified and never kept stale. Entry points can no longer replace registered or built-in plugin ids.
  • Spec parsing rejects non-finite floats and reports oversized JSON integers as SpecParseError.

Fixed (M0 review: models and steps)

  • core.formula: scalar ~ is logical not; overflow and division by zero give inf/nan, and other arithmetic errors are reported as SCHEMA_INVALID rather than escaping.
  • PyMC checkpoints: the codec rebuilds only classes defined in PyMC step-method modules (plus pymc.util and mapi_pymc), at exactly the encoded module:qualname.

Fixed (M0 review: plots, conformance and tooling)

  • Plot sanitizer (Plotly): updatemenus/sliders method args are checked in the positional [attr, value] form and inside nested frame/trace objects, so external image sources, href/url paths, tile-map subplots and tile-map trace types can no longer hide there.
  • Plot sanitizer (Vega-Lite): select.toggle strings and bind.legend event filters are checked as expressions, event['view'] is rejected like event.view, and usermeta.embedOptions is rejected.
  • from_plotly strips HTML from (and scans) the strings it moves into Arrow datasets, which the JSON sanitizer never decodes.
  • Plugin-originated error text stays out of trusted fields: render_plots and the step/model wrappers (Pipeline, harness fit/score/derive) move a plugin MapiError's detail into untrusted_text and keep its code only when plugin code may raise it (mapi_spec.errors.from_plugin_error, PLUGIN_ERROR_CODES).
  • Contract kit, check_step: effect_matches_output also compares each dim's kind and frequency and each variable's units; the toy FX and scale steps now set the units they declare.
  • Contract kit, check_model: new horizon_exceeds_fit, missing_future_covariate, recursive_unsupported, fast_score_matches_exact (plugin check_fast_score hook, provided by PyMCModel), plots_sanitize and outputs_match_manifest checks; extend_typed_errors covers integer extend dims; output_space_and_units covers outputs that inherit the target implicitly.
  • OutputDecl.dims_of (additive) lets a manifest declare settings-dependent data dims; the sklearn models (and so tabular.regressor) and mmm.pymc_marketing use it, so their static output dims match what score returns.