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 rdflib graph and query it with rdf.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
longeron.widgets.graph3d._viewer_class (static snapshot of the interactive widget)

Static snapshot -- run the notebook in JupyterLab (pixi run lab) to interact.

# 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
longeron.widgets.graph3d._viewer_class (static snapshot of the interactive widget)

Static snapshot -- run the notebook in JupyterLab (pixi run lab) to interact.

# 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
longeron.widgets.graph3d._viewer_class (static snapshot of the interactive widget)

Static snapshot -- run the notebook in JupyterLab (pixi run lab) to interact.

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.

  1. Retrieve: rag.search, rag.neighborhood, or a SPARQL query brings the right elements into the context window.

  2. Cite: chunk ids are qualified names, so the agent’s claims stay addressable instead of paraphrased.

  3. Resolve: feed the cited names back through the Interpreter for 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.