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
namemust equal the name of the document it resolves to (ontology_ref_mismatch). --ontologyand--modelwin over theiria mapping was written with, and the name is still checked. An inlinedocumenttogether with a flag for the same reference is refused (ontology_ref_conflict).- An
iriis a local path or afile://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 asossie-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 byAirport.codeisambiguous_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 bydescribebut 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,CityandMarketonAIRPORT— is reachable through its owner (Airport.city.name) butselect --from Stateis 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 underunmapped/not queryable. - Range domains are not enforced (see Value domains).
--ordertakes output column names, not concept paths.pathsstops at three hops.- Search is not a dosi command. Retrieval needs an index and storage; dosi
compiles rather than stores, and
describeoutput 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.