8. The knowledge graph¶
The question: what does motor-out redundancy cost, which shelf parts appear in both catalog conventions, and which requirements does nobody satisfy?
grep answers none of the three. Each answer joins facts from different
parts of the program, or asserts that an edge does not exist. This
notebook projects the DeepScout program onto RDF and answers all three
questions in SPARQL. The graph is not a second model. It is one more
perspective on the program that tutorial 4 trades, tutorial 6 scores, and tutorial 7 measures.
You will learn how to:
project the model onto an
rdflibgraph and query it withrdf.sparql;join satisfy edges with evaluated attribute values (the redundancy price list);
join attribute values across spec conventions to find shared shelf parts;
query for absent edges with
FILTER NOT EXISTS(the coverage holes);read the projection’s vocabulary: element IRIs, spec metaclasses, and value triples;
chunk the model for retrieval and wire the retrieve-cite-resolve agent loop.
Prerequisites: tutorial 4 introduces the configurations this notebook
queries. The SPARQL half needs the rdf extra
(pip install "longeron[rdf]"). The retrieval half (longeron.rag)
needs only the standard library.
import rdflib
import longeron
from longeron import rdf
model = longeron.load("../examples/deepscout")
interp = longeron.Interpreter(model)
# one projection, reused by every query below; evaluated=True asks the
# interpreter to add literals for expression-valued attributes
graph = rdf.to_graph(model, evaluated=True)
print(f"{sum(1 for _ in model.iter_tree())} model elements -> {len(graph)} triples")
assert len(graph) > 8_000
1921 model elements -> 13074 triples
Q1: the redundancy price list¶
Tutorial 4’s family matrix showed which configurations keep hovering after a
motor failure. The model records the outcome as satisfy edges in
multirotor.sysml: satisfy FailSafeHover by HexaCopter and its two
siblings. The projection turns each edge into a
sysml:SatisfyRequirementUsage node. Its sysml:subsets points at the
requirement, and its sysml:satisfiedBy points at the configuration.
The question has a budget attached: what does the redundancy cost? The
query below walks the three edges, then joins each configuration with
its own totalCost and totalMass attributes. Both attributes hold
build-up expressions, not literals, so the query reads the
sysml:evaluatedValue literals the projection added. The result is a
price list that no single file contains.
rows = list(
rdf.sparql(
graph,
"""
SELECT ?name ?cost ?mass WHERE {
?edge a sysml:SatisfyRequirementUsage ;
sysml:subsets ?req ;
sysml:satisfiedBy ?config .
?req sysml:name "FailSafeHover" .
?config sysml:name ?name ;
sysml:ownedMember ?costAttr , ?massAttr .
?costAttr sysml:name "totalCost" ; sysml:evaluatedValue ?cost .
?massAttr sysml:name "totalMass" ; sysml:evaluatedValue ?mass .
} ORDER BY ?cost
""",
)
)
print("the price of surviving a motor failure:")
for row in rows:
print(f" {row.name!s:11s} ${row.cost.toPython():.0f} {row.mass.toPython():.3f} kg")
assert [str(row.name) for row in rows] == ["HexaCopter", "CoaxX8", "OctoCopter"]
the price of surviving a motor failure:
HexaCopter $670 1.758 kg
CoaxX8 $692 1.702 kg
OctoCopter $732 2.040 kg
The graph and the interpreter must agree¶
The satisfy edges are hand-written claims, and the evaluated literals are copies of interpreter results. Either could lie. Two checks pin both to the physics.
First, the values: instantiate each configuration and compare its
totalCost and totalMass slots against the query’s literals. The
projection wrote each literal from the same interpreter, so the match
must be exact, not approximate.
Second, the edges: run check_requirement on FailSafeHover for all
five configurations. The set of passing configurations must equal the
set the graph returned. If a hand-written edge disagreed with the
model’s own thrust arithmetic, this cell would fail.
FAMILY = ("QuadCopter", "TriCopter", "HexaCopter", "OctoCopter", "CoaxX8")
for row in rows:
craft = interp.instantiate(f"Rotorcraft::{row.name}")
assert row.cost.toPython() == craft.slots["totalCost"]
assert row.mass.toPython() == craft.slots["totalMass"]
print("values: the graph's literals equal the interpreter's slots, exactly")
passing = set()
for config in FAMILY:
craft = interp.instantiate(f"Rotorcraft::{config}")
verdict = interp.check_requirement("DeepScout::FailSafeHover", subject=craft)
print(f" FailSafeHover on {config}: {'PASS' if verdict.satisfied else 'FAIL'}")
if verdict.satisfied:
passing.add(config)
assert passing == {str(row.name) for row in rows}
print("edges: the asserted satisfy edges match the computed verdicts")
values: the graph's literals equal the interpreter's slots, exactly
FailSafeHover on QuadCopter: FAIL
FailSafeHover on TriCopter: FAIL
FailSafeHover on HexaCopter: PASS
FailSafeHover on OctoCopter: PASS
FailSafeHover on CoaxX8: PASS
edges: the asserted satisfy edges match the computed verdicts
Q2: one shelf part, two spec sheets¶
parts.sysml holds the catalog in two spec conventions. The bench
sheets in ScoutParts::F450Kit describe one shelf part each, at the
fidelity the build family’s calibrated physics reads. The fleet entries
specialize the family classes Motor, Propeller, and Battery, at
the fidelity the trade studies select over. Three shelf parts appear in
both conventions, under different names: F450Kit::Motor and
EmaxMt2213 describe the same physical motor.
No relationship connects the two spec sheets. Only the values match.
The query below joins the two conventions on equal mass and equal
cost literals. Each side sits in its own sub-select, so the join sees
two small result sets instead of one wide pattern. The bench mass
carries a unit annotation, so its value lands as
sysml:evaluatedValue. The bare fleet literals land as sysml:value.
rows = list(
rdf.sparql(
graph,
"""
SELECT ?bench ?entry ?mass ?cost WHERE {
{ SELECT ?entry ?mass ?cost WHERE {
?family sysml:qualifiedName ?fq .
VALUES ?fq { "ScoutParts::Motor" "ScoutParts::Propeller"
"ScoutParts::Battery" }
?e sysml:specializes ?family ; sysml:qualifiedName ?entry ;
sysml:ownedMember ?em , ?ec .
?em sysml:name "mass" ; sysml:value ?mass .
?ec sysml:name "cost" ; sysml:value ?cost .
} }
{ SELECT ?bench ?mass ?cost WHERE {
?bm sysml:name "mass" ; sysml:evaluatedValue ?mass .
?b sysml:ownedMember ?bm ; sysml:qualifiedName ?bench ;
sysml:ownedMember ?bc .
?bc sysml:name "cost" ; sysml:value ?cost .
FILTER STRSTARTS(?bench, "ScoutParts::F450Kit::")
} }
} ORDER BY ?bench
""",
)
)
for row in rows:
pair = f"{row.bench!s:31s} <-> {row.entry!s:23s}"
print(f"{pair} {row.mass.toPython():.3f} kg ${row.cost.toPython():.0f}")
assert {(str(row.bench), str(row.entry)) for row in rows} == {
("ScoutParts::F450Kit::Motor", "ScoutParts::EmaxMt2213"),
("ScoutParts::F450Kit::Propeller", "ScoutParts::Apc10x45mr"),
("ScoutParts::F450Kit::Battery", "ScoutParts::Tattu3s5200"),
}
ScoutParts::F450Kit::Battery <-> ScoutParts::Tattu3s5200 0.390 kg $45
ScoutParts::F450Kit::Motor <-> ScoutParts::EmaxMt2213 0.055 kg $17
ScoutParts::F450Kit::Propeller <-> ScoutParts::Apc10x45mr 0.015 kg $4
Q3: the coverage holes¶
The third question asks about absence. Which configuration-requirement pairs carry no satisfy edge at all? A text search finds every edge that exists. It cannot return a line that is missing.
FILTER NOT EXISTS states the absence directly. The query below builds
every pair of a MultiRotor configuration and a requirement that
carries at least one satisfy edge. It then keeps the pairs with no
edge. The inner select keeps the requirements this family answers.
The flying wings’ tailless S&C checks carry their own edges, and a
check a rotorcraft never subjects is honest absence, not a hole. The tricopter’s missing mission edge falls out, because tutorial 4
showed the tricopter busts the 6-minute sortie budget. The
installation requirement keeps only its quad edge, because the quad
is the reference build tutorial 7 measured.
The model’s own comment calls the missing edges the point of the
architecture trade: no configuration collects them all.
rows = list(
rdf.sparql(
graph,
"""
SELECT ?reqName ?configName WHERE {
{ SELECT DISTINCT ?req WHERE {
[] a sysml:SatisfyRequirementUsage ; sysml:subsets ?req ;
sysml:satisfiedBy ?rotorConfig .
?rotorConfig sysml:specializes ?mr0 .
?mr0 sysml:qualifiedName "DeepScout::MultiRotor" . } }
?req sysml:name ?reqName .
?config sysml:specializes ?mr ; a sysml:PartDefinition ;
sysml:name ?configName .
?mr sysml:qualifiedName "DeepScout::MultiRotor" .
FILTER NOT EXISTS {
[] a sysml:SatisfyRequirementUsage ;
sysml:subsets ?req ;
sysml:satisfiedBy ?config .
}
} ORDER BY ?reqName ?configName
""",
)
)
holes = [(str(row.reqName), str(row.configName)) for row in rows]
for requirement, config in holes:
print(f" nobody wrote: satisfy {requirement} by {config}")
print(f"{len(holes)} coverage holes")
assert len(holes) == 7
assert ("mission", "TriCopter") in holes # the tri busts the 6-minute budget (tutorial 4)
assert ("installation", "QuadCopter") not in holes # the reference build tutorial 7 measured
assert {config for _, config in holes} == set(FAMILY) # nobody collects every edge
nobody wrote: satisfy FailSafeHover by QuadCopter
nobody wrote: satisfy FailSafeHover by TriCopter
nobody wrote: satisfy installation by CoaxX8
nobody wrote: satisfy installation by HexaCopter
nobody wrote: satisfy installation by OctoCopter
nobody wrote: satisfy installation by TriCopter
nobody wrote: satisfy mission by TriCopter
7 coverage holes
What text search cannot do¶
Be fair to text search: grep finds every satisfy edge in
multirotor.sysml in milliseconds. The doc comments in parts.sysml
name the shared shelf parts outright. The three questions fail for
structural reasons, not for missing keywords.
Q1’s $670 appears in no file, because the number exists only after
the interpreter evaluates a build-up expression. Q2 is a join on
values, and it would also catch an undocumented duplicate that no
comment names. Q3 asks for absent lines, and a text search can only
return lines that exist. The projection carries the model’s structure
into a graph, so SPARQL can join across it and negate over it.
How the model becomes triples¶
Every named element mints an IRI from its qualified name:
Rotorcraft::HexaCopter::totalCost becomes
.../element/Rotorcraft/HexaCopter/totalCost. Its rdf:type is the
spec metaclass that longeron.api emits as @type in Systems Modeling
API records. The RDF view and the API JSON view therefore agree on
what things are.
Relationships collapse into direct predicates: sysml:specializes,
sysml:definedBy, sysml:subsets, sysml:redefines,
sysml:ownedMember, sysml:satisfiedBy. Plain attribute values become
typed literals under sysml:value. Expression values stay as rendered
text under sysml:valueExpression, with the optional
sysml:evaluatedValue literal beside them. Anonymous elements, the
satisfy edges among them, become blank nodes.
The vocabulary namespace belongs to this package, not to OMG. OMG has not published an RDF vocabulary for SysML v2, so squatting on a plausible OMG IRI would be worse than owning an honest one. The class and property names copy the spec metamodel, so a future migration is a namespace substitution.
SYSML = rdflib.Namespace(rdf.VOCABULARY)
def excerpt(qname):
"""The triples about one element, serialized as Turtle."""
node = rdflib.URIRef(rdf.ELEMENT_BASE + qname.replace("::", "/"))
sub = rdflib.Graph()
sub.bind("sysml", SYSML)
for triple in graph.triples((node, None, None)):
sub.add(triple)
return sub.serialize(format="turtle")
print(excerpt("Rotorcraft::HexaCopter::totalCost"))
edges = sorted(
(str(graph.value(edge, SYSML.subsets)), str(graph.value(edge, SYSML.satisfiedBy)))
for edge in graph.subjects(rdflib.RDF.type, SYSML.SatisfyRequirementUsage)
)
assert len(edges) == 23
print("23 satisfy edges, each a blank node:")
for requirement, config in edges:
print(f" {requirement.rsplit('/', 1)[-1]:15s} satisfiedBy {config.rsplit('/', 1)[-1]}")
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix sysml: <https://sanbales.github.io/longeron/rdf/sysml#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
<https://sanbales.github.io/longeron/rdf/element/Rotorcraft/HexaCopter/totalCost> a sysml:AttributeUsage ;
rdfs:label "totalCost" ;
sysml:definedBy <https://sanbales.github.io/longeron/rdf/element/Real> ;
sysml:evaluatedValue 6.7e+02 ;
sysml:kind "attribute" ;
sysml:name "totalCost" ;
sysml:qualifiedName "Rotorcraft::HexaCopter::totalCost" ;
sysml:redefines <https://sanbales.github.io/longeron/rdf/element/DeepScout/MultiRotor/totalCost> ;
sysml:valueExpression "chassis.cost + bayCost + 6.0 * (17.0 + 4.0) + 40.0" .
23 satisfy edges, each a blank node:
FailSafeHover satisfiedBy CoaxX8
FailSafeHover satisfiedBy HexaCopter
FailSafeHover satisfiedBy OctoCopter
FlightEnvelope satisfiedBy CoaxX8
FlightEnvelope satisfiedBy HexaCopter
FlightEnvelope satisfiedBy OctoCopter
FlightEnvelope satisfiedBy QuadCopter
FlightEnvelope satisfiedBy TriCopter
installation satisfiedBy QuadCopter
mission satisfiedBy CoaxX8
mission satisfiedBy HexaCopter
mission satisfiedBy OctoCopter
mission satisfiedBy QuadCopter
PitchRollAuthority satisfiedBy FlyingWingSingle
PitchRollAuthority satisfiedBy FlyingWingTwin
PitchRollAuthority satisfiedBy FlyingWingTwinTip
PitchStability satisfiedBy FlyingWingSingle
PitchStability satisfiedBy FlyingWingTwin
PitchStability satisfiedBy FlyingWingTwinTip
YawStability satisfiedBy FlyingWingSingle
YawStability satisfiedBy FlyingWingTwin
YawStability satisfiedBy FlyingWingTwinTip
CruiseEngineOut satisfiedBy TiltTriWing
The graph in space¶
SPARQL reads the graph one query at a time. A force-directed embedding
shows all of it at once. graph_viewer computes two seeded 3D layouts
in the kernel (numpy, so the shapes are reproducible) and renders the
elements as instanced three.js spheres: one draw call for all of them.
The default view draws element nodes and relationship edges only. The
~5,000 literal triples fold into hover payloads instead of drowning
the picture (literals=True opts them back in).
Colors speak the explorer’s chip language: gray packages, blue
structure, green attributes, purple behavior, red requirements. Node
size grows with degree, billboard labels name the busiest nodes, and
the in-scene legend names every color. The shape names the program:
rosettes of green attribute leaves surround the blue parts that own
them (the model is mostly attributes), and the biggest blue sphere is
DeepScout::MultiRotor, the shared abstraction every configuration
specializes.
The top slider morphs the scene between the two layouts. The force
end shows clusters. The hierarchy end stacks the same nodes into
layered rings, with packages on top and the specialization fringe at
the bottom. The morph is a pure front-end interpolation, so dragging
it costs no kernel time. Drag to orbit, hover to name a node, click
to select it. The panel searches qualified names, budgets the labels,
and toggles namespaces and edge families; the camera flies to
selections and search hits.
The selected trait and on_select are the explorer’s selection
contract, so a graph click can drive the same consumers a tree click
drives. (Widget cells: captured at landing.)
from longeron.widgets import graph3d
viewer = graph3d.graph_viewer(graph) # the evaluated projection from above
print(
f"{viewer.counts['nodes']} element nodes, {viewer.counts['edges']} relationship"
f" edges, laid out in {viewer.layout_seconds:.1f}s"
)
assert 1_000 < viewer.counts["nodes"] < len(graph) # elements, not triples
viewer
1720 element nodes, 2092 relationship edges, laid out in 4.8s
# the filter panel's kernel seam: drop the membership tree and the
# unlinked nodes -- the cross-package web remains, with the nineteen
# red satisfy edges fanning from the ScoutSizing requirements to the
# configurations that answer them
skeleton = graph3d.graph_viewer(
graph,
families=["specialization", "typing", "connection", "satisfy"],
isolated=False,
)
skeleton.selected = ["DeepScout::MultiRotor"] # neighbors light up in-scene
print(f"skeleton: {skeleton.counts['nodes']} nodes, {skeleton.counts['edges']} edges")
skeleton
skeleton: 345 nodes, 334 edges
# focus mode isolates a hub: the kernel re-extracts the k-hop
# neighborhood and re-layouts both embeddings (instant at this size);
# a breadcrumb chip in-scene steps back out. export_html writes the
# focused view as a self-contained page that opens without a kernel.
import tempfile
from pathlib import Path
hub = graph3d.graph_viewer(graph)
hub.focus("DeepScout::MultiRotor", k=1)
print(f"focused: {hub.counts['nodes']} of 1134 nodes around DeepScout::MultiRotor")
page = Path(tempfile.mkdtemp()) / "deepscout_graph.html"
hub.export_html(page)
print(f"{page.name}: {page.stat().st_size / 1e6:.1f} MB, self-contained")
hub
focused: 71 of 1134 nodes around DeepScout::MultiRotor
deepscout_graph.html: 0.1 MB, self-contained
The retrieval substrate¶
SPARQL suits exact structural questions. A language model needs a
different surface: text fragments it can embed, rank, and quote.
longeron.rag produces them from the same model, with no dependency
beyond the standard library. The contract has three parts.
Chunk ids are qualified names, and chunking is deterministic. The same model yields byte-identical chunks, so an embedding cache keyed on
(id, text)stays warm across runs.Chunk text is the exporter’s own fragment printing, so every chunk re-parses as SysML v2. Packages chunk shallow, because their definitions are chunks of their own.
Chunk refs are outgoing qualified names, canonicalized through the resolver, ready to be edges in a retrieval graph.
from longeron import rag
chunks = rag.model_chunks(model)
print(len(chunks), "chunks")
chunk = next(c for c in chunks if c["id"] == "ScoutMissions::Catalog::AirframeChoice")
print("context:", chunk["context"])
print("refs: ", ", ".join(chunk["refs"]))
print(chunk["text"])
longeron.loads(chunk["text"]) # every chunk re-parses
print("the chunk text re-parses cleanly")
349 chunks
context: package ScoutMissions > package Catalog
refs: DeepScout::Airframe, Rotorcraft::BoxQuad, Rotorcraft::TeardropQuad, Rotorcraft::OpenTri, Rotorcraft::HexLifter, Rotorcraft::CoaxOcto, Rotorcraft::RingOcto, WingedVtol::VtolWing, WingedVtol::DartInterceptor, FlyingWings::FlyingWingSingle, FlyingWings::FlyingWingTwin, FlyingWings::FlyingWingTwinTip, TiltRotors::TiltTriWing
variation part def AirframeChoice :> Airframe {
variant part boxQuad : BoxQuad;
variant part teardropQuad : TeardropQuad;
variant part openTri : OpenTri;
variant part hexLifter : HexLifter;
variant part coaxOcto : CoaxOcto;
variant part ringOcto : RingOcto;
variant part vtolWing : VtolWing;
variant part dartInterceptor : DartInterceptor;
variant part flyingWingSingle : FlyingWingSingle;
variant part flyingWingTwin : FlyingWingTwin;
variant part flyingWingTwinTip : FlyingWingTwinTip;
variant part tiltTriWing : TiltTriWing;
}
the chunk text re-parses cleanly
rag.neighborhood walks the chunks within n semantic hops of an
element. Hops follow its types, its specializations, the calcs it
invokes, and, through reverse edges, whoever references it. The shared
MissionUAV assembly pulls its component catalog, its structural
calcs, and the three missions that specialize it.
rag.search is the embedding-free fallback: token scoring in the
TF-IDF style, aware of camelCase, standard library only. The search
below must surface the StationTime calc from plain station-time
words. The substrate is useful before any embedding model enters the
picture.
for c in rag.neighborhood(model, "ScoutMissions::MissionUAV", hops=1):
print(f"{c['kind']:9s} {c['id']}")
print()
hits = rag.search(model, "station time energy", limit=5)
for hit in hits:
print(f"{hit['score']:6.2f} {hit['chunk']['id']}")
assert hits[0]["chunk"]["id"] == "DeepScout::Performance::StationTime"
part def ScoutMissions::MissionUAV
part def DeepScout::Aircraft
calc def DeepScout::Structures::TubeWallForStress
calc def DeepScout::Structures::TubeWallForStiffness
calc def DeepScout::Structures::TubeMass
calc def DeepScout::Structures::SparRootMoment
package ScoutMissions
part def ScoutMissions::Catalog::AirframeChoice
part def ScoutMissions::Catalog::MotorChoice
part def ScoutMissions::Catalog::PropChoice
part def ScoutMissions::Catalog::BatteryChoice
part def ScoutMissions::Catalog::MaterialChoice
part def ScoutMissions::IsrUav
part def ScoutMissions::LogisticsUav
part def ScoutMissions::InterceptUav
package ScoutMissions::MissionRequirements
requirement def ScoutMissions::MissionRequirements::PayloadBayFit
10.24 DeepScout::Performance::StationTime
8.35 ScoutSizing::IsrPrime
5.25 DeepScout::goAroundSortie
4.90 ScoutMissions::StabilityRequirements::EngineOutYaw
4.39 DeepScout::mission
How an agent consumes the model¶
Both surfaces exist for tool use, not for prose retrieval. Generated text about a model is cheap, and this package can execute the model, so the reliable loop has three steps.
Retrieve:
rag.search,rag.neighborhood, or a SPARQL query brings the right elements into the context window.Cite: chunk ids are qualified names, so the agent’s claims stay addressable instead of paraphrased.
Resolve: feed the cited names back through the
Interpreterfor ground truth.
The model answers, and the language model only routes. The cell below
runs one loop. Retrieval surfaces the StationTime calc. The
interpreter instantiates ScoutSizing::IsrPrime, the ISR winner tutorial 4
froze as a sizing context. Its stationMinutes slot reads 200.4
minutes – frozen, deliberately: the fleet’s recorded reference point in
ScoutMissions has since moved to 274.6 (the tailless flying wing of
0.12 out-loiters the frozen bird even while paying for its payload bay;
the frozen bird itself now carries its tip props honestly – derived
recovery and doubler mass, not a free bonus), and the cell states both numbers, so
the loop closes against ground truth instead of a stale paraphrase.
hit = rag.search(model, "time on station minutes", limit=1)[0]["chunk"]
print("retrieved:", hit["id"])
prime = interp.instantiate("ScoutSizing::IsrPrime")
minutes = prime.slots["stationMinutes"]
print(f"ground truth: stationMinutes = {minutes:.1f}")
verdict = interp.check_requirement("ScoutSizing::IsrStation", subject=prime)
print("IsrStation (at least 90 min on station):", "PASS" if verdict.satisfied else "FAIL")
assert verdict.satisfied
reference = interp.evaluate("ScoutMissions::stationMinutes") # the fleet's reference point
assert round(minutes, 1) == 200.4 # the frozen sizing context holds its number
assert reference == 274.6 # the reference moved with 0.12's flying wing
print(f"frozen sizing context: {minutes:.1f} min; fleet reference point: {reference} min")
retrieved: DeepScout::Performance::StationTime
ground truth: stationMinutes = 200.4
IsrStation (at least 90 min on station): PASS
frozen sizing context: 200.4 min; fleet reference point: 274.6 min
The same truth, queried sideways¶
Nothing in this notebook lived outside the model. The satisfy edges, the part specs, and the requirement tree came from the same files tutorial 4 traded, tutorial 6 scored, and tutorial 7 measured. The projection only changed the access path. SPARQL joined values across spec conventions, priced a redundancy decision, and proved seven edges absent. The interpreter re-derived every number the graph quoted. When the model changes, the graph, the chunks, and the answers change with it.
Tutorial 9 assembles every perspective from tutorials 3 through 8 into one grand-tour surface.


