Skip to main content
Glama
README.md
# metric-contracts

<!-- mcp-name: io.github.val-ye/metric-contracts -->

One governed metric contract, many consumers.

A metric contract declares what a semantic layer usually leaves implicit: formula, components,
source, refresh cadence, as-of semantics, allowed dimensions, **additivity** (additive,
semi-additive with the non-additive dimension named, or non-additive), caveats, owner.
Four consumers enforce it:

1. **Validator CLI for CI.** A semi-additive metric must name its non-additive dimension or the
   build fails. Allowed-dimension sets must be subsets of the declared registry. Ratios must
   carry components. Every metric needs an owner, a refresh cadence and as-of semantics.
2. **MCP server.** `list_metrics`, `get_contract`, `plan_query(metric, grain, dimensions)` returns
   allowed or denied with the reason, so an agent reads the contract before it writes SQL.
3. **Chart meaning gate.** Given a proposed chart, return veto or allow with the rule that fired:
   ratios are not stacked, pie'd or summed; semi-additive at a coarse grain takes LAST; pie
   category cap; funnels only for ordered stages; no duplicate axis values. The model proposes,
   the rules veto.
4. **Decomposition check.** A driver/drag breakdown must sum back to the total change, partition
   one dimension, and state its method for ratios. Otherwise the "insight" is rejected.

Vocabulary follows [Apache Ossie discussion #465](https://github.com/apache/ossie/discussions/465)
(`additivity`, `non_additive_dimensions[{field, window}]`), and the contract ships both as a
standalone YAML file and as an Ossie `custom_extensions` payload. Design notes and the survey of
what dbt, Cube, Malloy, LookML, Rill, Snowflake, Databricks and others already support are in
[docs/design.md](docs/design.md).

## Install in two minutes

```bash
pip install "metric-contracts[all]"          # CLI + MCP server + DuckDB demo
git clone https://github.com/val-ye/metric-contracts && cd metric-contracts

metric-contracts validate examples/saas       # 0 errors
metric-contracts plan active_subscribers --grain month --dims plan -c examples/saas
metric-contracts plan active_subscribers --grain month --dims plan --agg sum -c examples/saas   # denied, P04
python demo/before_after.py                   # the before/after agent demo, offline
```

Python 3.11 or newer. The core package depends on pydantic, PyYAML and jsonschema only. The
`mcp`, `demo` and `live` extras add the MCP SDK, DuckDB and the Anthropic SDK.

## A contract

```yaml
contract_version: "0.1"
domain: subscriptions
owner: { team: analytics-engineering, contact: analytics-eng@example.com }
dimensions:
  - { name: date, type: time, grains: [day, week, month] }
  - { name: plan, type: categorical, values: [free, starter, pro, enterprise] }
metrics:
  - name: active_subscribers
    kind: measure
    formula: count(subscription_id)
    source: { table: fct_subscriber_snapshot_daily, grain: [date, subscription_id], time_dimension: date }
    additivity: semi_additive
    non_additive_dimensions:
      - { field: date, window: last }
    dimensions: { allowed: [date, plan, region], time_grains: [day, week, month] }
    refresh: { cadence: daily, lands_at: "06:00Z", settling_window: P1D }
    as_of: { semantics: snapshot, snapshot_at: end_of_day }
    caveats:
      - A month-end figure is the last daily snapshot, never a sum of days.
```

The full example with an additive measure, a semi-additive snapshot, a non-additive ratio and a
distinct count is in [examples/saas](examples/saas/subscriptions.contract.yaml). The JSON Schema
is committed under [schema/](schema/) and regenerated from the pydantic models in CI.

## Before and after

`python demo/before_after.py` seeds a synthetic DuckDB database and asks two scripted agents the
same question: total active subscribers by month for Q1, split by plan. Measured output of the
committed seed:

- Agent A has only the table name. It writes `COUNT(subscription_id) ... GROUP BY month` over a
  daily snapshot table and reports a total 23.3x the month-end truth.
- Agent B calls `plan_query` first, receives `{"date": "last", "plan": "sum"}` and a reference
  query, and matches the month-end truth row for row.
- Agent B then asks to SUM across months and is denied with
  `P04_sum_over_non_additive_dim: active_subscribers is semi-additive over date`.

`python demo/before_after.py --live` runs the same two prompts through a real model with
`plan_query` exposed as a tool (needs `ANTHROPIC_API_KEY` and the `live` extra) and prints the SQL
each one wrote.

## MCP server

```bash
metric-contracts serve -c examples/saas            # stdio
metric-contracts serve -c examples/saas --http     # streamable-http on 127.0.0.1:8765
```

Claude Desktop / Claude Code config:

```json
{ "mcpServers": { "metric-contracts": { "command": "metric-contracts", "args": ["serve", "-c", "/path/to/contracts"] } } }
```

Tools: `list_metrics`, `get_contract`, `plan_query`, `check_chart`, `check_decomposition`,
`list_dimensions`, `validate_contracts`. Resources: `contract://schema`,
`contract://schema/payload`, `contract://{domain}`. The server's instructions tell the agent to
plan before it queries and to treat a denial as an answer, not an obstacle.

## CI

```yaml
- uses: val-ye/metric-contracts@v0
  with:
    paths: contracts/
```

Or `pipx run metric-contracts validate contracts/ --format github`. Exit code 1 on any error;
findings are emitted as GitHub annotations.

## Rules

Every rule names the contract field it reads, so a veto is explainable and testable.

| Family | Ids | What they check |
|---|---|---|
| Validate | V00-V15 | schema, semi-additive names its dimension, allowed dims in registry, grains, ratio components, distinct count non-additive, source or components, owner, refresh, as-of, dangling components, ordinal funnel dims, unique names, no TODO markers |
| Plan | P01-P06 | unknown metric, dimension not allowed, grain not allowed, sum over the non-additive dimension, ratio aggregated directly, distinct count rolled up |
| Chart | C01-C14 | ratio stacked / pie'd / summed, semi-additive at coarse grain takes the declared window, semi-additive stacked over time, distinct count summed, pie cap, pie needs additive, funnel needs ordinal stages, duplicate axis values, dimension and grain allowed, settling window unmarked, series density |
| Decompose | D01-D06 | sums to total within tolerance, one partition, partition allowed, ratio needs a method, semi-additive deltas are last-to-last, residual disclosed and bounded |

Full tables with the field each rule reads: [docs/design.md](docs/design.md).

## Interop

```bash
metric-contracts import dbt target/semantic_manifest.json --out contracts/   # stubs with TODO markers
metric-contracts import ossie model.yaml --out contracts/
metric-contracts export ossie -c contracts/ --out model.ossie.yaml           # payload in custom_extensions
```

The dbt importer maps `non_additive_dimension.window_choice` `max` to `last` and `min` to
`first`, `count_distinct` to a non-additive distinct count, ratio metrics to `components`.
Refresh, as-of, owner and allowed dimensions are never inferred; they come back as `TODO` and
`validate` fails until a human declares them.

## What this is not

metric-contracts is not a query engine, not a semantic layer, and not a replacement for dbt,
Cube, MetricFlow, Malloy or LookML. It does not compute metrics. It holds the declarations those
tools leave implicit, checks them in CI, and hands them to agents, chart builders and insight
generators so the rules are enforced where the mistakes happen. The reference SQL `plan` emits
is a DuckDB illustration of the aggregation plan, not a dialect-aware compiler.

## Development

```bash
uv venv && uv pip install -e ".[dev]"
pytest
ruff check src tests demo examples && mypy
metric-contracts schema --write schema      # regenerate after a model change
```

Apache-2.0. Everything in `examples/` is synthetic.