← richlearning.ai

Can a Self-Driving Car Explain Every Decision It Makes?

A Graph-Native Autonomous Driving Agent Where Every Stop Has a Name

When a self-driving car stops unexpectedly, who do you ask why?

With a neural end-to-end system, the honest answer is: nobody knows. The weights decided. We built something different.

This post documents selfdriving-simulation — a Rich Learning V2 domain implementation where every driving decision is a traversable graph query with a named reason, a typed source object, and a human-readable audit line. The agent learns from experience and replays past decisions without re-computing them. And when it's wrong, you can read exactly why.

💾
Runs entirely on your machine Neural perception models (YOLO, lidar pipelines) run locally. The graph memory is local. No cloud inference APIs, no remote calls — the system works with no internet connection at runtime.
🧠
Learns as you drive Every completed drive is stored in a graph. The next time the agent sees the same scene, it recalls the decision instantly — no recomputation, no retraining.
🔍
Every decision has a name Stop, Slow, Continue — each one traces to a typed reason you can read, query, and audit. No black box.

The Problem With Opaque Driving

Modern end-to-end autonomous driving systems make decisions the same way a large language model generates tokens: by passing inputs through millions of parameters and producing an output. The process is fast. The result is often correct. But the reasoning is invisible.

Ask a neural system "why did you brake?" and the truthful answer is a 70-million-parameter tensor. That's not an explanation. In safety-critical domains — and autonomous driving is as safety-critical as it gets — that's a liability.

There's a second problem: amnesia. Each drive is a cold start. Yesterday's experience with a red light at 18 metres on the northbound lane is not remembered. The agent re-derives the same decision, at the same cost, every time.

What we wanted: A driving agent where every decision traces to a named reason, and where known situations are recalled from memory — not recomputed from scratch.

What Rich Learning Replaces (And What It Doesn't)

Let's be precise. This project does not replace deep learning for computer vision. YOLO still identifies the pedestrian. A lidar pipeline still clusters the point cloud. Neural perception still runs — and in Phase 3, it runs locally via a CARLA bridge.

What Rich Learning replaces is high-level behavioural decision-making and memory storage. Once perception has classified the scene — "red signal at 18 metres, obstacle 12 metres ahead, visibility 45 metres" — the decision layer takes over. That layer is a graph, not a neural network. Every decision is a typed edge. Every reason is a named field. Every replay is a graph lookup.

Neural perception remains — and runs locally. Camera, lidar, and radar inputs are classified by local models (e.g. YOLO for object detection, lidar clustering pipelines). No cloud inference APIs. No internet required at runtime.
Neural decision-making is replaced. Stop, Slow, and Continue decisions are computed by explicit constraint evaluation against a typed scene graph.
Memory is structural, not parametric. Past decisions are stored as typed graph edges. Replay is a lookup, not a re-inference.

How It Works: From Sensor to Decision to Memory

The architecture has three clean layers, separated by typed contracts. No layer reaches across the boundary above or below it.

Layer 1 — The Domain (F#)

The pure decision types live in F#. This is not a stylistic preference — it's a safety guarantee. F# discriminated unions give us exhaustive pattern matching at compile time. If you add a new signal state (say, Flashing), every match expression that doesn't handle it becomes a build error. The CI system rejects the code before it can reach production.

Types.fs — closed decision types
type SignalState = Green | Yellow | Red

type PlannerDecision =
    | Continue of reason: string
    | Slow     of reason: string
    | Stop     of reason: string * emergencyBrake: bool

The validation logic is just as explicit. The compiler rejects any match that doesn't cover all cases:

Validation.fs — exhaustive legality rules
let validateAction (scene: DrivingScene) (action: PlannerDecision) =
    match scene.SignalState, scene.HasObstacleAhead, scene.VisibilityMeters, action with
    | Red,  _,    d, Continue when d <= 30.0 -> Invalid "Continue illegal near red signal"
    | _,    true, _, Continue                -> Invalid "Continue illegal with obstacle ahead"
    | _,    _,    v, Continue when v < 25.0  -> Invalid "Continue illegal in low visibility"
    | _                                      -> Valid

Layer 2 — The Integration (C#)

C# owns everything that crosses into the Rich Learning V2 kernel: typed graph object wrappers, the perception adapter, the graph persistence layer, and the planning workflow. The F# types cross cleanly — discriminated unions compile to sealed class hierarchies that C# can switch on.

DrivingSceneLandmarkObject — the scene as a kernel landmark (stable identity, episode tag, lane key)
VehicleBoundaryObject — can veto planner actions via IExecutableBoundaryObject.Validate, calling the F# constraint layer. If a proposed action is vetoed, it is instantly rejected and an alternative must be generated.
SceneToDecisionEdge — the typed graph edge that persists scene→decision pairs into graph memory

Layer 3 — Perception (The CARLA Seam)

Sensor frames are adapters, not first-class graph objects. The IPerceptionProvider seam accepts Camera, Lidar, and Radar sub-records and produces the typed context and boundary objects the planner consumes. In Phase 1 and 2, a SyntheticPerceptionProvider satisfies this contract. In Phase 3, a CarlaPerceptionProvider wires real CARLA payloads into the same typed structs — no planner changes required.

The Results: 22 Green Tests, Two Episodes, One Audit Trail

The vertical slice covers two driving episodes against four named scenarios. Episode 1 (morning commute) actively computes decisions. Episode 2 (afternoon replay) recalls them from graph memory.

22/22
Tests Green
Phase 1 + 2 acceptance criteria
4
Named Scenarios
Stop, Slow, Continue, Neighbour recall
0
Re-computations in Ep. 2
All decisions recalled from graph

What the Audit Trail Actually Says

Every decision produces a structured audit log. Here's the real output from the demo run — this is not hand-crafted. It's the exact JSON from demo-output/audit.json:

Scene Episode Source Decision Reason
sim-stop-red-obstacle ep-morning-commute ActiveComputed Stop Red signal 18m ahead within legal stop distance.
sim-slow-low-visibility ep-morning-commute ActiveComputed Slow Visibility 15m below safe threshold.
sim-continue-clear ep-morning-commute ActiveComputed Continue All clear: green signal, no obstacle, visibility 80m.
sim-stop-red-obstacle ep-afternoon-replay PassiveRecalled EXACT Stop Recalled from graph memory — zero recomputation.
sim-stop-red-obstacle-near ep-afternoon-replay PassiveRecalled SIMILAR (d=0.022) Stop Nearest-neighbour match from similar scene in memory.

The passive recall path labels every decision as EXACT (distance = 0.0) or SIMILAR (nearest-neighbour with a reported distance). The system never silently falls through — the audit line always tells you how the decision was produced.

What a Recalled Decision Looks Like

Here's the full audit trail for the sim-stop-red-obstacle-near scenario — a scene the agent has never seen before, handled by structural similarity lookup:

audit.json — passive recall SIMILAR path
scene=sim-stop-red-obstacle-near episode=ep-afternoon-replay
  lane=northbound-through signal=red@20m obstacle=11m visibility=45m

passive-recall SIMILAR (d=0.022): decision=stop
  from scene=sim-stop-red-obstacle

recalled reason="Red signal 18m ahead within legal stop distance."
constraint-revalidation: accepted (recalled decision still legal in current scene)
boundary-validator: accepted

The agent saw a red light at 20 metres with an obstacle at 11 metres. It had learned from a scene with a red light at 18 metres and an obstacle at 12 metres. Structural distance: 0.022 (scale: 0.0 = exact, 1.0 = maximum distance). Decision: Stop. Revalidated. Accepted. No neural inference required.

Notice the constraint-revalidation line. The recalled decision isn't just replayed blindly — it's validated against the legality rules of the new scene. If the recalled action is no longer legal, the graph rejects it and falls back to active planning.

The Neo4j Export: Reasoning as a Queryable Graph

Every decision is also written to demo-output/decision-log.cypher — a Neo4j MERGE script that renders the entire decision history as a live graph. When Phase 3 lands a live Neo4j backend, researchers can query: "show me every scene where the agent recalled a Stop from a structurally similar episode."

Why this matters for safety: In neural systems, failure analysis requires re-running the model on the offending input and hoping the attention maps are interpretable. In this system, failure analysis is a graph query. The scene object, the constraint that rejected the action, and the recalled memory that influenced the decision are all first-class nodes. You don't need an interpreter — you need a browser.

Why Two Languages?

The project uses F# for the domain layer and C# for the integration layer. This is worth explaining, because it's a deliberate engineering choice — not a preference.

F#
Exhaustive match at compile time. Add a new SignalState variant and every unhandled match becomes a build error. CI rejects the code. No runtime miss possible.
C#
Kernel API compatibility. Rich Learning V2 interfaces are authored in C#. The adapter and workflow layers live here to stay idiomatic with the framework surface.
Hard boundary between layers. F# never imports the V2 kernel. C# never implements domain validation logic. The only crossing point is the F# → C# type boundary — sealed DU hierarchies consumed via C# pattern matching.

What's Next

Phase 1 and Phase 2 are shipped and green. Here's what comes next:

# Item Effort Value
1 Live Neo4j backend — swap the .cypher file export for a queryable Neo4j graph Medium High — strongest external demo surface
2 DapsaEngine wiring — thread the workflow through the full consonance → active → fossilize lifecycle Medium Full integration to unlock continuous active-to-fossil learning cycles in complex driving scenarios
3 CARLA adapter — implement IPerceptionProvider against a live CARLA Python gRPC bridge Large High — the seam is ready, this is only external integration

What This Experiment Doesn't Prove Yet