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.
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:
| Term | What it is | Why it exists | Tiny example |
|---|---|---|---|
| Inference | Deriving 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. |
| Rule | An 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 class | A 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. |
| Reasoning type | Understands |
|---|---|
RDFS | subclasses, subproperties, rdfs:domain/rdfs:range |
QL, RL, EL | three 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.
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 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 as | Prefix (up to the first colon) means | The rest, called the local part | Full IRI |
|---|---|---|---|
:Customer(your mappings) | : = tag:stardog:api:ecomm:(the PREFIX : line at the top of your mapping files) | Customer | tag: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.
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 label | IF (from the file) | THEN | Expect |
|---|---|---|---|
| large purchases | an Order whose purchasePrice > "250"^^xsd:decimal | Large_Order | 1,103 orders |
| big spenders | a Customer with some order that is a Large_Order | Big_Spender | 445 customers |
| 2022 orders | an Order with purchaseDate in 2022-01-01…2022-12-31 | 2022_Order | 194 orders |
| customers who made purchases in 2022 | a Customer with some 2022_Order | 2022_Orderer | 163 customers |
| sport category shopper | a Customer whose order's product is inCategory <…category:10> (Sports) | Sports_Category_Shopper | 144 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:
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.
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.
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:
?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.… FROM c360.purchase WHERE price > 250, and the Aurora part becomes a select on customer joined to address.GROUP BY state.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 added | What 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.
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.
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.
default schema. Check the schema picker.FROM? Without the Aurora graph, ?c a :Customer never matches, so there are no big spenders. :Large_Order alone needs only Redshift.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.
Nowhere. Stardog reasons at query time and doesn't materialize inferences. Turn reasoning off and the triple is gone.
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:.
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.
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.
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.
IF { pattern } THEN { triples }, stored as a rule:SPARQLRule. A derived class gets its members only from rules. The reasoning level must be SL (the default) for rules to fire.price > 250), Big_Spender, 2022_Order, 2022_Orderer, Sports_Category_Shopper. Two of them chain.stardog:ecomm:X = tag:stardog:api:ecomm:X = our :X.c360_extended_rules.:rewardsAccountUsed owl:inverseOf :hasRewards). Compare counts with reasoning off and on.
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.