Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Scenario harness — ox run-scenario

An .ar package declares the ontology; it does not carry data. So a built .oxbin queried with no fixtures answers against an empty store. A scenario is a small .toml script that seeds the store and then reads it back, so a model ships with realistic, reproducible state — and, with assertions, with executable checks.

A scenario file lists an ordered sequence of steps. Each [[step]] is tagged by do:

[[step]]
do   = "mutate"                  # invoke a declared mutation
path = "norms::register"
args = { p = "alice", age = 25 }

[[step]]
do     = "query"                 # run a declared query, print its rows
path   = "norms::findEmployeeNamesOfCompany"
args   = { c = "acme" }
label  = "employees of acme"     # optional, echoed in the output

[[step]]
do      = "derive"               # dump a derived predicate's extent
name    = "spouses"              # the rule head's short name
explain = true                   # optional: annotate each tuple with its proof tag

[[step]]
do    = "compute"                # invoke a declared `fn`, print its single result
path  = "norms::getSpouse"
args  = { p = "alice" }

Document order is execution order. A scenario interleaves writes and reads: populate, look, mutate again, look again — each read sees the state at that point. The four kinds map to the same operations as the standalone commands: a mutate step runs a declared mutation (with the same check-guard handling as a write over HTTP); a query step runs a declared pub query and prints its rows; a derive step dumps a derivation’s extent by its rule-head short name, the read ox derive <oxbin> <rule> performs (with explain = true adding the Rule atom — fn, derive, query, mutate, check proof tag per tuple); a compute step evaluates a declared pub fn and prints the single value it returns.

A query step’s args is what makes a parameterized declared query do a keyed lookup. A pub query findEmployeeNamesOfCompany(c: Company) -> … whose body binds c looks c up only when c is supplied; with no args, the parameter degrades to a free join variable and the query enumerates the whole extent. A derive step takes no args — a derivation has no parameters, so it always dumps the full extent.

Argument typing

Each args table maps a parameter name to a TOML value, typed into the runtime’s value space by one shared rule (the same path expect tuples use, below):

TOML formRuntime value
bare string "alice"an individual (hashed by name, identical to pub fact P(alice);)
{ individual = "alice" }the same individual (explicit form)
{ text = "active" }a String value (use this for a string-typed field, since a bare string is an individual)
integer 25Int
boolean trueBool
{ decimal = "0.22" } / { money = "100.50" } / { real = "…" }, or a bare float 0.22an exact Decimal / Money / Real, parsed exactly (never via f64)
{ date = "2024-01-01" }a Date

A bare string defaulting to an individual is the load-bearing convention: the same name in the source and in the scenario refers to the same entity. Array values and a non-finite float are rejected with a clear diagnostic.

Assertions — expect

Add an expect table to a step and it stops being a printer and becomes a test: its result is diffed against the expectation and a PASS/FAIL verdict is reported. Which sub-keys apply depends on the step kind:

[[step]]
do     = "query"
path   = "norms::findEmployeeNamesOfCompany"
args   = { c = "acme" }
expect = { equals = [ [{ text = "Dave" }], [{ text = "Alice" }] ] }

[[step]]
do     = "derive"
name   = "isDisenfranchised"
expect = { empty = true }

[[step]]
do     = "compute"
path   = "norms::getSpouse"
args   = { p = "alice" }
expect = { value = "dave" }      # a single expected value

[[step]]
do       = "mutate"
path     = "norms::register"
args     = { p = "bob", age = 12 }
expect   = { rejected = "Norms::E001" }   # assert a check guard rejects it
  • A query or derive step takes the row-set sub-keys: rows = N (exact count), contains = [tuple, …] (every listed tuple is present, count-sensitive), equals = [tuple, …] (the result set matches exactly, order-insensitive multiset equality), empty = true (no rows).
  • A compute step takes value = <toml value>: a single expected value.
  • A mutate step takes rejected = "<CODE>": assert the mutation is rejected by a check guard carrying that user code. A rejection by that code is a PASS and the run continues (the rejected transaction committed nothing); a success or a rejection by a different code is a FAIL. Without rejected, a guard rejection halts the scenario.

The correctness crux is that an expected value is typed through the same rule as the step args, and the diff is by value identity — never by rendered string. So an entity-valued result column matches a bare-string or { individual = "…" } expectation, but never a { text = "…" } one; a Text "dave" is never equal to the individual dave. A sub-key applied to the wrong kind (value on a query, rows on a compute, an unknown key) is a loud scenario error, not a silent no-op.

Files, discovery, and running

A package carries scenarios as a sibling demo.toml and/or any number of files under a scenarios/ subdirectory:

ox run-scenario <pkg>                       # run every discovered scenario
ox run-scenario <pkg> --scenario voting     # run scenarios/voting.toml (or a sibling voting.toml)
ox run-scenario <pkg> --scenario path/to.toml
ox run-scenario <pkg> --extent norms::Voter  # also print a concept extent after the run

With no --scenario, run-scenario discovers every scenario — the sibling demo.toml plus every scenarios/*.toml, filename-sorted for determinism — and runs them all. Each scenario runs against its own freshly-loaded, freshly-seeded store re-derived from the artifact bytes, so a mutation in one scenario cannot leak into another’s reads and the order across files is irrelevant. --scenario runs exactly one: a path verbatim, or a bare name resolving to scenarios/<name>.toml then a sibling <name>.toml. --extent <concept> prints that concept’s extent once the scenario has run.

run-scenario collects every failed assertion (it does not stop at the first) and exits non-zero if any assertion — or any scenario — failed, naming the offending file. A scenario therefore doubles as a CI-usable check.

A single file uses either [[step]] or the older flat [[mutate]] list (a pure population script, applied in order), never both — the whole point of [[step]] is interleaving, which a split across two arrays cannot express. The flat [[mutate]] form is also what ox query and ox derive apply when they find a sibling demo.toml: they seed from [[mutate]] fixtures, then run their own read. A [[step]] scenario beside one of those commands is not run by them (they have nowhere to surface the interleaved reads); they print a warning pointing at ox run-scenario.

The scenarios/ directory is the integration-.toml surface, distinct from tests/, which holds the in-language test atom (The test atom — ox test). The two are complementary: scenarios/ scripts the runtime from outside the language with a .toml harness; the test atom expresses checks inside the language.