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.
What is OSI?
Section titled “What is OSI?”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:
| Block | What it declares |
|---|---|
datasets | Logical 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. |
relationships | Join edges: a many-side dataset’s foreign-key columns mapped to the one-side dataset’s key columns. |
metrics | Model-level aggregates: dialect-tagged SQL like SUM(orders.amount), optionally spanning datasets. |
ai_context | Guidance 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.
How the feature works
Section titled “How the feature works”Two CLI flags drive the feature; both compose with a normal run:
# Run, export, convert to DuckDB, and write the semantic model next to itgo 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--duckdbreplaces the plain export: the per-machine Parquet files are written as the intermediary (Parquet carries the column types — dictionary-encoded categoricals,BIGINTids and counters,DOUBLEmeasures — so the import needs no type hints), imported as one table each into<scenario>.duckdbin the experiment directory, and deleted on success. The event log, when present, is additionally imported as anevent_logtable (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.--osiwrites<scenario>.osi.yamldescribing that database. Without--runit 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 element | OSI element | Notes |
|---|---|---|
| State machine | dataset | Name lowercased, spaces → underscores (Line Item → line_item); source <db>.main.<table>; primary key [ID]. |
System fields ID, TurnNumber, Age | plain fields | Described 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 field | field + dimension marker | The 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 marker | Described as a label drawn from the named catalog. |
Catalog field (func: from_catalog) | plain field | Described as the named attribute copied from the catalog entry. |
| Counter (int) | plain field + SUM metric | Baseline metric <table>_total_<field>. |
| Measure (float) | plain field + SUM/AVG metrics | Baseline metrics <table>_total_<field> and <table>_avg_<field>. |
| Dimension field (string) | field + dimension marker | Free-form categorical attribute. |
| Field family member | field per member | Exported columns carry brackets (stock_available[0]); SQL expressions double-quote them and metric names flatten them (..._stock_available_0). |
| Reference slot | <ref>_id field + relationship | One join edge per slot: from_columns: [<ref>_id] → to_columns: [ID] on the target dataset. |
| States | current_state field | dimension marker, with the machine’s state names as ai_context.examples. |
| Each machine | COUNT metric | Baseline 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.
Curated overlays
Section titled “Curated overlays”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
descriptionandai_contextreplace the generated ones. - Overlay
datasetsentries (matched by name) override that dataset’s description andai_context. - Overlay
metricslead 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.
Annotated example: OnlineShopping
Section titled “Annotated example: OnlineShopping”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.
1 — The source: the Order machine
Section titled “1 — The source: the Order machine”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)2 — The generated dataset
Section titled “2 — The generated dataset”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 Itemsimilarly becomesline_item). The logicalsourcematches where--duckdbactually 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_stateenumerates 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:
4 — The join graph
Section titled “4 — The join graph”Every reference: declaration becomes a many-to-one relationship; nothing is
inferred or guessed. At a glance:
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.- ①
Ordersanitises to the reserved wordorder; both the DDL (CREATE TABLE "order" ...) and every expression quote it, so the SQL executes as-is. - ② Each float measure gets a
SUMand anAVGbaseline metric; integer counters get aSUM. - ③–④ The Warehouse’s
stock_availablefield family exports one column per product (stock_available[0]…[4]): valid as quoted columns, flattened to_0…_4in metric names.
6 — What a curated overlay would add
Section titled “6 — What a curated overlay would add”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:
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.Consuming the model
Section titled “Consuming the model”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:
$ duckdb onlineshopping.duckdb -c 'SELECT AVG("order".order_total) FROM "order";'66.77Validation
Section titled “Validation”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:
| Tier | Meaning |
|---|---|
| Pass | The SQL executes against the populated database and returns a value. |
| Warn | The SQL executes but returns NULL or 0 (which can be legitimate when nothing matched in a short run). |
| Fail | The 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. |
How metrics are executed
Section titled “How metrics are executed”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.
Literal cross-check
Section titled “Literal cross-check”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.
When validation runs
Section titled “When validation runs”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 -ositogether 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).
Report format
Section titled “Report format”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 failReferences
Section titled “References”- 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.go—ConvertToDuckDB, the Parquet → DuckDB importer the model describes.scenarios/<Name>/osi.yamland<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.