Skip to content

OSI Semantic Layer

Mock Machines can describe its own output. At the end of an experiment the engine exports the entity tables into a single DuckDB database file, and alongside it generates an Open Semantic Interchange (OSI) semantic-model YAML — a vendor-neutral description of those tables: what each dataset means, how the tables join, and which metrics make sense over them. Any OSI-aware BI tool, semantic layer, or AI agent can pick up the pair and query the simulated data correctly without being told anything else.

The Open Semantic Interchange initiative is an open-source, Apache-2-licensed effort — launched by Snowflake with Salesforce/Tableau, dbt Labs, and others — to standardise how semantic models are exchanged between analytics, BI, and AI tools. A semantic model is the layer of business meaning above raw tables: which tables represent which business entities, how they join, which columns are dimensions, and how metrics like “churn rate” are defined as SQL. Historically each BI tool kept this layer in its own proprietary format; OSI defines one portable YAML document that all of them can read and write, so a metric is defined once and means the same thing everywhere — including to an AI agent deciding how to answer a question over the data.

An OSI document has four building blocks, all under a top-level semantic_model list:

BlockWhat it declares
datasetsLogical tables: a source reference (database.schema.table), a primary key, and named fields whose expressions are dialect-tagged scalar SQL. A field may carry a dimension marker.
relationshipsJoin edges: a many-side dataset’s foreign-key columns mapped to the one-side dataset’s key columns.
metricsModel-level aggregates: dialect-tagged SQL like SUM(orders.amount), optionally spanning datasets.
ai_contextGuidance for AI consumers at every level — instructions, synonyms, and example values — so an agent knows what the data means, not just its shape.

Why a semantic layer over simulation output?

Section titled “Why a semantic layer over simulation output?”

A Mock Machines experiment produces one table per state machine. The schema of those tables is a pure function of the config: the columns are the machine’s declared fields, its reference slots, and current_state. That means everything a semantic layer needs — table grain, join keys, dimension/measure classification, even the legal values of categorical columns — is already known before the simulation runs. Most teams hand-author this layer per dataset; Mock Machines can derive it automatically, and the part most layers never get (the full join graph) falls out of the config’s reference: declarations for free.

The OSI export pipeline A scenario config and optional osi.yaml overlay feed a simulation run, which exports per-machine Parquet files, converts them into one DuckDB database, and writes the OSI semantic model YAML next to it; BI tools and AI agents consume the database plus model pair. scenario config <Name>.yaml curated overlay osi.yaml (optional) run + Parquet export --run N DuckDB database --duckdb → <name>.duckdb OSI semantic model --osi → <name>.osi.yaml BI tools / AI agents query via the model
The OSI export pipeline: the config (plus an optional curated overlay) drives both the data and its description.

Two CLI flags drive the feature; both compose with a normal run:

Terminal window
# Run, export, convert to DuckDB, and write the semantic model next to it
go run main.go -run 50 -duckdb -osi scenarios/OnlineShopping/OnlineShopping.yaml
# Generate the semantic model alone from the config (no run, prints to stdout)
go run main.go -osi scenarios/OnlineShopping/OnlineShopping.yaml
  • --duckdb replaces the plain export: the per-machine Parquet files are written as the intermediary (Parquet carries the column types — dictionary-encoded categoricals, BIGINT ids and counters, DOUBLE measures — so the import needs no type hints), imported as one table each into <scenario>.duckdb in the experiment directory, and deleted on success. The event log, when present, is additionally imported as an event_log table (the JSONL stays on disk — it is the experiment’s reconcilable record). A machine that exported no rows still gets an empty table, so the database schema is always the full config schema.
  • --osi writes <scenario>.osi.yaml describing that database. Without --run it is an early-exit diagnostic like --dump: the model prints to stdout.

Through the facade the same operations are Experiment.ExportDuckDB(), Experiment.WriteOSIModel(overlayPath), and the config-only Lab.GenerateOSIModel(cfg, configDir); the generator itself lives in internal/engine/store/dataset/osi.go.

Generation rules: config → semantic model

Section titled “Generation rules: config → semantic model”

The generator walks the validated config and emits one OSI element per config element, mirroring the exported table schema exactly (machine fields in declaration order, then one <ref>_id column per reference slot, then current_state):

Config elementOSI elementNotes
State machinedatasetName lowercased, spaces → underscores (Line Itemline_item); source <db>.main.<table>; primary key [ID].
System fields ID, TurnNumber, Ageplain fieldsDescribed as the entity id, last-stepped turn, and turns-in-current-state. Turn counts are flagged as simulation time, not wall-clock, in the model’s ai_context.
Taxonomy fieldfield + dimension markerThe taxonomy’s resolved values are attached as ai_context.examples (capped at 16), so a consumer knows the legal values without sampling the data.
Catalog field (func: random)field + dimension markerDescribed as a label drawn from the named catalog.
Catalog field (func: from_catalog)plain fieldDescribed as the named attribute copied from the catalog entry.
Counter (int)plain field + SUM metricBaseline metric <table>_total_<field>.
Measure (float)plain field + SUM/AVG metricsBaseline metrics <table>_total_<field> and <table>_avg_<field>.
Dimension field (string)field + dimension markerFree-form categorical attribute.
Field family memberfield per memberExported columns carry brackets (stock_available[0]); SQL expressions double-quote them and metric names flatten them (..._stock_available_0).
Reference slot<ref>_id field + relationshipOne join edge per slot: from_columns: [<ref>_id]to_columns: [ID] on the target dataset.
Statescurrent_state fielddimension marker, with the machine’s state names as ai_context.examples.
Each machineCOUNT metricBaseline metric <table>_count.

All expressions use the ANSI_SQL dialect, which DuckDB executes directly. Names that collide with reserved SQL words are double-quoted — a machine named Order yields the table "order" in both the DuckDB DDL and every generated expression.

The generated baseline knows the data’s shape but not its business meaning: it cannot know that a churn rate should divide churned merchants by ever-activated merchants. A scenario package can therefore ship a curated overlay, scenarios/<Name>/osi.yaml, that merges over the baseline:

  • The overlay’s model-level description and ai_context replace the generated ones.
  • Overlay datasets entries (matched by name) override that dataset’s description and ai_context.
  • Overlay metrics lead the metric list, each displacing any same-named baseline metric.

PaymentProcessor, ProgrammaticAds, and ServiceQueue ship curated overlays (funnel conversion, churn and MRR; CTR, win rate and viewability; abandonment, wait and escalation rates). The generated result is checked in next to each config as <Name>.osi.yaml.

The OnlineShopping scenario is a good tour because it exercises every generation rule: five machines (Customer, Order, Line Item, Shipment, Warehouse), an inherited taxonomy, a product catalog with a derived price, a float measure, field families, and a machine whose table name is a reserved SQL word.

From scenarios/OnlineShopping/OnlineShopping.yaml:

Order:
init_state: pending
reference:
- { name: customer, entity: Customer } # ── becomes order.customer_id + a relationship
- { name: warehouse, entity: Warehouse } # ── becomes order.warehouse_id + a relationship
fields:
taxonomy:
- { name: region, func: inherit } # categorical dimension
catalog:
- { name: product, func: random, source: products } # catalog label
- { name: order_value, func: from_catalog, source: products, field: price }# derived price
measures:
- { name: order_total, func: default, init: 0.0 } # float measure (sum of line items)

Run go run main.go -osi scenarios/OnlineShopping/OnlineShopping.yaml and the Order machine becomes this dataset (trimmed to the interesting fields; every field also carries its dialect-tagged column expression):

semantic_model:
- name: onlineshopping
ai_context:
instructions: This database is generated by a Mock Machines finite-state-machine
simulation. Each dataset holds one row per simulated entity of that type;
current_state is the entity's FSM state at the end of the run, and
TurnNumber/Age are measured in simulation turns, not wall-clock time. ...
datasets:
- name: order # ① "Order" → table name, lowercased
source: onlineshopping.main.order # ② <db>.main.<table> — DuckDB names the
primary_key: [ID] # attached file database after the file
fields:
- name: region # ③ inherited taxonomy → dimension,
dimension: {} # with its legal values as examples
ai_context:
examples: [east, north, south, west]
description: Categorical dimension sampled from the "region" taxonomy.
- name: product # ④ catalog label → dimension
dimension: {}
description: Catalog label drawn from the "products" catalog.
- name: order_value # ⑤ from_catalog derivation, documented
description: Attribute "price" copied from the "products" catalog entry.
- name: order_total # ⑥ float measure → plain numeric field
description: Numeric measure accumulated by simulation update actions.
- name: customer_id # ⑦ reference slot → FK column
dimension: {}
description: Reference to the owning Customer entity (foreign key to customer.ID).
- name: current_state # ⑧ the FSM lifecycle, states enumerated
dimension: {} # for AI consumers
ai_context:
examples: [pending, cancelled, cancelling, confirmed, delivered,
lost_in_transit, rejected, return_pending, returned,
shipped, timed_out]

Annotations:

  • ①–② Dataset and source names follow the export naming rule (Line Item similarly becomes line_item). The logical source matches where --duckdb actually puts the table.
  • Taxonomy values travel with the model, so an AI agent can write WHERE region = 'east' without ever sampling the column.
  • ④–⑤ The catalog pair: the sampled label is a dimension, the derived price is numeric, and each says where it came from.
  • Measures stay plain numeric fields; their aggregates appear as metrics instead.
  • Reference slots surface twice: as an FK field here, and as a relationship below.
  • current_state enumerates the Order lifecycle — the most analytically important column in any Mock Machines export.

3 — The data tables: an ERD of the exported database

Section titled “3 — The data tables: an ERD of the exported database”

Converting an OnlineShopping experiment with --duckdb produces five entity tables (plus event_log). This is the schema the OSI datasets describe — every box below is one dataset, every column one field, and the connectors are the relationships the next section lists. ID and <ref>_id columns are numeric entity ids (BIGINT), so the foreign keys join directly:

Entity-relationship diagram of the OnlineShopping DuckDB tables Five tables — customer, order, line item, shipment, warehouse — with their columns; foreign keys customer_id, warehouse_id and order_id connect many-side tables to the ID primary key of their targets. N 1 N 1 N 1 N 1 N 1 customer ID BIGINT [PK] TurnNumber BIGINT Age BIGINT region VARCHAR order_count BIGINT current_state VARCHAR "order" ID BIGINT [PK] TurnNumber BIGINT Age BIGINT region VARCHAR product VARCHAR order_value DOUBLE order_total DOUBLE customer_id BIGINT [FK] warehouse_id BIGINT [FK] current_state VARCHAR line_item ID BIGINT [PK] TurnNumber BIGINT Age BIGINT product VARCHAR line_value DOUBLE quantity BIGINT order_id BIGINT [FK] current_state VARCHAR warehouse ID BIGINT [PK] TurnNumber BIGINT Age BIGINT region VARCHAR fulfilled_orders BIGINT orders_processing_today BIGINT stock_available[0–4] BIGINT stock_reserved[0–4] BIGINT current_state VARCHAR shipment ID BIGINT [PK] TurnNumber BIGINT Age BIGINT order_id BIGINT [FK] warehouse_id BIGINT [FK] current_state VARCHAR
ERD of the OnlineShopping DuckDB database: each table is one OSI dataset (the family columns are abbreviated to stock_available[0–4]); foreign keys connect to the ID primary key of their target, N rows to 1.

Every reference: declaration becomes a many-to-one relationship; nothing is inferred or guessed. At a glance:

The OnlineShopping join graph Order joins to customer and warehouse; line item joins to order; shipment joins to order and warehouse. Arrows point from the many side to the one side, labelled with the foreign-key column. customer_id warehouse_id order_id order_id warehouse_id customer Customer warehouse Warehouse "order" Order line_item Line Item shipment Shipment
The five relationships the generator derives: arrows run from the many side to the one side, labelled with the foreign-key column they join on.

And as the generator emits them:

relationships:
- name: line_item_to_order # Line Item.order → Order
from: line_item
to: order
from_columns: [order_id]
to_columns: [ID]
- name: order_to_customer # Order.customer → Customer
from: order
to: customer
from_columns: [customer_id]
to_columns: [ID]
- name: order_to_warehouse # Order.warehouse → Warehouse
from: order
to: warehouse
from_columns: [warehouse_id]
to_columns: [ID]
- name: shipment_to_order # Shipment.order → Order
from: shipment
to: order
from_columns: [order_id]
to_columns: [ID]
- name: shipment_to_warehouse # Shipment.warehouse → Warehouse
from: shipment
to: warehouse
from_columns: [warehouse_id]
to_columns: [ID]

This is the customer → order → line-item/shipment star a BI tool would otherwise need a human to draw.

5 — Baseline metrics, including the awkward names

Section titled “5 — Baseline metrics, including the awkward names”
metrics:
- name: order_count
expression:
dialects:
- dialect: ANSI_SQL
expression: COUNT("order".ID) # ① reserved word, so the table is quoted
description: Number of Order entities.
- name: order_avg_order_total
expression:
dialects:
- dialect: ANSI_SQL
expression: AVG("order".order_total) # ② measure → AVG (and a SUM sibling)
description: Average order_total per Order entity.
- name: warehouse_total_stock_available_0 # ③ family member, flattened metric name
expression:
dialects:
- dialect: ANSI_SQL
expression: SUM(warehouse."stock_available[0]") # ④ bracketed column, quoted
description: Total stock_available[0] across all Warehouse entities.
  • Order sanitises to the reserved word order; both the DDL (CREATE TABLE "order" ...) and every expression quote it, so the SQL executes as-is.
  • Each float measure gets a SUM and an AVG baseline metric; integer counters get a SUM.
  • ③–④ The Warehouse’s stock_available field family exports one column per product (stock_available[0][4]): valid as quoted columns, flattened to _0_4 in metric names.

OnlineShopping currently ships no osi.yaml, so the model above is the pure baseline. A curated overlay for it would look like this — drop the file next to the config and the next --osi run merges it in, listing these metrics first:

scenarios/OnlineShopping/osi.yaml
description: >-
Semantic model for the OnlineShopping scenario: customers placing orders
fulfilled from regional warehouses, with per-product stock tracking.
metrics:
- name: order_fulfilment_rate
expression:
dialects:
- dialect: ANSI_SQL
expression: >-
COUNT(CASE WHEN "order".current_state = 'delivered' THEN 1 END) * 1.0
/ NULLIF(COUNT(CASE WHEN "order".current_state IN
('delivered', 'cancelled', 'rejected', 'lost_in_transit',
'returned', 'timed_out') THEN 1 END), 0)
description: Delivered orders as a share of all closed orders.
- name: avg_delivered_order_value
expression:
dialects:
- dialect: ANSI_SQL
expression: AVG(CASE WHEN "order".current_state = 'delivered'
THEN "order".order_total END)
description: Average order total across delivered orders.

The experiment directory ends up holding the data and its description side by side:

experiments/<id>/
onlineshopping.duckdb # one table per machine + event_log
onlineshopping.osi.yaml # the OSI semantic model describing it
...

Any metric in the model is directly executable. For example, taking order_avg_order_total’s expression and running it with the DuckDB CLI:

Terminal window
$ duckdb onlineshopping.duckdb -c 'SELECT AVG("order".order_total) FROM "order";'
66.77

Every metric in the model carries a dialect: ANSI_SQL expression, and that expression is only useful if it actually runs against the database the model describes. Validation answers one question per metric — does this SQL bind and execute over the populated database? — and reports a tier for each. Per-table evaluation semantics stay the consumer’s concern, exactly as the evaluation note above describes; the check is solely that the expression resolves and runs, not that it computes a particular business figure.

Every metric is reported — there is no first-error-wins. Each lands in one tier:

TierMeaning
PassThe SQL executes against the populated database and returns a value.
WarnThe SQL executes but returns NULL or 0 (which can be legitimate when nothing matched in a short run).
FailThe metric’s SQL errors against the populated database — a binder error such as a missing column or table, or a malformed expression. The DuckDB error text is captured and reported with the metric.

There is no SQL parser. The FROM clause is derived deterministically: each bare aggregate expression is scanned for <dataset>. and "<dataset>". references over the model’s closed set of dataset names, then wrapped as SELECT <expr> FROM <t1>[, <t2>…] (a cross join when a metric names more than one table) and executed via the DuckDB CLI. The only thing under test is that the SQL binds and executes; the per-table evaluation semantics remain the consumer’s concern.

Single-quoted literals in metric expressions are matched against the config’s state names, taxonomy values, and catalog labels. This check needs no database: it reads the config alone. An unmatched literal raises an advisory warning that is orthogonal to the Pass/Warn/Fail tier: it rides on the metric’s line whatever tier the database execution assigned — including a Pass — and never changes the tier itself (a metric that fails to bind is not annotated).

This catches the silent state-rename case precisely. A metric filtering on 'Active Merchant' after that state is renamed still binds and runs — but returns nothing, forever, with no SQL error. Only the literal cross-check flags it, because the SQL itself is perfectly valid; nothing matches the renamed state, so the database tiers would see only a legitimate-looking empty result.

Both entry points report the same tiers and exit non-zero on any Fail (a Warn never fails the run):

  • Automatically at creation. Running with the flag combination -run <turns> -duckdb -osi together validates every metric against the database the run just produced, and exits non-zero if any metric Fails.
  • On demand. The scenario osi-check <dir> verb takes one argument — a scenario package directory. It generates a throwaway database (a small 20-seed × 10-turn run, exported and then discarded), validates every metric in the model — the config-derived baseline plus the package’s curated overlay — and exits non-zero on any Fail (or if the DuckDB CLI is absent).

Both paths print the same report:

=== OSI Model Validation (N metric(s)) ===
PASS <metric>
PASS <metric>: <warning text> (a literal-mismatch advisory rides on its line, whatever the tier)
WARN <metric>
FAIL <metric>: <duckdb error>
OSI validation: P pass / W warn / F fail
  • OSI specification repository — the core spec (spec.md), machine-readable schema (spec.yaml, osi-schema.json), and examples.
  • internal/engine/store/dataset/osi.go — the generator (document types, BuildOSIModel, overlay merge) and the validator (FROM-clause derivation, metric execution, literal cross-check).
  • internal/engine/store/dataset/export.goConvertToDuckDB, the Parquet → DuckDB importer the model describes.
  • scenarios/<Name>/osi.yaml and <Name>.osi.yaml — curated overlays and generated models for PaymentProcessor, ProgrammaticAds, and ServiceQueue.
  • Data Storage — how the experiment dataset, event log, and exports fit together.