The Semantic Layer, From ScratchChapter 10
Chapter 10

Reasoning: teaching the graph to conclude

How a two-line rule turns "big spender" into a live definition that works across Aurora and Redshift, without adding a column anywhere.

It's Monday, and marketing wants one number: "How many big spenders do we have, per state?" A big spender, they tell you, is a customer with at least one order whose price is over $250. There's no big_spender column and no large_order flag anywhere; the prices are in Redshift and the states are in Aurora.

Without a semantic layer, you'd be stuck. No single SQL statement can join Redshift and Aurora, so the old way is: query Redshift for the customers with a large order, paste those 445 ids into an IN (…) list on Aurora, group by state, and do it all again next Monday.

With what you've built in Chapters 5 to 9, you are not stuck. You have the ontology, the three virtual graphs and a working federated join, so you can already answer in one query:

PREFIX : <tag:stardog:api:ecomm:>
SELECT ?state (COUNT(DISTINCT ?c) AS ?bigSpenders)
WHERE {
  GRAPH <virtual://redshift_c360> {
    ?o :purchasedBy ?c ; :purchasePrice ?price .
    FILTER(?price > 250)                  # the definition of "big spender", inline
  }
  GRAPH <virtual://aurora_c360_safe> { ?c :address ?a . ?a :state ?state . }
}
GROUP BY ?state
ORDER BY DESC(?bigSpenders)

It returns Texas 46, Florida 46, California 42 and so on. Correct, live, and no ETL.

What's still missing is the definition. "Big spender" lives only inside that query, as FILTER(?price > 250). The weekly dashboard needs it too, so does the churn report, and so does the AI agent's prompt, and each one will write it again. Sooner or later one says > 300, another adds up the order totals instead, and three teams report three different numbers. This chapter moves the definition out of the queries and into the model, written once. Then every question simply asks for ?c a :Big_Spender, and Stardog concludes who qualifies, over live rows, at query time. Nobody ever stores the fact.

10.1Inference: facts you never stored

Every triple you've queried so far was asserted: a mapping or an INSERT DATA said it explicitly. Inference means deriving new triples from asserted ones plus the model. Stardog calls the machinery reasoning, and per its docs it "does not materialize inferences; rather, reasoning is performed at query time." With reasoning on, Stardog finds everything the model implies, answers, and throws the derivation away. Four terms carry this chapter:

TermWhat it isWhy it existsTiny example
InferenceDeriving a triple nobody asserted.So facts that follow from your data don't need storing or syncing.From customer:225 a :Big_Spender and :Big_Spender rdfs:subClassOf :Customer, infer customer:225 a :Customer.
RuleAn IF { pattern } THEN { triples } stored in the model.Subclass axioms can't test values ("price over 250"); rules can.IF { ?o a :Order ; :purchasePrice ?p . FILTER(?p > 250) } THEN { ?o a :Large_Order }
Derived classA class whose members come only from rules or axioms, never from data.To name a business concept once, in the model.No mapping says a :Large_Order. The rule decides, fresh on every query.
Reasoning level (Stardog: reasoning type)Which family of conclusions is allowed.More expressive logic costs more, so you choose.RDFS does subclasses but ignores rules. SL does both.

10.2Reasoning levels and schemas, in one page

Reasoning typeUnderstands
RDFSsubclasses, subproperties, rdfs:domain/rdfs:range
QL, RL, ELthree standard OWL 2 "profiles," subsets of OWL picked so reasoning stays fast (e.g. owl:inverseOf from Chapter 6)
SL (default)Stardog's combination of RDFS, QL, RL and EL, plus user-defined rules

kit-c360 uses SL, which matters because Stardog's troubleshooting guide says user rules "are only evaluated using the SL reasoning type." You won't change it.

The setting you will touch is reasoning.schemas from Chapter 6. A reasoning schema is a named set of graphs whose axioms and rules the reasoner uses. You have one, c360_schema, and you'll add a second. Per the docs, a query uses exactly one schema; Stardog doesn't merge them. So there are two dials per query: reasoning on or off, and which schema.

10.3Reading a real rule

A Stardog rule (Stardog Rule Syntax, or SRS) is a SPARQL pattern with a conclusion attached: "wherever IF matches, act as if the THEN triples exist." IF can hold the SPARQL you learned in Chapter 4 (triple patterns, FILTER, BIND, UNION, VALUES). The docs add three restrictions: every THEN variable must appear in IF; no variable may stand in the predicate position or as the class in rdf:type; and recursion is limited.

Where the rules come from. You don't write these from scratch: the c360 kit ships them, in the same GitHub folder as schema.ttl (Chapter 6). There are two files: c360_extended_model.ttl, which adds five new classes such as :Large_Order and :Big_Spender, and c360_extended_rules.ttl, which holds the five rules that decide who belongs in them. You'll download and load both in build step 5 (Section 10.7), the same way you loaded schema.ttl. Here is the big-spender rule, copied exactly from c360_extended_rules.ttl:

IF {
  ?sub a stardog:ecomm:Customer .
  ?obj_0 stardog:ecomm:purchasedBy ?sub . ?obj_0 a stardog:ecomm:Large_Order .
}
THEN {
  ?sub rdf:type stardog:ecomm:Big_Spender .
}

In English: "if ?sub is a Customer, and some order purchased by ?sub is a Large_Order, then ?sub is a Big_Spender." Names like ?obj_0 are what Stardog Designer generates when you build a rule by drag-and-drop.

The prefix puzzle

The rule says stardog:ecomm:Customer, but your mappings say :Customer. Are they the same class? You have met this question before. In Chapter 9, the Redshift mapping without its PREFIX lines turned :purchasedBy into http://api.stardog.com/purchasedBy, and the query found nothing. A short name is only a nickname. What counts is the full IRI it expands to, so expand both:

Written asPrefix (up to the first colon) meansThe rest, called the local partFull IRI
:Customer
(your mappings)
: = tag:stardog:api:ecomm:
(the PREFIX : line at the top of your mapping files)
Customertag:stardog:api:ecomm:Customer
stardog:ecomm:Customer
(the kit's rule)
stardog: = tag:stardog:api:
(an @prefix line at the top of the kit file)
ecomm:Customer
(a second colon is allowed here)
tag:stardog:api:ecomm:Customer

Both rows end in the same full IRI, so the rule matches exactly the customers your Aurora mapping produces. Two spellings, one class. The kit file hides two more prefix traps; you'll meet them in build step 5 (Section 10.7), when the file is open in front of you.

10.4The kit's five rules, and one firing

c360_extended_model.ttl is the Chapter 6 ontology plus five derived classes, each a subclass of Order or Customer. c360_extended_rules.ttl holds one rule per class. The two files add up to 197 triples (172 + 25). The last column is my answer key, computed with read-only SQL against the rows you'll query.

Rule labelIF (from the file)THENExpect
large purchasesan Order whose purchasePrice > "250"^^xsd:decimalLarge_Order1,103 orders
big spendersa Customer with some order that is a Large_OrderBig_Spender445 customers
2022 ordersan Order with purchaseDate in 2022-01-01…2022-12-312022_Order194 orders
customers who made purchases in 2022a Customer with some 2022_Order2022_Orderer163 customers
sport category shoppera Customer whose order's product is inCategory <…category:10> (Sports)Sports_Category_Shopper144 customers

Two rules chain: big spenders needs large purchases, and 2022 orderers needs 2022 orders. You never say what order to run them in. Press Play to watch a chain fire on three real orders:

ASSERTED: Redshift orders ASSERTED: Aurora customers order · $10,479.00 purchasedBy customer:225 order · $7.74 purchasedBy customer:225 order · $179.82 purchasedBy customer:476 large purchases price > 250 ? ✓ ✗ ✗ $10,479 order a :Large_Order big spenders Customer + Large_Order customer:225 Carolyn Inworth · WI a :Customer customer:476 Tull McMurraya · MO a :Customer (max order $179.82) a :Big_Spender Solid = SQL rows seen as triples. Dashed = inferred, exists only while the query runs.

Carolyn is a Big_Spender because one of her orders is a Large_Order. Tull, whose biggest order is $179.82, is not. Neither conclusion is stored.

Figure 10.1: Two rules chaining on real rows (Carolyn's $10,479 and $7.74 orders, Tull McMurraya's $179.82 order).

How a rule is stored. A rule is data too: a blank node a rule:SPARQLRule (rule: = tag:stardog:api:rule:) with an rdfs:label and the text in rule:content, which is what the reasoner reads. Two more triples, <tag:stardog:designer:rule:content> and <tag:stardog:designer:rule:data>, are Designer's copy of the text and a blob it uses to redraw the rule. Five triples times five rules gives the file's 25.

Where the threshold lives: FILTER(?dat_0 > "250"^^xsd:decimal) on purchasePrice, which our Redshift mapping takes from the price column: the unit price (Chapter 9 multiplied it by quantity). So a "large order" is a purchase line with a unit price over $250, not a basket over $250.

10.5Where reasoning runs: before the SQL is written

The big-spender rule needs Aurora ("?sub is a Customer") and Redshift ("an order over $250 purchased by ?sub"). The rule doesn't know that. It's written against the model, and the join works because both mappings mint the same customer IRI (Chapter 9's IRI trick).

The key point is when the rules run: inside Stardog's query planner, before the SPARQL-to-SQL rewrite. They aren't a batch job over copied data, and they aren't installed in Aurora or Redshift. The planner expands them into extra query patterns, and only then is the query split, turned into SQL, and sent to the live databases, so the rule logic always sees current rows. The docs support this picture (no materialization, and reasoning.rewriting "allows one to choose how query patterns are rewritten for reasoning") but don't publish the planner's internals, so the steps below are a model of what happens, and the profiler output after Figure 10.2 shows it happening on our data:

  1. Expand. ?c a :Big_Spender becomes the big-spenders rule's IF pattern. That pattern mentions :Large_Order, which becomes the large-purchases pattern, including its FILTER.
  2. Rewrite to SQL. Now it's plain triple patterns, exactly what Chapter 9 federated. The Redshift part becomes something like … FROM c360.purchase WHERE price > 250, and the Aurora part becomes a select on customer joined to address.
  3. Join on the IRI inside Stardog, then GROUP BY state.
?c a :Big_Spender 1 · your query 2 · planner expands rules no SQL yet ?c a :Customer . ?o :purchasedBy ?c . ?o a :Order ; :purchasePrice ?p FILTER(?p > 250) 3 · SQL on Aurora customer ⋈ address → IRI, state 3 · SQL on Redshift purchase WHERE price > 250 → cid 4 · join on urn:stardog:demos:c360:customer:{cid} inside Stardog → GROUP BY state
Figure 10.2: Reasoning happens first, federation second. The rules become ordinary patterns, and the patterns become SQL on two live engines.

The proof: what the profiler shows

This is the real plan for build step 5's big-spender query, with reasoning on and schema c360_extended_rules (Studio: the dropdown next to the plan icon → Run Profiler). It ran in 238 ms on warm databases. Trimmed to the parts that matter:

Group(by=[?state] aggregates=[(COUNT(DISTINCT ?c) AS ?bigSpenders)])   results: 42
`─ Distinct                                                             results: 445
   `─ HashJoin(?c)                                                      results: 1.1K
      +─ VirtualGraphSql<virtual://redshift_c360>
      │     SELECT "cid"
      │     FROM "dev"."c360"."purchase"
      │     WHERE "price" > 250 AND "cid" IS NOT NULL
      │     ?c <- TEMPLATE(urn:stardog:demos:c360:customer:{cid})
      `─ VirtualGraphSql<virtual://aurora_c360_safe>
            SELECT "customer"."cid", "customer"."location", "address"."state"
            FROM "c360"."c360"."customer"
            INNER JOIN "c360"."c360"."address" ON "customer"."location" = "address"."id"
                                               AND "address"."state" IS NOT NULL
            ?c <- TEMPLATE(urn:stardog:demos:c360:customer:{cid})

How reasoning changed the query. You wrote four patterns. Here is where each one went:

What you wrote, or what the rules addedWhat actually ran
?c a :Big_Spender (yours)Gone. No SQL mentions big spenders. The planner replaced it with the big-spenders rule's IF part.
?o a :Large_Order (added by the big-spenders rule)Gone too. Replaced in turn by the large-purchases rule's IF part: ?o :purchasePrice ?p . FILTER(?p > 250).
FILTER(?p > 250) (added by the large-purchases rule)WHERE "price" > 250 in the Redshift SQL. The rule's threshold was pushed down into Redshift.
?o :purchasedBy ?c (added by the big-spenders rule)SELECT "cid", turned into the customer IRI by the template. The order itself (?o) is never fetched, because nothing downstream needs it.
?c a :Customer (added by the big-spenders rule)Folded into the Aurora scan of customer: the mapping already says every row of that table is a :Customer.
?c :address ?addr . ?addr :state ?state (yours)The Aurora customer JOIN address, pushed down as one statement.

What's left for Stardog is the part no single database can do: join the two results on ?c (1.1K rows: one per purchase line over $250, so a customer with three such lines appears three times), remove duplicates (445 customers), and count per state (42 rows). One SQL statement went to each database.

reasoning.virtual.graph.enabled (default and here true) is what lets reasoning reach virtual graphs.

10.6The Studio switches, and why the answer changes

Reasoning is off by default, per query: --reasoning on the CLI, reasoning=true over HTTP, and a Reasoning toggle in Studio's query toolbar. In Studio it takes two clicks, both in the query toolbar: turn the Reasoning toggle on (it turns green), then click the gear icon right next to it and choose Reasoning Schema → c360_extended_rules. The same gear menu holds Query Timeout (ms), 60000 by default.

The schema choice decides everything. Flip the toggle without picking one and you get the schema called default, which here is the special graph tag:stardog:api:context:schema, and it's empty: no rules, no big spenders. Pick c360_extended_rules. It's self-contained, because the extended model repeats the whole Chapter 6 ontology.

This is the open world (Chapter 5) at work. With reasoning off, no asserted ?c rdf:type :Big_Spender triples exist, so you get zero rows, not an error. With reasoning on, the identical text returns 42 rows. Only Stardog's permission to conclude changed.

10.7Build step 5: load the rules, flip the switch

Before you start: aurora_c360_safe and redshift_c360 exist (Chapters 8 and 9), and reasoning.schemas holds c360_schema=…. If Aurora is asleep, the first query can take 15 to 20 seconds.

10.8When to reach for reasoning

Use it when a definition is shared and derivable: one definition, always fresh, applied across warehouses. Your Chapter 14 agent can then write ?c a :Big_Spender instead of re-deriving the threshold and the join. Skip it when you need a stored answer (an audit snapshot), heavy aggregation on every query, or bad data rejected. Reasoning only adds conclusions.

Check yourself

After the reasoning query, where is the triple "customer:225 a :Big_Spender" stored?

Nowhere. Stardog reasons at query time and doesn't materialize inferences. Turn reasoning off and the triple is gone.

Why is stardog:ecomm:Customer the same class as our :Customer?

stardog: = tag:stardog:api:, so the name expands to tag:stardog:api:ecomm:Customer, exactly what :Customer expands to when : = tag:stardog:api:ecomm:.

Reasoning is ON and the big-spender query returns nothing. Two likely causes?

The query ran under the empty default schema instead of c360_extended_rules (only one schema per query), or the Aurora virtual graph wasn't in the dataset, so ?c a :Customer never matched.

Do the rules run in Aurora and Redshift?

No. Stardog's planner expands the rules into extra query patterns first; only then is the query rewritten to SQL. The rule logic reaches the databases only as ordinary SQL (for example WHERE price > 250) over live rows.

What exactly makes a Large_Order here, and what does that imply?

purchasePrice > "250"^^xsd:decimal on the unit price. One pricey item is enough, so 1,103 orders and 445 of 500 customers qualify. It's generous, but it's defined in one place and easy to change.

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.