The Semantic Layer, From ScratchChapter 6
Chapter 6

The T-Box, part 2: properties and the c360 ontology (OWL)

Relationships and attributes, the domain-and-range trap that catches every SQL person, and a tour of the complete c360 model, which you will load into Stardog by the end of the chapter.

One line in an ontology can look like a check but isn't. Take :purchasedBy rdfs:domain :Order. A property's domain is the kind of thing the property is used on: the subject, the left-hand side of the triple (Section 6.2 covers it properly). The line reads like "only orders can have :purchasedBy." What it actually tells Stardog is: "if something has :purchasedBy, label it an Order." So if bad data says Wax Paper :purchasedBy Carolyn, Stardog doesn't refuse it. With reasoning on, it quietly labels Wax Paper an Order. That is wrong: Wax Paper is a product. This chapter shows why it happens, how the c360 kit avoids it, and ends with the full ontology (161 triples) loaded as build step 2.

6.1Properties: the columns and foreign keys of a graph

The ten classes from Chapter 5 have no columns: our :Customer has no name, no email, no address. That is deliberate. In a graph, columns and foreign keys are played by a property: anything used as the predicate of a triple, such as :firstName or :purchasedBy. A property has its own IRI and is declared on its own, outside any class, so that many classes can share it and the model can say things about the property itself (its label, what it connects). OWL splits properties into two kinds:

order pos363f9784… :purchasedBy customer:225 :firstName "Carolyn" owl:ObjectProperty value is a node (IRI) you can keep following it owl:DatatypeProperty value is a literal the path ends here IN SQL purchase.cid → customer.cid (foreign key) customer.first_name (column)
Figure 6.1: Object properties are relationships (foreign keys); datatype properties are attributes (columns).

Where does the real c360 vocabulary come from? The c360 knowledge kit (Chapter 1) ships its whole ontology as one Turtle file, schema.ttl, in Stardog's public GitHub repository: https://github.com/stardog-union/knowledge-kits/blob/main/examples/c360/models/schema.ttl. You'll load all of it at the end of this chapter. Here is its entry for :firstName, unchanged:

:firstName a owl:DatatypeProperty ;
    rdfs:label "first name" ;
    so:domainIncludes :Customer ;
    so:rangeIncludes xsd:string .

The last two lines say which class the property is used on (Customer) and what kind of value it takes (a string). so: is short for schema.org, a shared vocabulary for describing data on the web. The textbook way to write these two facts is rdfs:domain and rdfs:range, but that way has a trap (the Wax Paper problem from the opening). Sections 6.2 and 6.3 show the trap and why the kit uses so: instead.

TermWhat it isWhy it existsTiny example
PropertyA predicate declared as a thing itselfSo it can be shared and described:firstName
Object propertyLinks a thing to a thingRelationships (FKs):purchasedBy
Datatype propertyLinks a thing to a literalAttributes (columns):city
DomainThe class of a property's subjectSays what the property is used onpurchasedBy: Order
RangeThe class or datatype of its objectSays what values it takespurchasedBy: Customer
rdfs:domain / rdfs:rangeRDFS's domain and range: inference rulesLet a reasoner derive typessee Figure 6.2
so:domainIncludes / so:rangeIncludesschema.org's version: descriptions onlySay "expected here" without side effects:name so:domainIncludes :Vendor
owl:inverseOfOne relationship read both waysName the reverse directionplacedOrder ↔ purchasedBy
Reasoning schemaA named set of graphs Stardog treats as the T-BoxSo the reasoner knows where the axioms arec360_schema

Object properties point to things; datatype properties point to literals. Both are predicates, and both can have a domain and a range.

Example: :purchasedBy → a Customer; :firstName → a string.

6.2rdfs:domain and rdfs:range: rules, not checks

RDFS describes a property with two statements. Here they are for :purchasedBy, written the textbook way:

:purchasedBy rdfs:domain :Order ;
             rdfs:range  :Customer .

The semicolon makes this two triples, and the subject of both is the property itself, :purchasedBy. So these are facts about the property, not about any particular order or customer:

SubjectPredicateObject
:purchasedByrdfs:domain:Order
:purchasedByrdfs:range:Customer

To read them, picture a real data triple that uses the property:

order:0e7e…   :purchasedBy   customer:225
 left side                   right side

In plain words: "purchasedBy goes from an Order to a Customer." Every SQL person reads that as a check: "only orders may have :purchasedBy, and its value must be a customer." That reading is wrong, and why is the most useful idea in this chapter.

With reasoning on, Stardog doesn't check these two statements; it applies them. Whatever appears on the left of a :purchasedBy triple gets labelled an Order, and whatever appears on the right gets labelled a Customer.

Nothing is ever rejected. In an open world (Chapter 5) the reasoner assumes the data is right and the type merely unstated, so it fills it in.

T-BOX (HYPOTHETICAL, NOT THE KIT) :purchasedBy rdfs:domain :Order ; rdfs:range :Customer . A-BOX: A LOADING BUG (THE SUBJECT SHOULD HAVE BEEN AN ORDER) product:268 :purchasedBy customer:225 SQL: FK violation, insert rejected. RDFS: accepted, and a new "fact" is concluded. inferred: product:268 a :Order No error. "Wax Paper" is now also an order.

Press Play to replay the riddle.

Figure 6.2: rdfs:domain is not a check. With reasoning on, it turns a bad triple into a wrong type, silently; counting orders now counts a product.

It gets worse when a property is shared, like the kit's :name. Written with rdfs:domain:

:name rdfs:domain :Category, :Customer, :Product, :Vendor .   # do NOT do this

Several domains do not mean "one of these". Each is its own rule and each fires, so every named thing becomes a Category and a Customer and a Product and a Vendor; Carolyn would become a product. (The formal fix, an owl:unionOf class as the domain, is heavy and still an inference rule, not a check.)

rdfs:domain and rdfs:range label things; they never check. Checking is SHACL's job.

Example: one bad triple product:268 :purchasedBy customer:225 turns Wax Paper into an Order.

6.3so:domainIncludes and so:rangeIncludes: the kit's answer

The c360 kit avoids the trap by not using rdfs:domain and rdfs:range at all. It uses two predicates from schema.org (prefix so: = https://schema.org/), the search engines' vocabulary for describing web content:

The key word is expected. These predicates have no formal meaning in RDFS or OWL, so Stardog's reasoner draws no conclusions from them. :name so:domainIncludes :Category, :Customer, :Product, :Vendor says what the modeler meant, and reasoning in Chapter 10 will never make Carolyn a product.

They are documentation that machines can read. Stardog Designer writes them when you draw a property between two classes, and model views use them to show which properties belong to which class. Voicebox and your agent read them when writing SPARQL; :name so:domainIncludes :Category, :Customer, :Product, :Vendor is exactly the hint that :name can appear on a category, a customer, a product or a vendor, so a question like "what is customer 225's name?" is answered with :name. (How each tool uses them internally is up to that tool; if a view looks empty, tell Claude.)

6.4owl:inverseOf, and a wrinkle in the kit

Every relationship reads two ways: "order purchased by customer" is also "customer placed order". SPARQL can already walk an arrow backward (?order :purchasedBy customer:225), but sometimes you want a name for the reverse direction. owl:inverseOf says two properties are one relationship read in opposite directions. A correct example (not in the kit): :placedOrder owl:inverseOf :purchasedBy. With that line in the model, order:0e7e… :purchasedBy customer:225 also means customer:225 :placedOrder order:0e7e…: the same fact, read from the other end.

An inverse must swap the two ends. Now read what schema.ttl actually says:

:rewardsAccountUsed a owl:ObjectProperty ;
    owl:inverseOf :hasRewards ;
    so:domainIncludes :Order ;
    so:rangeIncludes :RewardsAccount ;
    rdfs:label "rewards account used" .

:hasRewards a owl:ObjectProperty ;
    owl:inverseOf :rewardsAccount ;
    so:domainIncludes :Customer ;
    so:rangeIncludes :RewardsAccount ;
    rdfs:label "has rewards account" .

Both owl:inverseOf triples deserve a raised eyebrow. :rewardsAccountUsed goes Order → RewardsAccount and :hasRewards goes Customer → RewardsAccount: both point at the account, so they are not each other's reverse. Taken literally with reasoning on, "order O used account R" implies "R has rewards O". The second, :hasRewards owl:inverseOf :rewardsAccount, is sensible in intent (the account points back at its customer), but :rewardsAccount is declared nowhere and no mapping produces it; the open world allows that.

We load the kit exactly as shipped, because the rest of the book assumes that file, and with reasoning off (until Chapter 10) inverse axioms have no effect. The practical risk is contained but real: 1,560 of the 2,500 purchases carry a rewards account (none of Carolyn's do), so with reasoning on under this schema each of them yields a bogus inferred triple, and a query that also requires ?c a :Customer filters those rows out. Chapter 10 shows how to spot them. The lesson: an ontology is code, and code has bugs. If rewards accounts ever show up where customers should be in a reasoning query, look here first.

6.5The whole c360 ontology

You can now read every line of schema.ttl: 10 classes, 11 object properties and 22 datatype properties. Figure 6.3 draws the classes and relationships.

Address Customer RewardsAccount CreditCard Order Product Category UnitPrice Vendor Industry address hasRewards cardHolder rewardsAccountUsed cardUsed itemPurchased inCategory msrp vendor industry purchasedBy fed from Aurora (by the ch08 mappings) fed from Redshift (ch09 mappings)

Press Play to build the model class by class.

Figure 6.3: The complete c360 ontology: 10 classes (boxes) and 11 object properties (arrows, drawn from so:domainIncludes to so:rangeIncludes). The colors are this book's annotation, not part of the ontology: the model itself has no idea where data lives (Section 6.6).

The 11 relationships

PropertyLabelFromToSQL equivalent
:purchasedBypurchased byOrderCustomerpurchase.cid
:itemPurchasedpurchasedOrderProductpurchase.pid
:cardUsedpurchase cardOrderCreditCardpurchase.card
:rewardsAccountUsedrewards account usedOrderRewardsAccountpurchase.reward_account
:addresshas addressCustomerAddresscustomer.location
:hasRewardshas rewards accountCustomerRewardsAccountrewards_account.cid, reversed
:cardHoldercard holderCreditCardCustomercredit_card.cid
:inCategoryin categoryProductCategoryproduct.dept
:vendorhas vendorProductVendorproduct.brand
:msrpMSRPProductUnitPricea node built from product.price
:industryin industryVendorIndustryvendor.industry

Two graph habits hide in that table. :hasRewards points from customer to account, the opposite of the SQL foreign key: modelers choose the direction that reads best as a sentence. And :msrp points at a UnitPrice node carrying a price and a currency, not a bare number: a value turned into a thing so it can hold more facts.

The 22 attributes

ClassDatatype properties (value type)
Customer:firstName, :lastName, :email, :phone, :ssn ★ (all string); shared :name, :thumbnail
Address:streetAddress, :city, :state, :zipCode (all string)
Order:purchasePrice (decimal), :quantity (integer), :purchaseDate (date), :purchaseTime (string)
Product:description (string); shared :name, :thumbnail
CreditCard:cardType, :cardNumber ★ (string)
RewardsAccount:accountId (string), :openDate (date)
UnitPrice:hasCurrency (string), :hasPrice (float)
Category, Vendorshared :name (string)

6.6What the ontology does not contain

Notice what schema.ttl does not mention: Aurora, Redshift, table or column names, cid, connection strings, SQL. It does not know there are 2,500 orders, or contain a single customer. It is purely a description of meaning: these kinds of things exist, and this is how they relate.

That separation is the point. Connecting meaning to where the data lives is the job of the mappings (Chapter 8 for Aurora, Chapter 9 for Redshift): "each row of c360.customer is a :Customer, and its first_name column is :firstName". Swap Redshift for Snowflake tomorrow and not one triple of the ontology changes; only a mapping does.

The flip side: model and mappings can drift apart, and the open world will not warn you. The Redshift mapping writes a vendor :homepage the ontology never declares, and no mapping produces the declared customer :thumbnail. A declaration is a promise of meaning, not a guarantee of values.

6.7OWL versus RDFS on one page

RDFSOWL
What it isThe minimal schema vocabularyA richer ontology language built on RDFS
Classesrdfs:Class, rdfs:subClassOfowl:Class, plus equivalent, disjoint and union classes, restrictions
Propertiesrdf:Property, rdfs:subPropertyOf, rdfs:domain, rdfs:rangeowl:ObjectProperty, owl:DatatypeProperty, owl:inverseOf, functional, transitive, symmetric
Identity(none)owl:sameAs, owl:differentFrom
Annotationsrdfs:label, rdfs:commentreuses RDFS's
Rejects data?No (open world)No (it can detect contradictions, which is different)
Used in c360labels, comments; subClassOf in Chapter 10owl:Class, both property types, owl:inverseOf

Stardog lets you choose how much of OWL its reasoner applies. The default setting (called SL) covers the everyday parts you've met in this book, such as subclasses, inverses, and domain and range, and it also runs Stardog's own IF/THEN rules, like "big spender" (Chapter 10). You never need to change that setting for this book. In one line: RDFS names kinds of things and arranges them in hierarchies, OWL says more precisely how things relate, and neither one checks your data.

6.8Real build step 2: the complete ontology

Four parts: A adds three properties by hand, B loads the whole schema.ttl on top, C registers the model graph as a reasoning schema, D looks at the result in Studio. Everything goes into database kit-c360, graph <urn:stardog:training-c360:1.0:model>, the same graph as Chapter 5. Keep the reasoning toggle off throughout.

Check yourself

Is :hasRewards an object or a datatype property, and what is its SQL counterpart?

An object property: its value is a RewardsAccount node. Its SQL counterpart is the foreign key rewards_account.cid, read the other way (customer to account).

Hypothetically the model says :quantity rdfs:range xsd:integer, and someone loads order :quantity "lots". What does Stardog do?

It stores the triple: rdfs:range is an inference rule, not a check. To have the value reported, write a SHACL shape with sh:datatype xsd:integer (Chapter 11).

Why would :name rdfs:domain :Category, :Customer, :Product, :Vendor be a bug, when the kit's so:domainIncludes version is fine?

Each rdfs:domain triple is a separate rule, so with reasoning on every named thing would become all four classes. so:domainIncludes only documents "any of these"; the reasoner ignores it.

Where in the ontology does it say that customers come from Aurora?

Nowhere. The ontology describes meaning only; the mappings (Chapters 8 and 9) connect classes and properties to physical data.

Why is :rewardsAccountUsed owl:inverseOf :hasRewards suspicious?

An inverse must swap the ends. Both properties point at a RewardsAccount, so they are not each other's reverse; with reasoning on, rewards accounts would "have rewards" orders.

You load schema.ttl on top of the 34 triples you typed. Why 161 and not 195?

The 34 typed triples are identical to triples in the file, and a graph is a set, so re-inserting them adds nothing.

Chapter recap

Paste it to Claude with what you saw (the counts or results), so we can confirm it matches before the next build step relies on it.