Bidirectional Requirements Traceability with Reproducible Evidence¶
Michael Zargham — Dynamical Systems Group JAXA MBSE/MCE Workshop · Presentations from Industry · 2026-06-12
How do we keep records of design judgment auditable across heterogeneous tools, disciplines, and time?
Two ideas carry this whole demo:
- Evidence does not verify requirements; only human attestation does.
- The vendor-neutral semantic spine is how we make provenance work across distinct authoritative sources of truth (ASoTs).
This notebook is the live walkthrough.
import os, json
from pathlib import Path
from typing import cast
import numpy as np
import matplotlib.pyplot as plt
from IPython.display import HTML
from rdflib import Dataset, Graph, URIRef
from rdflib.query import ResultRow
from ontology.prefixes import (
ADCS, RTM, EARL, PROV,
G_AUDIT, G_EVIDENCE, G_ATTESTATIONS, G_STRUCTURAL,
NAMED_GRAPHS,
)
# Consistent palette: blue/green/purple for X/Y/Z; red reserved for limits.
AXIS_COLORS = {"X": "#1f77b4", "Y": "#2ca02c", "Z": "#9467bd"}
LIMIT_COLOR = "#d62728"
def select(graph: Graph, q: str) -> "list[ResultRow]":
# Run a SPARQL SELECT and expose rows as ResultRow (typed attribute
# access: row.<var>); rdflib's Result iterator doesn't narrow to it.
return cast("list[ResultRow]", list(graph.query(q)))
def load_dotenv_file(path=".env"):
# Load a local .env into os.environ (no dotenv dependency by design).
# Already-set shell/kernel values win; blank values are skipped (so the
# FLEXO_TOKEN placeholder stays offline until you paste a real key).
# Returns the variable NAMES loaded -- never prints values.
p = Path(path)
if not p.exists():
return []
loaded = []
for line in p.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
key, val = key.strip(), val.strip()
if key and val and key not in os.environ:
os.environ[key] = val
loaded.append(key)
return loaded
_loaded = load_dotenv_file()
print("loaded from .env:", ", ".join(_loaded) if _loaded else "(none — offline rehearsal)")
print("cwd:", Path.cwd())
print("environment ready")
loaded from .env: FLEXO_TOKEN, FLEXO_URL, FLEXO_ORG, FLEXO_REPO, FLEXO_TIMEOUT, FLEXO_PROBE_TIMEOUT, FLEXO_BRANCH_PREFIX, FLEXO_AUTH_URL, FLEXO_USER, FLEXO_PASS, ADCS_OPERATING_ORG_IRI, ADCS_OPERATING_ORG_LABEL, ADCS_OPERATING_ORG_DESCRIPTION, ADCS_HOSTING_ORG_IRI, ADCS_HOSTING_ORG_LABEL, ADCS_HOSTING_ORG_DESCRIPTION, ADCS_TXNLOG_ENABLED cwd: ADCS-lifecycle-demo environment ready
1 · The spine, assembled like packages¶
rtm: is a thin integration ontology. It imports established standards and
adds only convenience subclasses/subproperties — no novel epistemic
vocabulary. The build enforces a triple budget so it cannot quietly grow new
terms.
graph LR PROV[PROV-O · provenance] --> RTM EARL[EARL · outcome + mode] --> RTM GSN[OntoGSN · assurance argument] --> RTM SYSML[SysMLv2 OWL · structure + reqs] --> RTM SH[SHACL · closure rules] --> RTM PPLAN[P-PLAN · process model] --> RTM OSLC[OSLC RM/QM · tool interop] --> RTM RTM[rtm: thin integration glue · 21 subclass + 9 subproperty axioms] RTM --> NG[(8 named graphs)]
The contribution is the assembly, not new terms — imported like open-source packages. The 21 subclass / 9 subproperty axioms and the 8 named graphs are listed in the cell output below.
manifest = json.loads(Path("ontology/assembly_manifest.json").read_text())
art = manifest["artifact"]
print(f"rtm.ttl — {art['total_triples']} triples "
f"(budget {manifest['triple_budget']['value']}, "
f"{manifest['triple_budget']['headroom']} headroom)")
print()
print("Local glue — the project's entire contribution:")
print(f" {art['subclass_axioms']:>3} subclass axioms")
print(f" {art['subproperty_axioms']:>3} subproperty axioms")
print(f" {art['equivalence_axioms']:>3} equivalence axioms (SysMLv2 OWL alignment)")
print()
print("Imported upstream vocabularies (the spine):")
for name, info in sorted(manifest["imports"].items()):
print(f" {name:9s} {info['total_triples']:>5} triples "
f"{info['referenced_count']} terms used {info['iri']}")
rtm.ttl — 241 triples (budget 450, 209 headroom)
Local glue — the project's entire contribution:
21 subclass axioms
9 subproperty axioms
9 equivalence axioms (SysMLv2 OWL alignment)
Imported upstream vocabularies (the spine):
EARL 162 triples 5 terms used http://www.w3.org/ns/earl#
OSLC QM 90 triples 1 terms used http://open-services.net/ns/qm#
OSLC RM 74 triples 1 terms used http://open-services.net/ns/rm#
OntoGSN 3784 triples 3 terms used https://w3id.org/OntoGSN/ontology
P-PLAN 154 triples 0 terms used http://purl.org/net/p-plan
PROV-O 1146 triples 10 terms used http://www.w3.org/ns/prov-o
P-PLAN shows 0 referenced terms because its vocabulary (p-plan:Step,
p-plan:Activity) is used in the instance graphs — the process plan and its
execution trace — not in the rtm: TBox itself.
2 · The system and its requirements · ASoT: git / RDF¶
The structural model is SysMLv2 instance data in RDF: a geostationary comms
satellite (sat:GeoSat) and its ADCS subsystem, with four allocated
requirements. This graph is version-controlled in git — the first
authoritative source of truth.
from analysis.load_params import load_structural_graph, load_params
g_struct = load_structural_graph()
params = load_params(g_struct)
q = '''
PREFIX sysml: <https://www.omg.org/spec/SysML/2.0/>
SELECT ?id ?text WHERE {
?r a sysml:RequirementDefinition ; sysml:declaredName ?id ; sysml:text ?text .
FILTER(STRSTARTS(?id, "REQ-"))
} ORDER BY ?id
'''
for row in select(g_struct, q):
text = " ".join(str(row.text).split())
print(f"{row.id}: {text}\n")
REQ-001: The ADCS shall maintain pointing accuracy within 0.1 degrees (3-sigma) under nominal disturbance conditions within 120 seconds of a slew maneuver. REQ-002: Reaction wheel angular momentum shall not exceed the rated maximum momentum capacity (4.0 N.m.s) during nominal operations. REQ-003: The closed-loop ADCS shall be asymptotically stable with all eigenvalues having real parts less than or equal to -0.010 rad/s. REQ-004: The ADCS shall reject gravity gradient disturbance torques encountered in geostationary orbit without exceeding actuator torque capacity.
3 · Traceable oracles for behavior models¶
A behavior model here is the closed-loop ADCS dynamics, integrated with scipy. A traceable oracle runs that model and emits a machine-checkable, content-addressed, provenance-linked outcome: it compares a model-output metric against the requirement's acceptance criterion.
Crucial distinction: the oracle performs verification — a fully-specified
comparison of a model-level claim ("settling time exceeds the 120 s
budget, within the model"). It is earl:mode = earl:automatic. It does not
assert that the physical requirement is satisfied. Only human attestation does.
from analysis.numerical import run_step_response
from analysis.symbolic import run_symbolic_analysis
# Run the numerical oracle's behavior model: a 10-degree slew, default gains.
sim = run_step_response(params)
summary = sim.summary()
# Symbolic oracle supplies REQ-003's metric (dominant eigenvalue real part).
sym = run_symbolic_analysis(params)
worst_real_part = max(sym.stability_margins.values())
print(f"settling_time_s = {summary['settling_time_s']:.1f} s (budget 120 s)")
print(f"peak_wheel_momentum = {summary['peak_wheel_momentum']:.3f} N.m.s (limit 4.0)")
print(f"worst_real_part = {worst_real_part:.4f} rad/s (limit -0.010) [symbolic]")
settling_time_s = 310.6 s (budget 120 s) peak_wheel_momentum = 0.810 N.m.s (limit 4.0) worst_real_part = -0.0153 rad/s (limit -0.010) [symbolic]
The attitude error is still above the 0.1° line when it crosses the 120 s budget (dotted). The behavior model itself tells us REQ-001's settling requirement is not met — the oracle just makes that machine-checkable.
# Reused step-response figure (analysis already ran above). The dotted vertical
# line is REQ-001's 120 s settling budget — the oracle's acceptance criterion.
fig, axes = plt.subplots(2, 2, figsize=(12, 8))
theta_deg = np.degrees(2 * np.linalg.norm(sim.q[:, :3], axis=1))
axes[0, 0].semilogy(sim.t, theta_deg, color=AXIS_COLORS["X"], lw=1.5, label="attitude error")
axes[0, 0].axhline(0.1, color=LIMIT_COLOR, ls="--", lw=1, label="REQ-001 accuracy (0.1°)")
axes[0, 0].axvline(120, color="#555555", ls=":", lw=1.5, label="REQ-001 budget (120 s)")
axes[0, 0].set(xlabel="time (s)", ylabel="attitude error (deg)", title="Pointing convergence")
axes[0, 0].set_ylim(bottom=1e-3); axes[0, 0].legend(fontsize=8); axes[0, 0].grid(True, alpha=0.3, which="both")
for i, (ax_name, c) in enumerate(AXIS_COLORS.items()):
axes[0, 1].plot(sim.t, np.degrees(sim.omega[:, i]), color=c, lw=1, label=f"{ax_name}-axis")
axes[0, 1].set(xlabel="time (s)", ylabel="rate (deg/s)", title="Angular velocity")
axes[0, 1].legend(fontsize=8); axes[0, 1].grid(True, alpha=0.3)
for i, (ax_name, c) in enumerate(AXIS_COLORS.items()):
axes[1, 0].plot(sim.t, sim.tau_ctrl[:, i], color=c, lw=1, label=f"{ax_name}-axis")
axes[1, 0].axhline(sim.config.max_torque, color=LIMIT_COLOR, ls="--", lw=1, alpha=0.7, label="torque limit")
axes[1, 0].axhline(-sim.config.max_torque, color=LIMIT_COLOR, ls="--", lw=1, alpha=0.7)
axes[1, 0].set(xlabel="time (s)", ylabel="torque (N.m)", title="Control torque")
axes[1, 0].legend(fontsize=8); axes[1, 0].grid(True, alpha=0.3)
axes[1, 1].plot(sim.t, np.linalg.norm(sim.h_wheel, axis=1), color=AXIS_COLORS["X"], lw=1.5, label="|h| total")
axes[1, 1].axhline(sim.config.max_momentum, color=LIMIT_COLOR, ls="--", lw=1, label="REQ-002 limit (4.0)")
axes[1, 1].set(xlabel="time (s)", ylabel="momentum (N.m.s)", title="Wheel angular momentum")
axes[1, 1].legend(fontsize=8); axes[1, 1].grid(True, alpha=0.3)
fig.suptitle("Behavior model — step response: 10° slew (Kp=1, Kd=10)", fontsize=13, fontweight="bold")
fig.tight_layout()
plt.show()
from analysis.oracle import evaluate_requirement_oracle
# One merged metric dict the oracle reads from (numerical + symbolic).
metrics = dict(summary)
metrics["worst_real_part"] = worst_real_part
oracle_results = {req: evaluate_requirement_oracle(metrics, req)
for req in ("REQ-001", "REQ-002", "REQ-003", "REQ-004")}
# Render the model-level VERIFICATION outcomes as a colored table.
badge = {"passed": "#2EA84E", "failed": "#D94A4A", "cantTell": "#A6794D"}
rows = ""
for req, r in oracle_results.items():
c = badge.get(r.outcome, "#888888")
chip = (f"<span style='background:{c};color:white;padding:2px 8px;"
f"border-radius:4px;font-family:monospace'>earl:{r.outcome}</span>")
rows += (f"<tr><td><b>{req}</b></td><td>{chip}</td>"
f"<td style='font-family:monospace;font-size:90%'>{r.detail}</td></tr>")
HTML(
"<table style='border-collapse:collapse'>"
"<thead><tr style='text-align:left;border-bottom:1px solid #ccc'>"
"<th>requirement</th><th>oracle outcome</th><th>model-level comparison</th></tr></thead>"
f"<tbody>{rows}</tbody></table>"
"<p style='font-size:90%;color:#555'>REQ-004 has no machine-readable scalar "
"budget → the oracle returns <b>cantTell</b> rather than fabricating a "
"verdict it cannot compute.</p>"
)
| requirement | oracle outcome | model-level comparison |
|---|---|---|
| REQ-001 | earl:failed | settling_time_s = 310.626 le 120 s -> fail (model-level) |
| REQ-002 | earl:passed | peak_wheel_momentum = 0.809768 le 4 N.m.s -> pass (model-level) |
| REQ-003 | earl:passed | worst_real_part = -0.0152841 le -0.01 rad/s -> pass (model-level) |
| REQ-004 | earl:cantTell | no machine-readable acceptance criterion for this requirement |
REQ-004 has no machine-readable scalar budget → the oracle returns cantTell rather than fabricating a verdict it cannot compute.
from traceability.oracle_assertion import emit_oracle_assertion
# Persist the REQ-001 oracle outcome as an earl:Assertion and show the RDF.
demo = Dataset()
ev = URIRef("urn:adcs:evidence:EV-SIM-REQ-001")
emit_oracle_assertion(demo, ev, ADCS["REQ-001"], oracle_results["REQ-001"])
print(demo.graph(URIRef(G_AUDIT)).serialize(format="turtle"))
@prefix ns1: <http://example.org/ontology/rtm#> .
@prefix ns2: <http://www.w3.org/ns/earl#> .
@prefix prov: <http://www.w3.org/ns/prov#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
<urn:adcs:assertion:oracle-REQ-001-2026-06-11T10-00-20-490164-00-00> a ns1:BehaviorOracleAssertion,
ns2:Assertion,
prov:Activity ;
ns1:evaluatesAgainst <http://example.org/adcs-demo/REQ-001> ;
ns1:metricKey "settling_time_s" ;
ns1:metricValue 310.62618149585654 ;
ns1:thresholdValue 120.0 ;
ns2:mode ns2:automatic ;
ns2:outcome ns2:failed ;
ns2:subject <urn:adcs:evidence:EV-SIM-REQ-001> ;
ns2:test <urn:adcs:test:behavior-oracle> ;
prov:atTime "2026-06-11T10:00:20.490164+00:00"^^xsd:dateTime ;
prov:wasAssociatedWith <urn:adcs:agent:behavior-oracle> .
Read the triples: earl:subject is the evidence under test; the
requirement is linked by rtm:evaluatesAgainst (subPropertyOf prov:used) —
never rtm:attests, which is reserved for the human. The oracle records
what it computed, not a judgment of satisfaction.
4 · The full lifecycle, then attestation = the human boundary¶
Run the whole tested lifecycle locally. The stage banners trace: assemble
ontology → load structural → symbolic → numerical → bind evidence → attest →
closure-rule verification → audit. It returns the populated Dataset.
from pipeline.runner import run_pipeline
ds = run_pipeline(auto_attest=True, backend="local")
print("\nlifecycle complete — Dataset populated")
[Preflight] Probing backends...
Compute: Local in-process compute (host=Michaels-MacBook-Pro-2.local, python=3.12.12)
Storage: Local filesystem (rdflib Dataset, persisted as output/rtm.ttl + output/rtm.trig)
Compute probe: PASS
Storage probe: PASS
Operating org: urn:adcs:org:dynamical-systems-group (Dynamical Systems Group)
Hosting org: urn:adcs:org:planetary-utilities (Planetary Utilities)
[Stage 0/8] Ontology Assembly
──────────────────────────────────────────────────────────────────────────────
Loading assembled rtm.ttl (built 2026-05-29T03:22:19Z from rtm-edit.ttl)
Imports resolved:
OK EARL 162 triples, 5 terms referenced
OK OSLC QM 90 triples, 1 terms referenced
OK OSLC RM 74 triples, 1 terms referenced
OK OntoGSN 3784 triples, 3 terms referenced
OK P-PLAN 154 triples, 0 terms referenced
OK PROV-O 1146 triples, 10 terms referenced
SysMLv2 equivalence axioms: 9
Local rtm: integration glue: 21 subclass + 9 subproperty axioms
Verification: Python assembly only (no-Java path; run `make ontology` for ROBOT/ELK verification)
Loaded into <rtm:ontology>: 454 triples
Closure-rule suite registered: 18 SHACL shapes
──────────────────────────────────────────────────────────────────────────────
[Stage 1] Loading structural model...
Model hash: 2f91b8c4fcb0e4ae...
Parameters loaded: 23
Structural verification: PASS
Compute: Local in-process compute (host=Michaels-MacBook-Pro-2.local, python=3.12.12)
[Stage 2] Running symbolic analysis...
Inertia: Jxx=327.1, Jyy=107.5, Jzz=303.4 kg.m^2 Stability margins: x=-0.0153, y=-0.0465, z=-0.0165 Proof REQ-001: VERIFIED (3 lemmas) Proof REQ-002: VERIFIED (3 lemmas) Proof REQ-003: VERIFIED (4 lemmas) Proof REQ-004: VERIFIED (3 lemmas) [Stage 3] Running numerical simulations... Step response: settling=310.6s, final_error=0.0072 deg Peak wheel momentum: 0.810 N.m.s (limit: 4.0) Peak control torque: 0.0436 N.m (limit: 0.1)
Disturbance rejection: peak_error=0.001000 deg
[Stage 4] Binding evidence to RDF graph...
Evidence artifacts created: 135 nodes (written to <adcs:evidence>)
[Stage 5] Assembling RTM...
Evidence completeness: PASS (all requirements have evidence)
Pre-attestation RTM exported to output/rtm_pre_attestation.{ttl,trig}
[Stage 6] Human attestation...
REQ-001: ATTESTATION DECLINED
Settling time 311s exceeds 120s requirement.
Action: retune gains (Kp: 1->4, Kd: 10->30) and re-verify.
============================================================
Requirement: REQ-001
The ADCS shall maintain pointing accuracy within 0.1 degrees
(3-sigma) under nominal disturbance conditions within 120 seconds
of a slew maneuver.
Derived from: SAT-REQ-POINTING
Allocated to: PDController, StarTracker
Evidence (2 artifacts):
[ProofArtifact] hash=a7f1bfb5ca74cfae...
Symbolic proof: Steady-state pointing error under constant disturbance is 2*tau_gg/Kp, bounded by actuator/gain design
[SimulationResult] hash=068c61dbfae35f13...
Step response: settling=310.6s, final_error=0.0072 deg
============================================================
REQ-001: outcome=earl:failed (ADCS Engineer)
============================================================
Requirement: REQ-002
Reaction wheel angular momentum shall not exceed the rated
maximum momentum capacity (4.0 N.m.s) during nominal operations.
Derived from: SAT-REQ-MOMENTUM
Allocated to: ReactionWheel_X, ReactionWheel_Y, ReactionWheel_Z
Evidence (2 artifacts):
[ProofArtifact] hash=e892ab7d2221e530...
Symbolic proof: Peak wheel momentum during slew is Kd*theta_0*sqrt(Kp/(2*J)), bounded by reaction wheel capacity
[SimulationResult] hash=068c61dbfae35f13...
Peak wheel momentum: 0.810 N.m.s (limit=4.0)
============================================================
REQ-002: outcome=earl:passed (ADCS Engineer)
============================================================
Requirement: REQ-003
The closed-loop ADCS shall be asymptotically stable with
all eigenvalues having real parts less than or equal to -0.010 rad/s.
Derived from: SAT-REQ-STABILITY
Allocated to: PDController
Evidence (1 artifacts):
[ProofArtifact] hash=79dd6b4381abe9cc...
Symbolic proof: PD controller produces asymptotically stable closed loop for all positive J, Kp, Kd (Routh-Hurwitz criterion)
============================================================
REQ-003: outcome=earl:passed (ADCS Engineer)
============================================================
Requirement: REQ-004
The ADCS shall reject gravity gradient disturbance torques
encountered in geostationary orbit without exceeding actuator
torque capacity.
Derived from: SAT-REQ-DISTURBANCE
Allocated to: PDController, ReactionWheel_X, ReactionWheel_Y, ReactionWheel_Z
Evidence (2 artifacts):
[ProofArtifact] hash=983c0d3b3d2e725c...
Symbolic proof: Gravity gradient disturbance torque at GEO is orders of magnitude below reaction wheel torque capacity
[SimulationResult] hash=d19e0be5468eddb0...
Disturbance rejection: peak_error=0.001000 deg
============================================================
REQ-004: outcome=earl:passed (ADCS Engineer)
[Stage 6.5] Verifying closure-rule suite...
Closure-rule verification: PASS
SHACL shapes: 0 violations
Re-verification: 0 mismatches
Closure-rule assertion: urn:adcs:assertion:closure-2026-06-11T10-00-22-078636-00-00
[Stage 7a] Auditing forward / backward / bidirectional traceability...
Forward PASS (4 checked, 0 failures)
Backward PASS (4 checked, 0 failures)
Bidirectional: PASS
Orphans: none
Audit report -> output/audit.md, output/audit.csv
[Stage 7] Generating reports...
Backend: Local filesystem (rdflib Dataset, persisted as output/rtm.ttl + output/rtm.trig)
Persisted 8 named graphs (1110 triples total)
========================================================================
REQUIREMENTS TRACEABILITY MATRIX — STATUS SUMMARY
========================================================================
REQ-001: The ADCS shall maintain pointing accuracy within 0.1 degrees...
Status: ATTESTED (earl:failed) — engineering finding
REQ-002: Reaction wheel angular momentum shall not exceed the rated ...
Status: ATTESTED (earl:passed)
REQ-003: The closed-loop ADCS shall be asymptotically stable with ...
Status: ATTESTED (earl:passed)
REQ-004: The ADCS shall reject gravity gradient disturbance torques ...
Status: ATTESTED (earl:passed)
========================================================================
Open engineering findings (covered+failed/cantTell): REQ-001
========================================================================
[Stage 8] Visualization and interrogation ready.
Use interrogate/explain.py for 'How do you know X?' queries
Use interrogate/reproduce.py to re-verify evidence
Use interrogate/visualize.py to render the RTM graph
lifecycle complete — Dataset populated
# Evidence binding (Stage 4): every artifact is content-addressed and
# addresses a requirement. (rtm:addresses = structural intent, NOT a verdict.)
q = '''
PREFIX rtm: <http://example.org/ontology/rtm#>
SELECT ?ev ?req ?hash ?summary WHERE {
?ev a rtm:SimulationResult ;
rtm:addresses ?req ; rtm:contentHash ?hash ; rtm:resultSummary ?summary .
} ORDER BY ?req
'''
for row in select(ds, q):
print(f"{str(row.req).split('/')[-1]} hash={str(row.hash)[:16]}...")
print(f" {row.summary}\n")
REQ-001 hash=068c61dbfae35f13... Step response: settling=310.6s, final_error=0.0072 deg REQ-002 hash=068c61dbfae35f13... Peak wheel momentum: 0.810 N.m.s (limit=4.0) REQ-004 hash=d19e0be5468eddb0... Disturbance rejection: peak_error=0.001000 deg
The engineer attests each requirement with two distinct judgments — the Hawkins–Habli Assurance Claim Point split:
- model adequacy (
gsn:Assumption) — is the model a faithful enough representation of the physical system? - evidence sufficiency (
gsn:Justification) — given the model is adequate, does the evidence support the conclusion?
For REQ-001 the model-adequacy assumption holds; it is the evidence that
shows the requirement is not yet satisfied, so the requirement outcome is
earl:failed — recorded, not hidden. (The failure is the requirement
verdict, not a judgment that the model is inadequate.)
from collections import defaultdict
q = '''
PREFIX rtm: <http://example.org/ontology/rtm#>
PREFIX gsn: <https://w3id.org/OntoGSN/ontology#>
SELECT ?req ?outcome ?ctype ?stmt WHERE {
?a a rtm:Attestation ; rtm:attests ?req ; rtm:hasOutcome ?outcome ;
gsn:inContextOf ?ctx .
?ctx a ?ctype ; gsn:statement ?stmt .
FILTER(?ctype IN (gsn:Assumption, gsn:Justification))
} ORDER BY ?req
'''
ctx, outcome = defaultdict(dict), {}
for row in select(ds, q):
req = str(row.req).split("/")[-1]
outcome[req] = str(row.outcome).split("#")[-1]
ctx[req][str(row.ctype).split("#")[-1]] = str(row.stmt)
label = {"Assumption": "model adequacy (Assumption)",
"Justification": "evidence sufficiency (Justification)"}
for req in sorted(ctx):
print(f"{req} -> requirement outcome: earl:{outcome[req]}")
for kind in ("Assumption", "Justification"):
if kind in ctx[req]:
print(f" {label[kind]}: {ctx[req][kind]}")
print()
REQ-001 -> requirement outcome: earl:failed model adequacy (Assumption): Rigid-body attitude dynamics with PD control and linearized gravity-gradient disturbance is an adequate representation of the GeoSat ADCS for assessing slew settling at this lifecycle stage. Flexible-mode and sensor-noise effects are second-order for this judgment. evidence sufficiency (Justification): Evidence is sufficient to conclude REQ-001 is NOT yet satisfied: settling time 311s exceeds the 120s requirement. Action item: retune gains (Kp: 1->4, Kd: 10->30) and re-verify. REQ-002 -> requirement outcome: earl:passed model adequacy (Assumption): Energy-based momentum bound is conservative. Reaction wheel model adequate for peak momentum estimation. evidence sufficiency (Justification): Symbolic bound and simulation both confirm peak momentum well below 4.0 N.m.s. REQ-003 -> requirement outcome: earl:passed model adequacy (Assumption): Linearized stability analysis via Routh-Hurwitz is adequate. Nonlinear effects are second-order for small angles. evidence sufficiency (Justification): Routh-Hurwitz proof confirms asymptotic stability for all positive J, Kp, Kd. Numerical eigenvalues confirm margins exceed -0.010 rad/s on all axes. REQ-004 -> requirement outcome: earl:passed model adequacy (Assumption): Linearized gravity gradient model adequate for GEO orbit. Higher-order terms negligible at geostationary altitude. evidence sufficiency (Justification): Gravity gradient torques are micro-Nm at GEO, orders of magnitude below 0.1 N.m actuator capacity. Simulation confirms negligible pointing impact.
5 · Bidirectional traceability¶
Fold the oracle outcomes into the lifecycle Dataset and render the entire RTM.
Every requirement traces forward to evidence and backward from
attestation. The attestation nodes are colored by earl:outcome — REQ-001's
is red (failed).
def evidence_for(ds, req):
q = f'''
PREFIX rtm: <http://example.org/ontology/rtm#>
SELECT ?e WHERE {{ ?e rtm:addresses <{ADCS[req]}> }} LIMIT 1
'''
rows = select(ds, q)
return URIRef(str(rows[0].e)) if rows else URIRef(f"urn:adcs:evidence:{req}")
for req, r in oracle_results.items():
emit_oracle_assertion(ds, evidence_for(ds, req), ADCS[req], r)
from interrogate.visualize import build_rtm_figure
build_rtm_figure(ds, figsize=(17, 9),
title="ADCS RTM — requirements / evidence / attestation")
plt.show()
6 · Provenance across distinct authoritative sources of truth¶
The same Dataset persists identically to local disk, Flexo MMS, or bare
Fuseki. No single store holds the whole truth — each is an authoritative source
under its own jurisdiction, stitched together by standard PROV edges:
graph LR
subgraph git
CODE[code · ontology · Dockerfile]
end
subgraph "local Docker"
IMG[rtm:DockerImage] -->|prov:wasDerivedFrom| CON[rtm:DockerContainer]
end
subgraph "Flexo MMS"
RDF[RDF named graphs · evidence · attestations · audit]
end
subgraph "txn-log store"
LOG[wire-log JSON]
end
CON -->|prov:used| ACT[analysis activity]
ACT -->|prov:wasGeneratedBy| EV[evidence node]
IMG -.->|rtm:gitRef| CODE
IMG -.->|rtm:flexoRecord| RDF
EV --> RDF
ACT -.-> LOG
We push to a Flexo MMS instance hosted by Planetary Utilities live, then read it back to
prove it landed. A fail-fast probe() runs first; with no FLEXO_TOKEN we skip
the remote and demonstrate the identical trust queries against the local
Dataset.
import httpx
from pipeline.backends.flexo import FlexoBackend
from pipeline.backends.base import BackendUnavailable
flexo = FlexoBackend()
flexo_live = False
# A live push needs a credential, not just a reachable host (the HEAD probe
# is unauthenticated). Gate on FLEXO_TOKEN, then probe for reachability.
if not flexo.token:
print("FLEXO_TOKEN not set — offline rehearsal mode.")
print("Trust queries below run identically against the local Dataset.")
else:
try:
flexo.probe()
flexo_live = True
print(f"Flexo reachable: {flexo.describe()}")
except BackendUnavailable as exc:
print(f"Flexo unreachable ({exc}). Continuing with local trust queries.")
Flexo reachable: Flexo MMS at a hosted instance (org=adcs-demo / repo=lifecycle; auth=pre-issued)
if flexo_live:
try:
counts = flexo.persist(ds, Path("output"))
print("pushed named graphs to Flexo:")
for graph_iri, n in counts.items():
print(f" {n:>5} triples -> {graph_iri}")
print(f"\nstable record URI: {flexo.record_uri('attestations')}")
except (BackendUnavailable, httpx.HTTPError) as exc:
flexo_live = False
print(f"live push failed ({exc}); falling back to local trust queries.")
else:
print("(skipped live push — offline rehearsal)")
pushed named graphs to Flexo:
103 triples -> http://example.org/adcs-demo/graph/plan
68 triples -> http://example.org/adcs-demo/graph/audit
57 triples -> http://example.org/adcs-demo/graph/plan-execution
454 triples -> http://example.org/adcs-demo/graph/ontology
6 triples -> http://example.org/adcs-demo/graph/context
89 triples -> http://example.org/adcs-demo/graph/attestations
135 triples -> http://example.org/adcs-demo/graph/evidence
253 triples -> http://example.org/adcs-demo/graph/structural
stable record URI: urn:adcs:flexo:adcs-demo/lifecycle/cert-2026-06-12-001-attestations
if flexo_live:
# Read the attestation back from Flexo to prove it round-tripped.
sparql = '''
PREFIX rtm: <http://example.org/ontology/rtm#>
PREFIX adcs: <http://example.org/adcs-demo/>
ASK { adcs:ATT-REQ-003 a rtm:Attestation }
'''
url = (f"{flexo.url}/orgs/{flexo.org}/repos/{flexo.repo}"
f"/branches/{flexo.branch_prefix}attestations/query")
r = httpx.post(url, headers={
"Authorization": f"Bearer {flexo.token}",
"Content-Type": "application/sparql-query",
"Accept": "application/json",
}, content=sparql, timeout=30.0)
print(f"read-back ASK -> {r.json()}")
else:
print("(skipped live read-back — no FLEXO_TOKEN)")
read-back ASK -> {'head': {}, 'boolean': True}
The trust queries answer "how can I trust this?" — backend-agnostic, they walk evidence → activity → container → image → git ref, plus the auspices (who operated and hosted the run). This is reproducible evidence with full provenance.
from traceability.queries import trust_summary, render_trust_summary
ev_iri = str(ADCS["EV-SIM-REQ-001"])
print(render_trust_summary(trust_summary(ds, ev_iri)))
Trust panel for http://example.org/adcs-demo/EV-SIM-REQ-001 ====================================================================== Technical provenance: activity: http://example.org/adcs-demo/NS-REQ-001 container: - (id=-) image: http://example.org/adcs-demo/EV-PROOF-REQ-001 digest: a7f1bfb5ca74cfae7e8891b5caecb67eab6104b1eb0f838ff14c13ee683b829f git ref: - host: urn:adcs:location:local:Michaels-MacBook-Pro-2.local executor: http://example.org/adcs-demo/ScipyEngine Auspices: operating: - hosting: Planetary Utilities Closure-rule assertions: 1 - outcome=http://www.w3.org/ns/earl#passed violations=0
7 · Close the loop¶
REQ-001 failed. The engineer retunes the controller gains. The model changes,
so its hash changes — every prior proof and simulation bound to the old model
is invalidated. Re-run the behavior model and the oracle: settling drops below
the 120 s budget and the outcome flips to passed. The loop is auditable
because every step traces across the ASoTs — and the vendor-neutral spine is
what made that possible.
retuned = dict(params)
retuned["Kp"], retuned["Kd"] = 4.0, 30.0 # was 1.0 / 10.0
sim2 = run_step_response(retuned)
s2 = sim2.summary()
r2 = evaluate_requirement_oracle({**s2, "worst_real_part": worst_real_part}, "REQ-001")
# Before/after overlay of the attitude-error curve vs the 120 s budget.
theta_v1 = np.degrees(2 * np.linalg.norm(sim.q[:, :3], axis=1))
theta_v2 = np.degrees(2 * np.linalg.norm(sim2.q[:, :3], axis=1))
fig2, ax = plt.subplots(figsize=(9, 5))
ax.semilogy(sim.t, theta_v1, color=LIMIT_COLOR, lw=1.8,
label=f"v1 Kp=1, Kd=10 → {summary['settling_time_s']:.0f} s (FAIL)")
ax.semilogy(sim2.t, theta_v2, color=AXIS_COLORS["Y"], lw=1.8,
label=f"v2 Kp=4, Kd=30 → {s2['settling_time_s']:.0f} s (PASS)")
ax.axhline(0.1, color="#999999", ls="--", lw=1, label="REQ-001 accuracy (0.1°)")
ax.axvline(120, color="#555555", ls=":", lw=1.5, label="REQ-001 budget (120 s)")
ax.set(xlabel="time (s)", ylabel="attitude error (deg)",
title="Design iteration — the loop closes")
ax.set_ylim(bottom=1e-3); ax.legend(fontsize=9); ax.grid(True, alpha=0.3, which="both")
fig2.tight_layout()
plt.show()
print(f"oracle re-verification: earl:{r2.outcome} — {r2.detail}")
print("\nThe engineer would now re-attest REQ-001 against the v2 model.")
print("Evidence, oracle outcomes, and attestations all trace across git +")
print("Flexo + Docker. That is reproducible evidence with full provenance.")
oracle re-verification: earl:passed — settling_time_s = 77.9662 le 120 s -> pass (model-level) The engineer would now re-attest REQ-001 against the v2 model. Evidence, oracle outcomes, and attestations all trace across git + Flexo + Docker. That is reproducible evidence with full provenance.