Build with JSON records
You can build a complete application on little big brain with JSON. This guide goes further than the quickstart. It adds typed fields, a chain of links of any length, rules and a replayed decision.
Underneath, each record is stored as RDF and each graph serves SPARQL. You do not need to know RDF for this guide. The one query and the one rules document each take a few lines.
The running example is a medicine recall. A supplier recalls one ingredient lot. Which released batches contain material from that lot? Which active study sites received them?
The answer runs through intermediate lots. It is a chain of links, so one join cannot answer it.
The four terms below are the whole vocabulary you need. The API calls them entity types, entities, relations and properties.
| Term | What it is | JSON example |
|---|---|---|
| Type | A kind of record | ReleasedBatch |
| Record | One thing, identified by its type plus a key or a name |
{ "type": "ReleasedBatch", "name": "Cardiovex batch CVX-A17", "key": "CVX-A17" } |
| Link | A named connection from one record to another | PRODUCED_FROM |
| Field | A typed value on one record | "batch_number": "CVX-A17" |
-
Declare the schema. A graph accepts only the types, links and fields it declares, so declare them first. One
definecall creates the graph with its types and links. Oneevolvecall adds the typed fields. Both calls are safe to repeat.A type can include
super_types: ["ParentClass"]. Here anActiveStudySiteis a kind ofStudySite. Declare each parent in the same document, in any order. Unknown fields and unknown parents are rejected, so the hierarchy is never lost without an error.The graph stores each parent as an
rdfs:subClassOfstatement. A study-site query withentailment: "subclass"then matches the active sites too. Use RDF import when you also need OWL axioms, labels and comments.import { LbbClient } from "@littlebigbrain/client";const lbb = new LbbClient({baseUrl: process.env.LBB_URL!,apiKey: process.env.LBB_API_KEY!,graph: "recall",});const graph = lbb.graph("recall");await graph.ontology.define({format: "spec",merge_default: false,source: JSON.stringify({entity_types: [{ name: "IngredientLot" }, { name: "IntermediateLot" }, { name: "ReleasedBatch" },{ name: "StudySite" }, { name: "ActiveStudySite", super_types: ["StudySite"] },{ name: "RecallNotice" }, { name: "AgentDecision" },],relation_types: [{ name: "PRODUCED_FROM", source: ["IntermediateLot", "ReleasedBatch"], target: ["IngredientLot", "IntermediateLot"] },{ name: "RECALLS", source: "RecallNotice", target: "IngredientLot", inverse: "RECALLED_BY" },{ name: "RECEIVED_BATCH", source: ["StudySite", "ActiveStudySite"], target: "ReleasedBatch", inverse: "SHIPPED_TO" },{ name: "DECIDED_ABOUT", source: "AgentDecision", target: "RecallNotice" },],}),});await graph.ontology.evolve({ops: [{ op: "add_property", name: "lot_number", value_type: "keyword" },{ op: "add_property", name: "batch_number", value_type: "keyword" },{ op: "add_property", name: "recall_date", value_type: "date_time" },{ op: "add_property", name: "based_on_commit_seq", value_type: "i64" },{ op: "add_property", name: "decision_outcome", value_type: "keyword",allowed_values: ["affected", "clear"] },],});import json, osfrom lbb import LbbClientlbb = LbbClient(os.environ["LBB_URL"], api_key=os.environ["LBB_API_KEY"], graph="recall")graph = lbb.graph("recall")lbb.ontology.define({"format": "spec","merge_default": False,"source": json.dumps({"entity_types": [{"name": "IngredientLot"}, {"name": "IntermediateLot"}, {"name": "ReleasedBatch"},{"name": "StudySite"}, {"name": "ActiveStudySite", "super_types": ["StudySite"]},{"name": "RecallNotice"}, {"name": "AgentDecision"},],"relation_types": [{"name": "PRODUCED_FROM", "source": ["IntermediateLot", "ReleasedBatch"], "target": ["IngredientLot", "IntermediateLot"]},{"name": "RECALLS", "source": "RecallNotice", "target": "IngredientLot", "inverse": "RECALLED_BY"},{"name": "RECEIVED_BATCH", "source": ["StudySite", "ActiveStudySite"], "target": "ReleasedBatch", "inverse": "SHIPPED_TO"},{"name": "DECIDED_ABOUT", "source": "AgentDecision", "target": "RecallNotice"},],}),})lbb.ontology.evolve({"ops": [{"op": "add_property", "name": "lot_number", "value_type": "keyword"},{"op": "add_property", "name": "batch_number", "value_type": "keyword"},{"op": "add_property", "name": "recall_date", "value_type": "date_time"},{"op": "add_property", "name": "based_on_commit_seq", "value_type": "i64"},{"op": "add_property", "name": "decision_outcome", "value_type": "keyword","allowed_values": ["affected", "clear"]},],})Terminal window curl --fail-with-body -sS -X POST "$LBB_URL/v1/ontology/define?graph=recall" \-H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \-d '{"format": "spec", "merge_default": false,"source": "{\"entity_types\":[{\"name\":\"IngredientLot\"},{\"name\":\"IntermediateLot\"},{\"name\":\"ReleasedBatch\"},{\"name\":\"StudySite\"},{\"name\":\"ActiveStudySite\",\"super_types\":[\"StudySite\"]},{\"name\":\"RecallNotice\"},{\"name\":\"AgentDecision\"}],\"relation_types\":[{\"name\":\"PRODUCED_FROM\",\"source\":[\"IntermediateLot\",\"ReleasedBatch\"],\"target\":[\"IngredientLot\",\"IntermediateLot\"]},{\"name\":\"RECALLS\",\"source\":\"RecallNotice\",\"target\":\"IngredientLot\",\"inverse\":\"RECALLED_BY\"},{\"name\":\"RECEIVED_BATCH\",\"source\":[\"StudySite\",\"ActiveStudySite\"],\"target\":\"ReleasedBatch\",\"inverse\":\"SHIPPED_TO\"},{\"name\":\"DECIDED_ABOUT\",\"source\":\"AgentDecision\",\"target\":\"RecallNotice\"}]}"}'curl --fail-with-body -sS -X POST "$LBB_URL/v1/ontology/evolve?graph=recall" \-H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \-d '{"ops": [{"op": "add_property", "name": "lot_number", "value_type": "keyword"},{"op": "add_property", "name": "batch_number", "value_type": "keyword"},{"op": "add_property", "name": "recall_date", "value_type": "date_time"},{"op": "add_property", "name": "based_on_commit_seq", "value_type": "i64"},{"op": "add_property", "name": "decision_outcome", "value_type": "keyword", "allowed_values": ["affected", "clear"]}]}'allowed_valuesis an enforced value set. A later write withdecision_outcome: "maybe"is rejected at commit time. No rules file is needed. The message is:value for 'decision_outcome' is not one of its allowed values [affected, clear]inversedeclares a second name for the same link. A query can askSHIPPED_TOorRECEIVED_BATCHand read the same fact.GET /v1/ontology?graph=recallreturns what the graph declares. Itsproperty_defslist the field names and types a write may use. -
Write records. A commit carries
triplets(links) andentity_properties(values). Give a record akeywhen a source system already identifies it. A rename then keeps the same record. Two records with the same display name stay distinct.Each triplet can carry a plain
evidencestring, the reason the fact was recorded.const lot = { type: "IngredientLot", name: "Valsartan API lot ZH-VAL-2018-07", key: "ZH-VAL-2018-07" };const granulation = { type: "IntermediateLot", name: "Granulation lot G-17", key: "G-17" };const batch = { type: "ReleasedBatch", name: "Cardiovex batch CVX-A17", key: "CVX-A17" };const notice = { type: "RecallNotice", name: "FDA valsartan recall" };const written = await graph.facts.create({triplets: [{ source: notice, relation: "RECALLS", target: lot, evidence: "FDA recall notice, 2018-07-13" },{ source: granulation, relation: "PRODUCED_FROM", target: lot, evidence: "Batch record CVX-A17, page 3" },{ source: batch, relation: "PRODUCED_FROM", target: granulation, evidence: "Batch record CVX-A17, page 9" },{ source: { type: "ActiveStudySite", name: "Berlin Cardiology Study Site" },relation: "RECEIVED_BATCH", target: batch, evidence: "Shipment receipt, Berlin site" },{ source: { type: "StudySite", name: "Oslo Cardiology Study Site" },relation: "RECEIVED_BATCH", target: batch, evidence: "Shipment receipt, Oslo site (closed 2018-03-31)" },],entity_properties: [{ ...lot, properties: { lot_number: "ZH-VAL-2018-07" } },{ ...batch, properties: { batch_number: "CVX-A17" } },{ ...notice, properties: { recall_date: "2018-07-13" } },],},{ idempotencyKey: "recall-load-1" },);await graph.waitForPublished(written.commit_seq);console.log(written.commit_seq);lot = {"type": "IngredientLot", "name": "Valsartan API lot ZH-VAL-2018-07", "key": "ZH-VAL-2018-07"}granulation = {"type": "IntermediateLot", "name": "Granulation lot G-17", "key": "G-17"}batch = {"type": "ReleasedBatch", "name": "Cardiovex batch CVX-A17", "key": "CVX-A17"}notice = {"type": "RecallNotice", "name": "FDA valsartan recall"}written = graph.facts.create({"triplets": [{"source": notice, "relation": "RECALLS", "target": lot, "evidence": "FDA recall notice, 2018-07-13"},{"source": granulation, "relation": "PRODUCED_FROM", "target": lot, "evidence": "Batch record CVX-A17, page 3"},{"source": batch, "relation": "PRODUCED_FROM", "target": granulation, "evidence": "Batch record CVX-A17, page 9"},{"source": {"type": "ActiveStudySite", "name": "Berlin Cardiology Study Site"},"relation": "RECEIVED_BATCH", "target": batch, "evidence": "Shipment receipt, Berlin site"},{"source": {"type": "StudySite", "name": "Oslo Cardiology Study Site"},"relation": "RECEIVED_BATCH", "target": batch, "evidence": "Shipment receipt, Oslo site (closed 2018-03-31)"},],"entity_properties": [{**lot, "properties": {"lot_number": "ZH-VAL-2018-07"}},{**batch, "properties": {"batch_number": "CVX-A17"}},{**notice, "properties": {"recall_date": "2018-07-13"}},],}, idempotency_key="recall-load-1")graph.wait_for_published(written["commit_seq"])print(written["commit_seq"])Terminal window curl --fail-with-body -sS -X POST "$LBB_URL/v1/graph/commit?graph=recall" \-H "Authorization: Bearer $LBB_API_KEY" -H "Idempotency-Key: recall-load-1" \-H "Content-Type: application/json" \-d '{"triplets": [{"source": {"type": "RecallNotice", "name": "FDA valsartan recall"}, "relation": "RECALLS","target": {"type": "IngredientLot", "name": "Valsartan API lot ZH-VAL-2018-07", "key": "ZH-VAL-2018-07"},"evidence": "FDA recall notice, 2018-07-13"},{"source": {"type": "IntermediateLot", "name": "Granulation lot G-17", "key": "G-17"}, "relation": "PRODUCED_FROM","target": {"type": "IngredientLot", "name": "Valsartan API lot ZH-VAL-2018-07", "key": "ZH-VAL-2018-07"},"evidence": "Batch record CVX-A17, page 3"},{"source": {"type": "ReleasedBatch", "name": "Cardiovex batch CVX-A17", "key": "CVX-A17"}, "relation": "PRODUCED_FROM","target": {"type": "IntermediateLot", "name": "Granulation lot G-17", "key": "G-17"},"evidence": "Batch record CVX-A17, page 9"},{"source": {"type": "ActiveStudySite", "name": "Berlin Cardiology Study Site"}, "relation": "RECEIVED_BATCH","target": {"type": "ReleasedBatch", "name": "Cardiovex batch CVX-A17", "key": "CVX-A17"},"evidence": "Shipment receipt, Berlin site"},{"source": {"type": "StudySite", "name": "Oslo Cardiology Study Site"}, "relation": "RECEIVED_BATCH","target": {"type": "ReleasedBatch", "name": "Cardiovex batch CVX-A17", "key": "CVX-A17"},"evidence": "Shipment receipt, Oslo site (closed 2018-03-31)"}],"entity_properties": [{"type": "IngredientLot", "name": "Valsartan API lot ZH-VAL-2018-07", "key": "ZH-VAL-2018-07", "properties": {"lot_number": "ZH-VAL-2018-07"}},{"type": "ReleasedBatch", "name": "Cardiovex batch CVX-A17", "key": "CVX-A17", "properties": {"batch_number": "CVX-A17"}},{"type": "RecallNotice", "name": "FDA valsartan recall", "properties": {"recall_date": "2018-07-13"}}]}'The response reports
commit_seq. The code then waits until the commit is published. The reads in the next steps then see it.Field writes merge. A later commit that sends only
recall_datekeepslot_numberin place. A value of the wrong type fails the whole commit before anything is written. So does an unknown field or an unknown type.For a large load, stream the same records as newline-delimited JSON. Use the durable import job.
-
Query a single hop without query text. The structured query takes link names and fields as JSON. This one asks which records were produced directly from the recalled lot:
const direct = await lbb.entities.filterByAttributes({patterns: [{ subject: { var: "made" }, predicate: "PRODUCED_FROM", object: { var: "lot" } }],where: [{ var: "lot", field: "lot_number", value: "ZH-VAL-2018-07" }],select: ["made"],});direct = lbb.entities.filter_by_attributes(patterns=[{"subject": {"var": "made"}, "predicate": "PRODUCED_FROM", "object": {"var": "lot"}}],where=[{"var": "lot", "field": "lot_number", "value": "ZH-VAL-2018-07"}],select=["made"],)That returns the granulation lot, one hop away, with its type and name. The released batch is two hops away, and a real genealogy can have many. SPARQL property paths follow a chain of unknown length. So the next step uses query text.
-
Follow the chain with one SPARQL query. Each name you declared in JSON has a fixed IRI. A query needs three prefixes and nothing else:
You declared SPARQL IRI Rule Entity type ReleasedBatch<https://littlebigbrain.com/class/releasedbatch>lowercase Relation PRODUCED_FROM<https://littlebigbrain.com/r/produced_from>lowercase Property batch_number<https://littlebigbrain.com/p/batch_number>lowercase An entity’s namerdfs:labelplain string dr:produced_from+means “one or morePRODUCED_FROMlinks in a row”. The query finds the released batches whose genealogy reaches the recalled lot. It also finds the active site that received each one:PREFIX dclass: <https://littlebigbrain.com/class/>PREFIX dr: <https://littlebigbrain.com/r/>PREFIX dp: <https://littlebigbrain.com/p/>PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>SELECT ?batchNumber ?site WHERE {?lot dp:lot_number "ZH-VAL-2018-07" .?batch a dclass:releasedbatch ;dr:produced_from+ ?lot ;dp:batch_number ?batchNumber .OPTIONAL {?siteNode a dclass:activestudysite ;dr:received_batch ?batch ;rdfs:label ?site .}}ORDER BY ?batchNumber ?siteIn the code below,
genealogyQuery(genealogy_queryin Python) holds this query text. Pass the write’scommit_seqas the floor. The response names the commit it answered from, assnapshot.served_at_seq.Publication may not cover the floor yet. Then the server answers with a retryable
429and aRetry-After. The SDKs wait and retry. With cURL,--retrydoes the same.const exposure = await lbb.sparqlRows({ query: genealogyQuery },{ consistency: "eventual", minIndexedSeq: written.commit_seq },);const readAt = exposure.snapshot!.served_at_seq!; // keep this for step 6exposure = lbb.sparql(genealogy_query, consistency="eventual", min_indexed_seq=written["commit_seq"])read_at = exposure.snapshot["served_at_seq"] # keep this for step 6Terminal window curl --retry 10 --fail-with-body -sS -X POST \"$LBB_URL/v1/query/sparql-text?graph=recall&consistency=eventual&min_indexed_seq=$COMMIT_SEQ" \-H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \-d "{\"query\": $(jq -Rs . < genealogy.sparql)}"The result is one row: batch
CVX-A17, siteBerlin Cardiology Study Site. The Oslo site received the same batch. It is aStudySite, so theactivestudysitepattern leaves it out. That is the reportable answer.A strong read is exact as soon as the commit is acknowledged. Pass
consistency: "strong"with the same floor. Its response has nosnapshotblock.Use it when you need the answer now. Use the eventual read with a floor when you must record which commit you read.
-
Validate writes.
allowed_valuesalready guards one field. For rules across fields and links, publish SHACL shapes. This is the one Turtle document in the guide. It uses the same three prefixes:@prefix sh: <http://www.w3.org/ns/shacl#> .@prefix dclass: <https://littlebigbrain.com/class/> .@prefix dr: <https://littlebigbrain.com/r/> .@prefix dp: <https://littlebigbrain.com/p/> .<https://example.org/shapes/ReleasedBatch> a sh:NodeShape ;sh:targetClass dclass:releasedbatch ;sh:property [ sh:path dp:batch_number ; sh:maxCount 1 ;sh:message "A released batch carries at most one batch number." ] ;sh:property [ sh:path dr:produced_from ; sh:minCount 1 ;sh:message "A released batch names the lot it was produced from." ] .Publish in
warnmode first and read the conformance report. Then publish the same source inrejectmode. In the code,shapesTurtle(shapes_turtlein Python) holds the Turtle document above.await graph.schema.publish({ desired_mode: "warn", shapes: { source: shapesTurtle, format: "turtle" } });const report = await graph.ontology.conformance({ consistency: "strong" });if (report.conforms) {await graph.schema.publish({ desired_mode: "reject", shapes: { source: shapesTurtle, format: "turtle" } });}lbb.schema.publish({"desired_mode": "warn", "shapes": {"source": shapes_turtle, "format": "turtle"}})report = lbb.ontology.conformance(consistency="strong")if report.conforms:lbb.schema.publish({"desired_mode": "reject", "shapes": {"source": shapes_turtle, "format": "turtle"}})The strong conformance read returns a retryable
429until the report for the new shapes exists. Then it returnsconforms,result_countandvalidated_at_seq.In
rejectmode, a write that breaks a shape returns HTTP 400. Nothing is stored. A shipment to a new batch that names no lot fails like this:schema validation failed in reject mode with 1 SHACL violation(s):http://www.w3.org/ns/shacl#MinCountConstraintComponenton '<https://littlebigbrain.com/e/4b3d…>' path '<https://littlebigbrain.com/r/produced_from>':A released batch names the lot it was produced from.Preflight with dry_run=true to get the full structured report (schema_validation.results)To get that report as data before a real write, use a dry run. Send the same body to
commitDryRun/commit_dry_run.Then
schema_validation.conformsisfalse.schema_validation.resultslists each violation with itscomponent,focus_node,result_pathandmessage. An agent can read that list, fix its own output and retry. -
Record the decision and replay it later. An agent that acts on a query result should store what it read. Write the decision as a record. Set its
based_on_commit_seqto theserved_at_seqfrom step 4. Link it to the notice it was about:const decision = { type: "AgentDecision", name: "Valsartan lot impact assessment", key: "decision-2018-07-13-01" };await graph.facts.create({triplets: [{ source: decision, relation: "DECIDED_ABOUT", target: notice,evidence: "genealogy query, batch CVX-A17 and Berlin site exposed" }],entity_properties: [{ ...decision, properties: { based_on_commit_seq: readAt, decision_outcome: "affected" } }],},{ idempotencyKey: "decision-2018-07-13-01" },);// Weeks later: rerun the same query against the state the agent read.const replay = await lbb.sparqlRows({ query: genealogyQuery, as_of_commit_seq: readAt });decision = {"type": "AgentDecision", "name": "Valsartan lot impact assessment", "key": "decision-2018-07-13-01"}graph.facts.create({"triplets": [{"source": decision, "relation": "DECIDED_ABOUT", "target": notice,"evidence": "genealogy query, batch CVX-A17 and Berlin site exposed"}],"entity_properties": [{**decision, "properties": {"based_on_commit_seq": read_at, "decision_outcome": "affected"}}],}, idempotency_key="decision-2018-07-13-01")# Weeks later: rerun the same query against the state the agent read.replay = lbb.sparql(genealogy_query, as_of_commit_seq=read_at)Suppose a later commit retracts the Berlin shipment. The receipt was filed against the wrong site. A query of the latest data then returns batch
CVX-A17with no site.The replay still returns
CVX-A17withBerlin Cardiology Study Site. Itssnapshotechoesas_of_commit_seq. The decision and the rows it rested on stay together.Reads of a retained commit are exact. A commit may no longer be retained. Then the read fails with
historical_generation_unavailable. It never returns a nearby state. Replay an answer covers retention and the change log.
Where the JSON path stops
Section titled “Where the JSON path stops”The type hierarchy stays on the JSON path. Step 1 gives ActiveStudySite the
parent StudySite. The graph stores that link as an rdfs:subClassOf
statement. A query for dclass:studysite with entailment: "subclass" also
matches the active sites. See
Reasoning & inference.
Three things need RDF statements. Each is optional, and each works on the graph you built above without changing it.
- OWL axioms. OWL can declare that a batch with a recalled lot has a reportable exposure. One plain pattern then returns the answer. The JSON schema has no field for such rules, so load them with RDF import. See Answer a question with OWL.
- Records that already are RDF. A dump from another triple store loads
into the same graph with
facts.importRdf. Its statements keep their source predicates, next to the JSON-declared ones. - Load the JSON records first and the RDF second. A graph whose first write
is RDF is RDF-native. It refuses the JSON path with
400 rdf_native_graph. See Load RDF data. - Standard tooling. YASGUI, Protégé and RDFLib connect to the graph’s
/sparqlendpoint with the stack API key. ACONSTRUCTquery there returns Turtle. That is the export path for the whole graph.
Related
Section titled “Related”- Use cases: five more agent decisions with the same modeling and query patterns.
- Coming from SQL: the mapping from tables and joins to types and links.
- Query & validate: the full structured query surface and SHACL enforcement modes.
- HTTP API: every route this guide called.