Skip to content

Ontologies (experimental)

Experimental

This page documents a feature that is still being shaped. Flags, output and error payloads may change without notice.

Dosi can load an Ossie ontology alongside — or instead of — a core Ossie model. An ontology adds no information a vendor extension could not carry. What it adds is that one model can bear several of them, and they may disagree: operations means the departure airport by "airport delay", ground handling means the arrival one. Both are right, and each is a separate ontology over the same tables.

Everything below is the dosi CLI; Python shows the same calls through the Python binding. Examples use three of the bundled example models (see Verify it works for $DOSI_EXAMPLES): ossie-mini-flights (two concepts), aviation (a hand-written model plus an ontology) and ossie-flights (the upstream reference ontology).

Loading an ontology

An ontology reaches Dosi through a mapping document. A mapping names one ontology and one semantic model, and maps the model's columns onto the ontology's concepts. It is the only one of the three documents that points at the others, so it is the one you load.

These flags apply to every command:

Flag Env Meaning
--mapping <path> DOSI_MAPPING A mapping document
--ontology <path> DOSI_ONTOLOGY An ontology document, when the mapping should not find it on its own. Beside a --model directory, it picks that ontology's mappings
--model <path> DOSI_MODEL A semantic model. A mapping or ontology document given here is recognized by its structure, and a directory is read as a catalog

Three ways to write it

One file. The mapping carries the ontology and the model inline, each under document:. This is the quickest way to start, and ossie-mini-flights is written this way:

version: 0.2.0.dev0
name: mini_flights_mapping
ontology_ref:
  name: MiniFlights
  document:              # the whole ontology document
    version: 0.2.0.dev0
    name: MiniFlights
    ontology: [...]
semantic_model_ref:
  name: mini_flights_model
  document:              # the whole semantic model
    version: 0.2.0.dev0
    name: mini_flights_model
    datasets: [...]
concept_mappings: [...]

Three files. Each reference gives an iri instead, resolved against the mapping's own directory (iri: ./ontology.yaml). ossie-flights and aviation are written this way.

A directory. --model <dir> reads every .yaml, .yml and .json file at the top of the directory, and a reference with neither iri nor document is found by its name. Files that are not Ossie documents, such as a queries.yaml, are skipped. When the directory holds mappings onto more than one ontology, add --ontology <file> to load one of them: only the mappings whose ontology_ref names it are read, along with the model each one names. A mapping that carries its ontology inline is bound to that one and is passed over. If no mapping names the ontology, the load is refused (ontology_ref_unresolved). --mapping does not go with a directory.

$ dosi describe --mapping $DOSI_EXAMPLES/ossie-mini-flights/mapping.yaml
$ dosi describe --mapping $DOSI_EXAMPLES/aviation/mapping.yaml
$ dosi describe --model $DOSI_EXAMPLES/aviation
$ dosi describe --model ./catalog --ontology ./catalog/flights.ontology.yaml

Note

The inline document: is proposed in the Ossie community but is not yet in the published mapping schema, so the upstream validator rejects it until it lands. Dosi reads it in both engine modes.

These rules hold in every layout:

  • A reference's name must equal the name of the document it resolves to (ontology_ref_mismatch).
  • --ontology and --model win over the iri a mapping was written with, and the name is still checked. An inline document together with a flag for the same reference is refused (ontology_ref_conflict).
  • An iri is a local path or a file:// IRI. Dosi does not fetch documents over the network: resolve those through your catalog and pass the file.
  • Several mappings onto one model, found in a directory, are merged. A concept mapped twice is refused (ontology_mapping_conflict). Mappings onto different models are refused for now; load one at a time.
  • The model is the one the mapping names. A complete hand-written model such as aviation's keeps everything it declares, and where it already declares a join with the same columns as an ontology link, the concept walks the model's relationship instead of adding a second one. A model that carries only datasets and fields, such as ossie-flights', gets its keys, relationships and metrics from the ontology.

The embedded layout (deprecated)

An ontology may still carry its mapping under ontology_mappings, with the semantic model embedded in it; that is how the format began. --ontology o.yaml loads it, with a legacy_embedded_mapping note, and --model m.yaml --ontology o.yaml replaces the embedded model with m.yaml. One embedded mapping is loaded at a time: split several into mapping documents. Dosi will stop reading this layout when the Ossie specification does.

What loading produces

The ontology is lowered to an ordinary core Ossie model, which then goes through the same validate → compile → plan path a hand-written model does. A ConceptMap is kept beside it; it only names things — a concept path to the dataset, field and join path it resolves to.

Ontology construct What it becomes
identify_by with an object mapping the dataset's primary_key, unless the model already declares one
Entity → Value relationship a name for an existing field; temporal value types mark it is_time
Entity → Entity, ManyToOne / OneToOne a relationship named <owner>_<link>, e.g. flight_departs_from
Entity stored on another entity's table no join, no primary key, not a query root — see Limitations
Unary relationship with a filtered mapping a boolean field <owner>_<name>, reachable as Concept.name
derived_by: AGG[<value> WHERE <correlation> GROUP BY <concept>] a metric <owner>_<relationship> — see Derived metrics

What is not lowered is reported as a note: line on stderr on every call:

$ dosi describe --mapping $DOSI_EXAMPLES/ossie-flights/mapping.yaml
note: City.state: State has no table of its own; reached without a join
note: Airport.city: City has no table of its own; reached without a join
note: Flight.registers_latitude_series: n-ary relationship recorded as unmapped
...

Browsing: describe and paths

dosi describe [concept]

With no argument, the concept catalog:

$ dosi describe --mapping $DOSI_EXAMPLES/ossie-mini-flights/mapping.yaml
ontology MiniFlights
Two airports' worth of ontology, kept small enough to read whole.
concept  identity  out  in  props  root
Airport  code      0    2   3      yes
Flight   id        2    0   2      yes

out and in count links, props counts properties, and root says whether a query can start from the concept.

With a concept name, everything needed to write a query against it:

$ dosi describe FlightAlert --mapping $DOSI_EXAMPLES/aviation/mapping.yaml
concept FlightAlert
identity: id  (root: flight_alerts)

properties
path                            type                    domain                   time
FlightAlert.id                  AlertId (String)
FlightAlert.type                AlertType (String)
FlightAlert.priority            AlertPriority (String)  one of [P0, P1, P2, P3]
FlightAlert.status              AlertStatus (String)    one of [OPEN, RESOLVED]
FlightAlert.created_at          DateTime                                         yes
FlightAlert.resolved_at         DateTime                                         yes
FlightAlert.resolution_comment  String

links out
name    role    to      multiplicity  join
flight  flight  Flight  ManyToOne     yes

metrics by group-by
group by        metrics
FlightAlert.id  alert_count, open_alert_count, flights_with_open_alert, maintenance_alerts

time dimensions: FlightAlert.created_at, FlightAlert.flight.actual_arrival, FlightAlert.flight.actual_departure, FlightAlert.flight.scheduled_arrival, FlightAlert.flight.scheduled_departure, FlightAlert.resolved_at
Section What it tells you
properties Every mapped property as a concept path, with its value type, closed value domain and time flag
links out Name, role, target and multiplicity. join: none means the target is stored on this table
links in (query from) Concepts that link here — the side to root a query on
metrics reachable Metrics the ontology derived, with the group-by and the edges (via) that give them their meaning
metrics by group-by The model's own metrics this concept can group, each under a group-by that walks from the concept the metric lives on. Every listed group-by is planned before it is printed
time dimensions Time properties on the concept and one hop out, for a time range
unmapped Relationships declared but not lowered: n-ary, or links with no mapping
not queryable Properties bound to no column (row-level derived_by, mostly)

Every path in the tables can be pasted into select or query unchanged. That is the contract that makes the view worth calling.

--format json returns the same content: concept, identity, root, properties (path, type_name, domain, domain_summary, is_time, mapped), links, incoming (query_from), metrics_reachable (name, metric, grain, group_by, via), groupable_metrics (group_by, from, metrics), time_dimensions_reachable, default_projection and unmapped. The catalog is ontology, description, requires and concepts (name, identity, links_out, links_in, properties, queryable, summary).

Without an ontology, describe with no argument lists datasets and metrics instead, and naming a concept is an error:

$ dosi describe Airport --model $DOSI_EXAMPLES/ossie-mini-flights/model.yaml
error: this model has no ontology, so "Airport" is not a concept; pass --mapping <path>, or use `list` for datasets and metrics

dosi paths --from <concept> --to <concept>

Every named path between two concepts, up to three hops, with the roles walked and the pairs the ontology declares equivalent:

$ dosi paths --from Flight --to Airport --mapping $DOSI_EXAMPLES/aviation/mapping.yaml
paths Flight → Airport
path                              roles                        hops  multiplicity  join
Flight.arrives_at                 destination                  1     ManyToOne     yes
Flight.departs_from               origin                       1     ManyToOne     yes
Flight.follows_route.destination  follows_route → destination  2     ManyToOne     yes
Flight.follows_route.origin       follows_route → origin       2     ManyToOne     yes
equivalent: Flight.departs_from ≡ Flight.follows_route.origin  (Flight.follows_route.origin == origin)
equivalent: Flight.arrives_at ≡ Flight.follows_route.destination  (Flight.follows_route.destination == destination)

Two paths to one target is exactly the case a bare dataset.field reference cannot resolve (ambiguous_join_path); the role names are what separate them. Equivalences come from relationship requires of the form Concept.link.role == role. JSON adds unique: true when exactly one path exists.

Concept paths in queries

With an ontology loaded, select and query accept concept spellings wherever they take a field:

Where Spelling
select --from a concept name: FlightAlert
fields, --group-by Concept.property, Concept.link.property, Concept.link.link.property
--where the same paths; string literals are never rewritten
--time-dimension a time property path: Delay.flight.scheduled_departure
--metrics a derived metric's concept path: Airport.average_departure_delay
select --from, --group-by a derived subtype: PremierCustomer — the parent's rows that satisfy its rule, or its membership as a boolean dimension
--where, D-DERIVE filter.where <Concept> is [not] <Subtype>: Customer is PremierCustomer, Membership.held_by is not PremierCustomer

Each resolves to the dataset, field and relationship path the planner already understood, so the SQL is identical to the dataset spelling:

$ dosi select --mapping $DOSI_EXAMPLES/aviation/mapping.yaml \
    --from FlightAlert \
    --fields "FlightAlert.id,FlightAlert.priority,FlightAlert.flight.arrives_at.code" \
    --where "FlightAlert.status = 'OPEN' AND FlightAlert.priority = 'P1'"
SELECT flight_alerts.alert_id AS alert_id, flight_alerts.priority AS priority, airports.airport_code AS flight_alerts_flight__flights_arrives_at__airport_code FROM flight_alerts AS flight_alerts LEFT JOIN flights AS flights ON flight_alerts.flight_id = flights.flight_id LEFT JOIN airports AS airports ON flights.destination_airport_code = airports.airport_code WHERE flight_alerts.status = 'OPEN' AND flight_alerts.priority = 'P1' LIMIT 100

Output columns keep the planner's names, and --order takes those names, not concept paths.

Derived metrics

The correlation path of a derived_by rule is what gives the metric its meaning. Two such metrics can compile to byte-identical SQL and still mean different things:

$ duckdb mini.duckdb < $DOSI_EXAMPLES/ossie-mini-flights/seed.sql
$ dosi query --mapping $DOSI_EXAMPLES/ossie-mini-flights/mapping.yaml \
    --metrics Airport.average_departure_delay --group-by Airport.code --execute --db mini.duckdb
code  airport_average_departure_delay
ATL   16
ORD   30
DCA   20
SFO   32
4 rows

$ dosi query --mapping $DOSI_EXAMPLES/ossie-mini-flights/mapping.yaml \
    --metrics Airport.average_inbound_departure_delay --group-by Airport.code --execute --db mini.duckdb
code  airport_average_inbound_departure_delay
ATL   30
DCA   16
SFO   35
ORD   10
4 rows

Both are AVG(FLIGHT.dep_delay). Named by its concept path, each metric groups through its own edge — the first through the departure airport, the second through the arrival airport. Two consequences are deliberate:

  • Asking for the model metric by its flat name (airport_average_departure_delay) names no path, so grouping by Airport.code is ambiguous_join_path: the model alone cannot tell the two apart.
  • Asking for both at once names two paths that disagree, and the ambiguity is reported rather than resolved to one side.

A rule is lowered whole or not at all. A WHERE that is not a correlation (WHERE Flight.departure_delay > 10), or a correlation over a link that does not resolve, leaves the rule as text with a note:; no metric is emitted.

When a group-by is rooted on the wrong side

A metric grouped through a path that starts on a concept it cannot reach is refused by the planner. With an ontology loaded, the refusal names the repair — the same path, walked from the concept the metric lives on:

$ dosi query --mapping $DOSI_EXAMPLES/aviation/mapping.yaml \
    --metrics delayed_flights --group-by Flight.arrives_at.code
error: InvalidRelationshipPath: a relationship path pins this query to dataset "flights", which has no many→one path to "delays" — where measure "delays_flight_id_count_distinct" of metric "delayed_flights" lives
  candidates: Delay.flight.arrives_at.code
  hint: root the group-by on the concept the metric lives on: Delay.flight.arrives_at.code

Every candidate is planned before it is offered; at most three, shortest first. Under --format json they are candidates and suggested_retry.

Derived subtypes

A rule-defined subset of an entity — Premier customers, delayed flights, high-risk orders — is a derived subtype: an EntityType that extends one parent and carries one concept-level derived_by rule, in the specification's own words (its example is Employee extends Person, derived_by EXISTS(Person.earns)). The rule is written once, over the parent's properties; the subtype is then a query root, a dimension and a filter, and may add relationships of its own.

- concept: PremierCustomer
  type: EntityType
  extends: [Customer]
  description: Premier qualification. Eligibility, not enrollment.
  derived_by:
  - 'Customer.margin >= 3000 AND Customer.order_count >= 6 AND Customer.return_rate <= 0.20'
  relationships:
  - name: qualifies_for
    roles: [{concept: Membership}]
    multiplicity: ManyToOne

The subtype needs no object_mappings: it inherits the parent's identity, table and properties. A relationship it adds is mapped with link_mappings under its own concept and exists only there (Customer.qualifies_for stays unknown).

What lowering makes of it. One boolean field on the parent's dataset, named after the concept (customers.premier_customer), whose expression is the rule translated one hop — Customer.margin to the column that property maps to, a unary fact to its field, Customer is Gold to Gold's flag — wrapped in COALESCE(…, FALSE). A chain (PremierPlus extends [PremierCustomer]) ANDs the parent's flag in. The field is a dimension (is_dimension: true) and may be named directly by its dataset spelling; the planner never hears the word "subtype".

The rule's grammar is boolean algebra over the parent's mapped properties and nothing else — aggregation happens first, through a D-LOD fixed(metric, key) field, so margin and order_count above are properties:

expr  := expr AND expr | expr OR expr | NOT expr | '(' expr ')' | atom
atom  := attr cmp value | attr IS [NOT] NULL | attr [NOT] IN '(' value, … ')'
       | attr                                  -- a boolean (unary) property
       | Concept 'is' [NOT] Concept            -- another subtype of the parent
value := number | 'string' | TRUE | FALSE | ':' name
attr  := Concept '.' name                      -- one hop: the parent's own property
cmp   := = | == | <> | != | < | <= | > | >=

Anything else — a function, an aggregate, a two-hop path (Customer.orders.amount), EXISTS — is ontology_subtype_rule_unsupported with a hint, never guessed at.

On the query surface:

$ dosi describe PremierCustomer --mapping northstar.yaml
concept PremierCustomer  (extends Customer)
rule: Customer.margin >= 3000 AND Customer.order_count >= 6 AND Customer.return_rate <= 0.20
identity: id  (root: customers, inherited from Customer)
…
$ dosi select --from PremierCustomer --fields PremierCustomer.name,Customer.margin   # the parent's rows, filtered
$ dosi query --metrics booked_revenue --group-by PremierCustomer                      # true / false
$ dosi query --metrics booked_revenue --where "Customer is PremierCustomer"
$ dosi select --from Membership --fields Membership.held_by.name --where "Membership.held_by is not PremierCustomer"

is is sugar the ontology resolves before planning (customers.premier_customer = TRUE); it never reaches the SQL grammar, and IS NULL is left to SQL. A name after is that is not a subtype of that concept is unknown_subtype, with the concept's subtypes as candidates. describe Customer lists derived_subtypes. A D-DERIVE filter may use the same spelling: "where": "Customer is PremierCustomer" — on a fact metric too, since a guard on a dimension the fact reaches many→one is admitted (S-ATTR-3′).

Thresholds as parameters. A rule may hold :name placeholders, declared on the mapping document under concept_params and bound at query time with --param margin_min=4000 — a what-if that changes no file. See D-PARAM. A literal rule is standard Ossie and works under --osi-basic; a placeholder rule is a DATUS feature, and basic mode records the subtype unmapped (describable, not queryable).

The flag column takes no verbalizes of its own, and the subtype's rows are evaluated in the query's time context like every level of detail: select --from PremierCustomer --time-range … is the Premier set within the window.

Value domains

A ValueType whose requires enumerates a closed set makes a filter literal checkable before the query is compiled:

$ dosi select --mapping $DOSI_EXAMPLES/aviation/mapping.yaml \
    --from FlightAlert --fields FlightAlert.id --where "FlightAlert.status = 'unresolved'"
error: value_not_in_domain: "unresolved" is not a value of FlightAlert.status; its domain is a closed set
  allowed: OPEN, RESOLVED
{
  "code": "value_not_in_domain",
  "violations": [
    {
      "reference": "FlightAlert.status",
      "literal": "Open",
      "allowed": ["OPEN", "RESOLVED"]
    }
  ]
}

This is what profiling the warehouse cannot do: a probe finds the values a column holds today, but only the declaration asserts the set is closed.

What is checked, exactly:

  • Enumerations written X == 'A' OR X == 'B', whose every value is a literal. A rule comparing to anything else (Status == Other.status) contributes no domain.
  • Predicates of the form <path> = '<literal>' and <path> IN ('a', 'b'), with '' read as a quote inside the literal. Text inside a string literal is never taken for a reference.
  • Ranges (1 <= X, X <= 10) are shown by describe but not checked, and neither are numeric literals. Cross-field, cross-relationship and model-level constraints are carried verbatim and enforced by nothing.

Lineage

dosi lineage draws the metric lineage graph — physical tables → datasets → atomic metrics → derived metrics — and, with an ontology loaded, the concepts on top of it. The graph itself is documented on Metric lineage; this section is what the ontology adds.

$ dosi lineage view --mapping $DOSI_EXAMPLES/ossie-mini-flights/mapping.yaml
$ dosi lineage view --mapping $DOSI_EXAMPLES/ossie-flights/mapping.yaml --no-open --out flights.html
flights.html
$ dosi lineage dump --mapping $DOSI_EXAMPLES/ossie-mini-flights/mapping.yaml > graph.json

view goes straight from the model to one HTML page — it does not read a dump — and the page is self-contained: the graph is inlined, nothing is fetched, and it opens over file://. It opens in the default browser, or, with --no-open or no browser, prints the path. The note: lines from loading still go to stderr.

The page has two views. Lineage lays the lanes out left to right with a concept lane inserted between datasets and metrics; an entity with no table of its own has a dashed border, and each grain edge is labelled with its path. Concepts shows the entities as an ER diagram, properties folded into each card, parallel relationships drawn apart. Selecting a node in either view shows its detail and its edges; clicking an edge goes to the other end, switching views when it has to.

What the ontology adds

Nothing in the plain graph moves: the four lanes, the seven edge kinds and every node and edge id are unchanged, and a model with no ontology has no ontology key at all. An ontology adds:

Added Where What
entity nodes ontology.nodes one per entity concept, id concept:<Name>
mapping edges ontology.edges dataset → concept
relationship edges ontology.edges concept → concept
subtype edges ontology.edges derived subtype → parent, with rule and flag (the dataset.field it lowered to)
concept object metric nodes in nodes what a derived_by rule says about the metric: path, measure, group_by, via, realized_by

The three edge kinds are lineage — where a concept's data is. A derived subtype has no mapping edge of its own and repeats none of the relationship edges it inherits: its node carries subtype_of, and the subtype edge says which rows of the parent it keeps. The concept object is semantics, which is why it sits on the node beside dimensions rather than being an edge of its own.

One field is new on every graph, ontology or not: each entry in a dataset node's detail.fields carries label — the field's label in the model, else its name.

Value types are not nodes; they appear on their entity as properties[].concept. Overlay edges point at plain-graph nodes (dataset:, metric:) and at entity nodes (concept:), so a renderer looks endpoints up in nodes and ontology.nodes together.

Entity nodes

{
  "id": "concept:Airport", "kind": "entity", "name": "Airport", "label": "Airport",
  "mapping": "table", "dataset": "AIRPORT", "key_columns": ["code"], "identity": ["code"],
  "properties": [
    { "name": "code", "concept": "AirportCode", "base": "String",
      "field": "AIRPORT.code", "is_time": false, "derived": false }
  ]
}
Field Meaning
mapping what the ontology mapping landed on. table: a dataset of its own, one row per object. denormalized: no table of its own — its columns sit on another entity's table, whose rows they are a projection of (City on AIRPORT, where two Californian airports make State CA twice). unmapped: nothing it declares resolves to a column
dataset, key_columns the dataset and key it is identified by — only when mapping is table
identity the relationships named in identify_by
properties[] name; concept, the value type; base, the built-in it reduces to; field, as dataset.field, absent when unmapped; is_time; derived, computed by derived_by; domain, the closed value set when there is one; description
unmapped relationships declared but not lowered — the list describe prints

description appears when the ontology gives one. A key with nothing to say is absent, not null.

Edges

Kind Source → target Payload Id
mapping dataset:D → concept:C via: identity (the entity's own dataset) or property (a dataset its properties are denormalized onto — one edge per such dataset) mapping:dataset:D->concept:C
relationship concept:A → concept:B name, role, multiplicity, description; realized_by, the join: edge ids it walks; no_join, true when the target sits on the same table (then realized_by is empty); equivalences, the relationship's requires, verbatim relationship:A.<name>

The concept object on a metric node

Only metrics the ontology derived have one. A metric written by hand in the model, as in aviation, keeps its aggregates edge from its dataset and gains nothing.

Field Meaning
path the concept spelling, e.g. Airport.average_departure_delay — paste it into query --metrics
measure the value the rule aggregates, as a concept path: Flight.departure_delay
group_by the concept the rule groups by
via the relationship: ids walked from the measured concept to the grain
realized_by the same path as plain-projection join: ids

The two metrics from Derived metrics:

{ "id": "metric:airport_average_departure_delay", "concept": {
    "path": "Airport.average_departure_delay", "measure": "Flight.departure_delay",
    "group_by": "Airport", "via": ["relationship:Flight.departs_from"],
    "realized_by": ["join:flight_departs_from"] } }
{ "id": "metric:airport_average_inbound_departure_delay", "concept": {
    "path": "Airport.average_inbound_departure_delay", "measure": "Flight.departure_delay",
    "group_by": "Airport", "via": ["relationship:Flight.arrives_at"],
    "realized_by": ["join:flight_arrives_at"] } }

In the plain graph each metric has one in-edge, dataset:FLIGHT → metric, and nothing else; via is what tells them apart. The viewer draws it as an arrow from the grain concept to the metric, labelled with the path.

--redact-sql leaves the ontology block unchanged — it holds no SQL. In Python, Engine(mapping_path=…) loads the ontology and lineage() returns the same graph.

Names: dosi's, not the specification's

The overlay's kinds and fields are dosi's lineage vocabulary. The Apache Ossie ontology specification defines concepts, relationships, roles, derived_by, requires and ontology mappings; it has no grounds, measures, grain or grounding. How each maps back:

In the graph From the specification
entity node; concept.path a concept of type: EntityType. Concept.relationship is how the specification identifies a relationship
relationship edge a binary Entity → Entity relationship. It is not called link: in the specification a link is one instance of a relationship — one tuple — not the relationship itself
equivalences the relationship's requires, verbatim
mapping edge; the node's mapping field the concept mappings — object mappings and identify_by — that place an entity on a dataset. Its three values are dosi's reading of what those mappings landed on, which the specification does not itself distinguish
concept.measure, concept.group_by, concept.via one aggregate derived_by rule, AGG[<value> WHERE <correlation> GROUP BY <concept>], split into the value, the GROUP BY concept and the WHERE correlation path. Measure and grain are the semantic-layer words, not the specification's
realized_by, no_join nothing in the ontology — the joins lowering produced

Note also that the specification has no notion of a metric at all: what it declares is a derived relationship (Airport.average_departure_delay relates an Airport to a Delay). dosi lowers that into a core-OSI metric, which is why the graph has a metric node to hang the concept object on.

The aggregate form is not a grammar the specification's text defines either: its derivation examples are logical rules (Person.parent_of(descendant), EXISTS ( Person.earns )). The AGG[…] rules dosi lowers are the ones in examples/flights.yaml in apache/ossie, which is the bundled ossie-flights ontology.

Python

The Python binding (dosi_engine.Engine) loads an ontology the way the CLI does, through mapping_path (or mapping_text), and exposes every call above. model_path and ontology_path play the parts of --model and --ontology.

from dosi_engine import Engine, QueryError

e = Engine(mapping_path="aviation/mapping.yaml")
e.has_ontology          # True — also for Engine(ontology_path=…) alone
e.ontology_info()       # {"name": "Aviation", "description": …,
                        #  "mappings": ["aviation_duckdb"],
                        #  "source_path": "aviation/ontology.yaml"}

ontology_info() returns None without an ontology, and source_path is None when the ontology came as text or inline. Text has no location, so a mapping passed as mapping_text must carry its documents inline.

Browsing

Method CLI equivalent Returns
describe() dosi describe The catalog: ontology, description, concepts[] (name, identity, links_out, links_in, properties, queryable, summary), requires
describe("FlightAlert") dosi describe FlightAlert One concept: properties[] with path, domain, is_time; links[] with role, to, multiplicity; incoming, metrics_reachable, groupable_metrics, time_dimensions_reachable, default_projection
paths("Flight", "Airport") dosi paths --from Flight --to Airport paths[] (path, roles, hops, multiplicity, no_join), equivalences[] (a, b, declared_by), unique, and max_hops

The JSON is the same as --format json and the MCP describe and paths tools. Without an ontology, describe() lists datasets and metrics instead.

p = e.paths("Flight", "Airport")
[x["path"] for x in p["paths"]]
# ['Flight.arrives_at', 'Flight.departs_from',
#  'Flight.follows_route.destination', 'Flight.follows_route.origin']
p["equivalences"][0]
# {'a': 'Flight.departs_from', 'b': 'Flight.follows_route.origin',
#  'declared_by': 'Flight.follows_route.origin == origin'}

Queries

compile, execute, explain, compile_select, select and explain_select accept concept paths wherever the CLI does: group-by and select fields, from (a concept name), where_sql, context_filter and a time range's dimension. A query may mix concept paths with dataset.field. The SQL is the same either way. order_by still names output columns.

Every compile and execute result carries requested_columns, pairing each group-by or field as written with the result column it lands in:

r = e.select({
    "from": "FlightAlert",
    "fields": ["FlightAlert.id", "FlightAlert.flight.arrives_at.name"],
    "where_sql": "FlightAlert.status = 'OPEN'",
}, db_path="aviation.duckdb")
r["requested_columns"]
# [{'requested': 'FlightAlert.id', 'column': 'alert_id'},
#  {'requested': 'FlightAlert.flight.arrives_at.name',
#   'column': 'flight_alerts_flight__flights_arrives_at__name'}]

The existing outputs key is unchanged: it still lists metric columns with their parameter bindings.

Errors in Python

Every refusal is a QueryError whose code is machine-readable and whose to_dict() carries the structured fields, not only the message:

code Raised by Structured fields
ontology_required describe(name), paths without an ontology —
unknown_concept describe(name) the message lists the declared concepts
invalid_concept_path paths naming an undeclared concept —
value_not_in_domain every query method, before compiling violations[] (reference, literal, allowed[]); candidates repeats the first allowed
invalid_relationship_path compile, execute, explain candidates[] (at most three, shortest first) and hint: the group-by rooted where the metric lives
ambiguous_join_path every query method the ordinary planner payload
try:
    e.compile_select({"from": "FlightAlert", "fields": ["FlightAlert.id"],
                      "where_sql": "FlightAlert.status = 'unresolved'"})
except QueryError as err:
    err.code                       # 'value_not_in_domain'
    err.to_dict()["violations"]    # [{'reference': 'FlightAlert.status',
                                   #   'literal': 'unresolved',
                                   #   'allowed': ['OPEN', 'RESOLVED']}]

Constructs the ontology declares but dosi does not lower are warned about once at load, as a RuntimeWarning, and are also listed in Engine.warnings with code ontology_note, so a server process that never sees Python warnings can still read them.

Errors

Refusals while loading an ontology exit non-zero with one or more codes, each naming the construct and, where there is one, a fix:

Code Meaning
ontology_parse The document does not parse as the kind it was given as
ontology_unknown_reference A referenced concept, relationship or role does not exist
ontology_ref_unresolved A reference has no iri, no inline document and no flag naming it, or its iri cannot be read; network IRIs are refused. Also: --ontology beside a directory that holds no mapping onto it
ontology_ref_mismatch A reference's name is not the name of the document it resolves to
ontology_ref_kind A reference resolves to the wrong kind of document, or an inline ontology carries ontology_mappings
ontology_ref_conflict A reference is given two ways: document and iri, or an inline document and a flag
ontology_federation_unsupported The mappings bind more than one semantic model or target more than one ontology, or an ontology embeds several mappings
ontology_mapping_conflict One concept is mapped more than once
ontology_mapping_empty A concept mapping has neither object_mappings nor link_mappings, or an object mapping has neither expression nor referent_mappings
ontology_duplicate_name Two documents of one kind in a directory share a name
ontology_mapping_unresolved A mapping expression names a dataset or field the model lacks
ontology_expression_unsupported A mapping expression is not a plain qualified column, optionally WHERE ( … )-filtered
ontology_mapping_depth_mismatch A link-mapping node sits at a depth that does not match its relationship's arity
ontology_multiplicity_required A relationship that must become a join declares no multiplicity
ontology_concept_spans_datasets One concept's mappings land on more than one dataset
ontology_identity_unresolved identify_by names something that cannot identify
ontology_name_collision A concept name collides with a dataset or relationship name
ontology_type_unresolved extends forms a cycle, a value type never reaches a built-in, or an EntityType extends a ValueType
ontology_recursion_unsupported A derived_by rule references the relationship it defines, or a subtype's rule tests itself (directly, or through subtypes that test each other)
ontology_subtype_multiple_parents An EntityType extends more than one concept; a derived subtype has one parent
ontology_subtype_needs_rule An EntityType extends another but declares no derived_by rule; a subtype with a table of its own is not supported
ontology_multi_rule_unsupported A derived subtype lists more than one derived_by rule; join them with OR
ontology_subtype_identity A derived subtype declares identify_by; identity is inherited
ontology_subtype_mapped A derived subtype's concept mapping has object_mappings; only link_mappings for its own relationships are allowed
ontology_subtype_rule_unsupported A derived_by rule is outside the grammar (a function, an aggregate, a two-hop path, EXISTS), or names a property the parent does not map
ontology_subtype_params The rule's :name placeholders and the mapping document's concept_params disagree, or a declaration is invalid
$ dosi describe --mapping $DOSI_EXAMPLES/aviation/mapping.yaml \
    --model $DOSI_EXAMPLES/orders/model.yaml
error: ontology could not be lowered:
  ontology_ref_mismatch: semantic_model_ref names "aviation", but the document it resolves to is named "orders_model" (aviation_duckdb.semantic_model_ref)

At query time an ontology adds value_not_in_domain and unknown_subtype (<Concept> is <Name> naming no derived subtype), and fills candidates on invalid_relationship_path. Everything else is the ordinary query error set. The Python binding adds three codes of its own; see Errors in Python.

Limitations

  • Denormalized concepts are not query roots. A concept stored on another concept's table — State, City and Market on AIRPORT — is reachable through its owner (Airport.city.name) but select --from State is refused, because its rows would repeat once per airport until roots can deduplicate.
  • n-ary relationships and row-level derived_by (MINUTES(a - b)) are not lowered; they appear under unmapped / not queryable.
  • Range domains are not enforced (see Value domains).
  • --order takes output column names, not concept paths.
  • paths stops at three hops.
  • Search is not a dosi command. Retrieval needs an index and storage; dosi compiles rather than stores, and describe output is the raw material an orchestration layer indexes.
  • Derived subtypes, V1: one parent, one rule, no table of their own, no EXISTS, one hop, no rule that tests two subtypes in a cycle. A rule's thresholds bind one value per query (no list expansion), and a field computed from a parameterised flag reads it at the defaults.