<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
	<id>http://edurange.org/wiki/api.php?action=feedcontributions&amp;feedformat=atom&amp;user=Jwgranville</id>
	<title>EDURange - User contributions [en]</title>
	<link rel="self" type="application/atom+xml" href="http://edurange.org/wiki/api.php?action=feedcontributions&amp;feedformat=atom&amp;user=Jwgranville"/>
	<link rel="alternate" type="text/html" href="http://edurange.org/wiki/Special:Contributions/Jwgranville"/>
	<updated>2026-06-19T03:08:32Z</updated>
	<subtitle>User contributions</subtitle>
	<generator>MediaWiki 1.43.0</generator>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=Message_Bus_Service&amp;diff=945</id>
		<title>Message Bus Service</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=Message_Bus_Service&amp;diff=945"/>
		<updated>2026-06-17T03:30:01Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Module repository and wiki documentation are pending. This is a placeholder topic page to index resources related to the development of the message bus service.&lt;br /&gt;
&lt;br /&gt;
== Related ==&lt;br /&gt;
&lt;br /&gt;
=== Project Charter ===&lt;br /&gt;
[[Message Bus Project Charter]]&lt;br /&gt;
&lt;br /&gt;
=== Examples, Tutorials, Supporting Documents ===&lt;br /&gt;
[[Message Bus Live Coding Exercise]]&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=Message_Bus_Live_Coding_Exercise&amp;diff=944</id>
		<title>Message Bus Live Coding Exercise</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=Message_Bus_Live_Coding_Exercise&amp;diff=944"/>
		<updated>2026-06-16T16:50:09Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* Phase 3: Explore shell command data by hand */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Introduction =&lt;br /&gt;
&lt;br /&gt;
== Audience and scope ==&lt;br /&gt;
&lt;br /&gt;
This exercise is intended for EDURange developers learning how to write event processors with the message bus.&lt;br /&gt;
&lt;br /&gt;
The packaged &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; modules are treated as read-only for this exercise. All new code belongs under a demonstration directory such as:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
workspace/&lt;br /&gt;
    mbus/&lt;br /&gt;
    aostore/&lt;br /&gt;
    demos/&lt;br /&gt;
        __init__.py&lt;br /&gt;
        event_processors/&lt;br /&gt;
            __init__.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Pre-release copies of &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; are available on the Discord fileshare channel. Modules may be released under other package names; see also the EDURange GitHub.&lt;br /&gt;
&lt;br /&gt;
The exercise uses two problem families.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;1. Shell and TTY event processing&#039;&#039;&#039;&lt;br /&gt;
** Send and receive messages through the bus.&lt;br /&gt;
** Capture and inspect message history.&lt;br /&gt;
** Represent high-level shell command events.&lt;br /&gt;
** Recreate the regex-based milestone role in a simplified form.&lt;br /&gt;
** Mock up TTY-derived observations such as keystroke timing.&lt;br /&gt;
* &#039;&#039;&#039;2. Graph event processing&#039;&#039;&#039;&lt;br /&gt;
** Represent graph edge relationships as source events.&lt;br /&gt;
** Derive direct path facts from edge relation events.&lt;br /&gt;
** Add feedback by deriving longer paths from known paths.&lt;br /&gt;
** Simulate nondeterministic processor scheduling while keeping source input fixed.&lt;br /&gt;
&lt;br /&gt;
The shell examples draw from EDURange&#039;s institutional knowledge. The graph examples provide a common model problem family for meaningful use of feedback loops.&lt;br /&gt;
&lt;br /&gt;
== Running the examples ==&lt;br /&gt;
&lt;br /&gt;
Adjust paths to match the local checkout layout if necessary. Start an interpreter from the workspace root with project packages available.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
PYTHONPATH=&amp;quot;$PWD/mbus:$PWD/aostore:$PWD&amp;quot; python3.14&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
When module files are changed, restart the interpreter unless the group is deliberately practicing &amp;lt;code&amp;gt;importlib.reload()&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Create the demonstration package ==&lt;br /&gt;
&lt;br /&gt;
Before the first durable file edit, create the demonstration package directories:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir -p demos/event_processors&lt;br /&gt;
touch demos/__init__.py&lt;br /&gt;
touch demos/event_processors/__init__.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
== Important note about capture records ==&lt;br /&gt;
&lt;br /&gt;
The current demonstration works close to the low-level capture layer. As a result, it exposes &amp;lt;code&amp;gt;MessageSymbolRegistration&amp;lt;/code&amp;gt; records while building the temporary history helper.&lt;br /&gt;
&lt;br /&gt;
That is not the intended ordinary user experience. In a more complete release, higher-level capture and history infrastructure should handle symbol registration bookkeeping on behalf of processor authors. In this exercise, symbol registration records are visible because the demonstration history helper reconstructs readable messages from compact captured records directly.&lt;br /&gt;
&lt;br /&gt;
== Suggested group session flow ==&lt;br /&gt;
&lt;br /&gt;
; Session 1 - Bus and capture basics&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 1:&#039;&#039;&#039;&lt;br /&gt;
** Signal over the bus by hand.&lt;br /&gt;
** Inspect received messages.&lt;br /&gt;
** Inspect low-level captured records.&lt;br /&gt;
* &#039;&#039;&#039;Phase 2:&#039;&#039;&#039;&lt;br /&gt;
** Discuss why symbol registration appears in this temporary demo.&lt;br /&gt;
** Build the capture history helper.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1-3 hours (1-2 hours work, 0.5-1 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 2 - Shell commands and milestones&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 3:&#039;&#039;&#039;&lt;br /&gt;
** Represent a shell command as a dictionary.&lt;br /&gt;
* &#039;&#039;&#039;Phase 4:&#039;&#039;&#039;&lt;br /&gt;
** Replace it with &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Phase 5:&#039;&#039;&#039;&lt;br /&gt;
** Manually evaluate milestone expressions.&lt;br /&gt;
* &#039;&#039;&#039;Phase 6:&#039;&#039;&#039;&lt;br /&gt;
** Implement &amp;lt;code&amp;gt;RegexMilestoneProcessor&amp;lt;/code&amp;gt;.&lt;br /&gt;
** Inspect source and analysis events in history.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3 hours (1.5-2.5 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 3 - TTY-derived observations&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 7:&#039;&#039;&#039;&lt;br /&gt;
** Create raw TTY read, write, and line events.&lt;br /&gt;
** Show why milestones need reconstructed shell command events.&lt;br /&gt;
* &#039;&#039;&#039;Phase 8:&#039;&#039;&#039;&lt;br /&gt;
** Implement keystroke timing.&lt;br /&gt;
** Discuss shell command reconstruction as the next derived-event problem (to be continued in Session 6 onward).&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1-2.5 hours (1-2 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 4 - Graph-derived facts&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 9:&#039;&#039;&#039;&lt;br /&gt;
** Define graph edges and paths.&lt;br /&gt;
* &#039;&#039;&#039;Phase 10:&#039;&#039;&#039;&lt;br /&gt;
** Add the demo-only nonblocking receive helper.&lt;br /&gt;
* &#039;&#039;&#039;Phase 11:&#039;&#039;&#039;&lt;br /&gt;
** Implement direct path derivation.&lt;br /&gt;
* &#039;&#039;&#039;Phase 12:&#039;&#039;&#039;&lt;br /&gt;
** Explore the extension rule by hand.&lt;br /&gt;
* &#039;&#039;&#039;Phase 13:&#039;&#039;&#039;&lt;br /&gt;
** Add path extension feedback.&lt;br /&gt;
** Discuss duplicate prevention.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3.5 hours (1.5-3 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 5 - Nondeterministic processor scheduling&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 14:&#039;&#039;&#039;&lt;br /&gt;
** Add the randomized processor runner.&lt;br /&gt;
* &#039;&#039;&#039;Phase 15:&#039;&#039;&#039;&lt;br /&gt;
** Keep source events fixed.&lt;br /&gt;
** Vary processor scheduling by seed.&lt;br /&gt;
** Compare final path facts across runs.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3.5 hours (1.5-3 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 6 - Follow-up refinements&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 16:&#039;&#039;&#039;&lt;br /&gt;
** Rebuild a graph index from history.&lt;br /&gt;
* &#039;&#039;&#039;Phase 17:&#039;&#039;&#039;&lt;br /&gt;
** Implement shell command reconstruction.&lt;br /&gt;
** Replace local Python payload assumptions with explicit payload projection formats when the bus format layer is ready for the exercise.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-4.5 hours (1.5-4 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
= Event Processor Demonstration Plan =&lt;br /&gt;
&lt;br /&gt;
== Phase 1: Signal over the bus by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Send one message through the direct bus and inspect what the receiver sees.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* A bus is useful only when something emits and something receives.&lt;br /&gt;
* Public message setup uses readable strings: topic, producer, and message type.&lt;br /&gt;
* The direct broker delivers local Python payloads in this demonstration.&lt;br /&gt;
* Capture must be attached before delivery in the current prototype.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Import the bus and capture interfaces:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.capture.sink import CaptureSink&lt;br /&gt;
from mbus.message.records import CapturedMessage&lt;br /&gt;
from mbus.message.symbols import MessageSymbolRegistration&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create a temporary capture sink in the interpreter. This sink stores whatever the bus sends to the capture layer:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
CapturedRecord = MessageSymbolRegistration | CapturedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class ListCaptureSink(CaptureSink):&lt;br /&gt;
    records: list[CapturedRecord] = field(default_factory=list)&lt;br /&gt;
&lt;br /&gt;
    def capture_symbol_registration(&lt;br /&gt;
        self,&lt;br /&gt;
        registration: MessageSymbolRegistration,&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self.records.append(registration)&lt;br /&gt;
&lt;br /&gt;
    def capture(self, message: CapturedMessage) -&amp;gt; None:&lt;br /&gt;
        self.records.append(message)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create the bus and attach capture:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = ListCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the empty capture sink:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
sink&lt;br /&gt;
sink.records&lt;br /&gt;
len(sink.records)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ListCaptureSink(records=[])&lt;br /&gt;
[]&lt;br /&gt;
0&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create a receiver. This describes the messages the receiver wants to hear:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the receiver and confirm that capture is still empty:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
type(receiver)&lt;br /&gt;
vars(receiver)&lt;br /&gt;
sink.records&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;class &#039;mbus.broker.direct._BrokerReceiver&#039;&amp;gt;&lt;br /&gt;
{&#039;_queue&#039;: ...}&lt;br /&gt;
[]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create an emitter. Registering the emitter gives the bus enough information to bind readable names to compact internal IDs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the captured registration records:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
sink.records&lt;br /&gt;
[type(record).__name__ for record in sink.records]&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    print(record)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[&#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;]&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;demo.signals&#039; ...)&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;ping&#039; ...)&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;demo-source&#039; ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit one message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;hello bus&amp;quot;})&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect capture before receiving:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
len(sink.records)&lt;br /&gt;
[type(record).__name__ for record in sink.records]&lt;br /&gt;
sink.records[-1]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
4&lt;br /&gt;
[&#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;CapturedMessage&#039;]&lt;br /&gt;
CapturedMessage(... payload={&#039;text&#039;: &#039;hello bus&#039;} ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
message = receiver.receive()&lt;br /&gt;
&lt;br /&gt;
message&lt;br /&gt;
message.msg_topic&lt;br /&gt;
message.msg_producer&lt;br /&gt;
message.msg_type&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ReceivedMessage(...)&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&#039;demo-source&#039;&lt;br /&gt;
&#039;ping&#039;&lt;br /&gt;
{&#039;text&#039;: &#039;hello bus&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the received message with the captured record:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured = sink.records[-1]&lt;br /&gt;
&lt;br /&gt;
captured&lt;br /&gt;
captured.msg_topic_id&lt;br /&gt;
captured.msg_type_id&lt;br /&gt;
captured.msg_producer_id&lt;br /&gt;
captured.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
CapturedMessage(...)&lt;br /&gt;
TopicID(...)&lt;br /&gt;
MessageTypeID(...)&lt;br /&gt;
ProducerID(...)&lt;br /&gt;
{&#039;text&#039;: &#039;hello bus&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The receiver gets the readable message view. The capture sink sees compact IDs and local payload data. The readable names are still available through symbol registration records, but manually reconstructing them is awkward.&lt;br /&gt;
&lt;br /&gt;
That awkwardness motivates the next phase.&lt;br /&gt;
&lt;br /&gt;
== Phase 2: Build a temporary capture history helper ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a small demonstration helper that turns captured records back into readable history messages.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Capture records are useful, but raw captured messages use compact IDs.&lt;br /&gt;
* A history helper can rebuild readable message history from registration records.&lt;br /&gt;
* This is demonstration infrastructure, not the final persistence/query API.&lt;br /&gt;
* The helper writes captured records into an in-memory &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; sequential unit.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration before the file edit&lt;br /&gt;
&lt;br /&gt;
List only the captured messages:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured_messages = []&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    if isinstance(record, CapturedMessage):&lt;br /&gt;
        captured_messages.append(record)&lt;br /&gt;
&lt;br /&gt;
captured_messages&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[CapturedMessage(... payload={&#039;text&#039;: &#039;hello bus&#039;} ...)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first captured message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured_message = captured_messages[0]&lt;br /&gt;
&lt;br /&gt;
captured_message.msg_topic_id&lt;br /&gt;
captured_message.msg_type_id&lt;br /&gt;
captured_message.msg_producer_id&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
TopicID(...)&lt;br /&gt;
MessageTypeID(...)&lt;br /&gt;
ProducerID(...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Manually reconstruct a topic name:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
topic_names = {}&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    if isinstance(record, MessageSymbolRegistration):&lt;br /&gt;
        if record.symbol_kind.name == &amp;quot;TOPIC&amp;quot;:&lt;br /&gt;
            topic_names[record.symbol_id] = record.symbol&lt;br /&gt;
&lt;br /&gt;
topic_names&lt;br /&gt;
topic_names[captured_message.msg_topic_id]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
{TopicID(...): &#039;demo.signals&#039;}&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This works, but repeating it by hand would distract from writing event processors.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/capture_history.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/capture_history.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Capture and history helpers for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from collections.abc import Iterable&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
&lt;br /&gt;
from aostore.sequentialunit.listsequentialunit import ListSequentialUnit&lt;br /&gt;
from mbus.capture.sink import CaptureSink&lt;br /&gt;
from mbus.message.records import CapturedMessage&lt;br /&gt;
from mbus.message.symbols import (&lt;br /&gt;
    MessageSymbolKind,&lt;br /&gt;
    MessageSymbolRegistration,&lt;br /&gt;
    MessageTypeID,&lt;br /&gt;
    ProducerID,&lt;br /&gt;
    TopicID,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
CapturedRecord = MessageSymbolRegistration | CapturedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class AOStoreCaptureSink(CaptureSink):&lt;br /&gt;
    records: ListSequentialUnit[CapturedRecord] = field(&lt;br /&gt;
        default_factory=ListSequentialUnit&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    def capture_symbol_registration(&lt;br /&gt;
        self,&lt;br /&gt;
        registration: MessageSymbolRegistration,&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self.records.append(registration)&lt;br /&gt;
&lt;br /&gt;
    def capture(self, message: CapturedMessage) -&amp;gt; None:&lt;br /&gt;
        self.records.append(message)&lt;br /&gt;
&lt;br /&gt;
    def all_records(self) -&amp;gt; tuple[CapturedRecord, ...]:&lt;br /&gt;
        records = self.records.sequential_read(0, len(self.records))&lt;br /&gt;
        result = tuple(records)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class HistoryMessage:&lt;br /&gt;
    msg_topic: str&lt;br /&gt;
    msg_type: str&lt;br /&gt;
    msg_producer: str&lt;br /&gt;
    payload: object&lt;br /&gt;
    bus_sequence: int&lt;br /&gt;
    topic_sequence: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class CaptureHistory:&lt;br /&gt;
    _records: tuple[CapturedRecord, ...]&lt;br /&gt;
    _topics: dict[TopicID, str]&lt;br /&gt;
    _types: dict[MessageTypeID, str]&lt;br /&gt;
    _producers: dict[ProducerID, str]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, records: Iterable[CapturedRecord]) -&amp;gt; None:&lt;br /&gt;
        self._records = tuple(records)&lt;br /&gt;
        self._topics = {}&lt;br /&gt;
        self._types = {}&lt;br /&gt;
        self._producers = {}&lt;br /&gt;
        self._index_symbols()&lt;br /&gt;
&lt;br /&gt;
    def _index_symbols(self) -&amp;gt; None:&lt;br /&gt;
        for record in self._records:&lt;br /&gt;
            if isinstance(record, MessageSymbolRegistration):&lt;br /&gt;
                self._record_symbol(record)&lt;br /&gt;
&lt;br /&gt;
    def _record_symbol(self, record: MessageSymbolRegistration) -&amp;gt; None:&lt;br /&gt;
        if record.symbol_kind is MessageSymbolKind.TOPIC:&lt;br /&gt;
            self._topics[record.symbol_id] = record.symbol&lt;br /&gt;
        elif record.symbol_kind is MessageSymbolKind.MSG_TYPE:&lt;br /&gt;
            self._types[record.symbol_id] = record.symbol&lt;br /&gt;
        elif record.symbol_kind is MessageSymbolKind.PRODUCER:&lt;br /&gt;
            self._producers[record.symbol_id] = record.symbol&lt;br /&gt;
&lt;br /&gt;
    def messages(self) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for record in self._records:&lt;br /&gt;
            if isinstance(record, CapturedMessage):&lt;br /&gt;
                message = self._decode_message(record)&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def by_topic(self, msg_topic: str) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for message in self.messages():&lt;br /&gt;
            if message.msg_topic == msg_topic:&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def since(self, bus_sequence: int) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for message in self.messages():&lt;br /&gt;
            if message.bus_sequence &amp;gt;= bus_sequence:&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def _decode_message(self, message: CapturedMessage) -&amp;gt; HistoryMessage:&lt;br /&gt;
        history_message = HistoryMessage(&lt;br /&gt;
            msg_topic=self._topics[message.msg_topic_id],&lt;br /&gt;
            msg_type=self._types[message.msg_type_id],&lt;br /&gt;
            msg_producer=self._producers[message.msg_producer_id],&lt;br /&gt;
            payload=message.payload,&lt;br /&gt;
            bus_sequence=message.bus_sequence,&lt;br /&gt;
            topic_sequence=message.topic_sequence,&lt;br /&gt;
        )&lt;br /&gt;
        return history_message&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;history helper&amp;quot;})&lt;br /&gt;
message = receiver.receive()&lt;br /&gt;
&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
history.messages()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[HistoryMessage(msg_topic=&#039;demo.signals&#039;, msg_type=&#039;ping&#039;, msg_producer=&#039;demo-source&#039;, payload={&#039;text&#039;: &#039;history helper&#039;}, ...)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the decoded history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history_message = history.messages()[0]&lt;br /&gt;
&lt;br /&gt;
history_message.msg_topic&lt;br /&gt;
history_message.msg_type&lt;br /&gt;
history_message.msg_producer&lt;br /&gt;
history_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&#039;ping&#039;&lt;br /&gt;
&#039;demo-source&#039;&lt;br /&gt;
{&#039;text&#039;: &#039;history helper&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 3: Explore shell command data by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Identify the high-level event shape needed by a milestone processor.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The old milestone strategy operated on command-level records.&lt;br /&gt;
* A command-level record includes context such as host and working directory.&lt;br /&gt;
* It also includes shell input and shell output.&lt;br /&gt;
* Raw TTY events do not directly have this shape; reconstruction comes later.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Represent a shell command as a dictionary:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = {&lt;br /&gt;
    &amp;quot;session_id&amp;quot;: &amp;quot;session-1&amp;quot;,&lt;br /&gt;
    &amp;quot;command_index&amp;quot;: 0,&lt;br /&gt;
    &amp;quot;host&amp;quot;: &amp;quot;alpha&amp;quot;,&lt;br /&gt;
    &amp;quot;cwd&amp;quot;: &amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    &amp;quot;input_text&amp;quot;: &amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    &amp;quot;output_text&amp;quot;: &amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    &amp;quot;started_at_ns&amp;quot;: 1000,&lt;br /&gt;
    &amp;quot;ended_at_ns&amp;quot;: 2000,&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the fields used by milestone checks:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command[&amp;quot;host&amp;quot;]&lt;br /&gt;
command[&amp;quot;cwd&amp;quot;]&lt;br /&gt;
command[&amp;quot;input_text&amp;quot;]&lt;br /&gt;
command[&amp;quot;output_text&amp;quot;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;alpha&#039;&lt;br /&gt;
&#039;/home/student&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Try an accidental wrong key:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command[&amp;quot;working_directory&amp;quot;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeyError: &#039;working_directory&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The dictionary contains the information, but it does not document the shape of the event. A typo is discovered only when the field is used. A named event class makes the processor code easier to read and easier to explain.&lt;br /&gt;
&lt;br /&gt;
Positional encoding like tuples and CSV-like records also suffer from this innate limitation. They are inherently detached from the structural contract of the record type; they describe only whatever data they carry, not any particular schema or type contract to which the record itself belongs.&lt;br /&gt;
&lt;br /&gt;
== Phase 4: Persist the event record classes ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create reusable payload classes for the TTY, shell command, and milestone examples.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/events.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/events.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Payload records for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from dataclasses import dataclass&lt;br /&gt;
from typing import Literal&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class TTYReadEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    data: bytes&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class TTYWriteEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    data: bytes&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class LineDisciplineEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    line: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class KeystrokeTimingEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    previous_timestamp_ns: int | None&lt;br /&gt;
    delta_ns: int | None&lt;br /&gt;
    text: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class ShellCommandEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    command_index: int&lt;br /&gt;
    host: str&lt;br /&gt;
    cwd: str&lt;br /&gt;
    input_text: str&lt;br /&gt;
    output_text: str&lt;br /&gt;
    started_at_ns: int&lt;br /&gt;
    ended_at_ns: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
RegexMilestoneField = Literal[&lt;br /&gt;
    &amp;quot;host&amp;quot;,&lt;br /&gt;
    &amp;quot;cwd&amp;quot;,&lt;br /&gt;
    &amp;quot;input_text&amp;quot;,&lt;br /&gt;
    &amp;quot;output_text&amp;quot;,&lt;br /&gt;
]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneExpression:&lt;br /&gt;
    name: str&lt;br /&gt;
    field: RegexMilestoneField&lt;br /&gt;
    pattern: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneResult:&lt;br /&gt;
    expression_name: str&lt;br /&gt;
    field: RegexMilestoneField&lt;br /&gt;
    matched: bool&lt;br /&gt;
    match_text: str | None = None&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneAnalysisEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    command_index: int&lt;br /&gt;
    results: tuple[RegexMilestoneResult, ...]&lt;br /&gt;
    matched_count: int&lt;br /&gt;
    expression_count: int&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.events import ShellCommandEvent&lt;br /&gt;
&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
command&lt;br /&gt;
command.host&lt;br /&gt;
command.cwd&lt;br /&gt;
command.input_text&lt;br /&gt;
command.output_text&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ShellCommandEvent(session_id=&#039;session-1&#039;, command_index=0, ...)&lt;br /&gt;
&#039;alpha&#039;&lt;br /&gt;
&#039;/home/student&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The event shape is now explicit. Other code can depend on a named &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; instead of assuming that a loose dictionary has the expected fields.&lt;br /&gt;
&lt;br /&gt;
== Phase 5: Evaluate milestones manually ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Recreate the core milestone check in a small, transparent form.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* A milestone expression names one check against one shell command field.&lt;br /&gt;
* The field may be command input, command output, working directory, or host context.&lt;br /&gt;
* The result records whether the expression matched and, when available, the matched text.&lt;br /&gt;
* Manual evaluation is useful for learning the shape of the problem before writing a processor.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Import regular expressions and the payload classes:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create one command event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create three milestone expressions:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
expressions = (&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;used-ls&amp;quot;,&lt;br /&gt;
        field=&amp;quot;input_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;\bls\b&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;saw-notes&amp;quot;,&lt;br /&gt;
        field=&amp;quot;output_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;notes\.txt&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;home-directory&amp;quot;,&lt;br /&gt;
        field=&amp;quot;cwd&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;^/home/student$&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Evaluate one expression:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
expression = expressions[0]&lt;br /&gt;
text = getattr(command, expression.field)&lt;br /&gt;
match = re.search(expression.pattern, text)&lt;br /&gt;
&lt;br /&gt;
expression.name&lt;br /&gt;
expression.field&lt;br /&gt;
text&lt;br /&gt;
match.group(0)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;used-ls&#039;&lt;br /&gt;
&#039;input_text&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;ls&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Evaluate all expressions:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
manual_results = []&lt;br /&gt;
&lt;br /&gt;
for expression in expressions:&lt;br /&gt;
    text = getattr(command, expression.field)&lt;br /&gt;
    match = re.search(expression.pattern, text)&lt;br /&gt;
    manual_results.append((expression.name, match is not None))&lt;br /&gt;
&lt;br /&gt;
manual_results&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(&#039;used-ls&#039;, True), (&#039;saw-notes&#039;, True), (&#039;home-directory&#039;, True)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
Manual evaluation is simple for one command, but the loop is already event processor logic. The next phase preserves the loop in a reusable processor.&lt;br /&gt;
&lt;br /&gt;
== Phase 6: Persist the RegexMilestone processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a processor that receives shell command events and emits regex milestone analysis events.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/regex_milestones.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/regex_milestones.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Regex milestone processor for shell command events.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneAnalysisEvent,&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    RegexMilestoneResult,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class RegexMilestoneProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _expressions: tuple[RegexMilestoneExpression, ...]&lt;br /&gt;
&lt;br /&gt;
    def __init__(&lt;br /&gt;
        self,&lt;br /&gt;
        bus: DirectMessageBus,&lt;br /&gt;
        expressions: tuple[RegexMilestoneExpression, ...],&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.shell.command&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;shell-reconstructor&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;shell-command&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.shell.regex_milestone&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;regex-milestone-processor&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;regex-milestone-analysis&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._expressions = expressions&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; RegexMilestoneAnalysisEvent:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        command = message.payload&lt;br /&gt;
&lt;br /&gt;
        if not isinstance(command, ShellCommandEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected ShellCommandEvent, got {type(command).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        analysis = analyze_regex_milestones(command, self._expressions)&lt;br /&gt;
        self._emitter.emit(analysis)&lt;br /&gt;
        return analysis&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def analyze_regex_milestones(&lt;br /&gt;
    command: ShellCommandEvent,&lt;br /&gt;
    expressions: tuple[RegexMilestoneExpression, ...],&lt;br /&gt;
) -&amp;gt; RegexMilestoneAnalysisEvent:&lt;br /&gt;
    results = []&lt;br /&gt;
    for expression in expressions:&lt;br /&gt;
        result = evaluate_regex_milestone(command, expression)&lt;br /&gt;
        results.append(result)&lt;br /&gt;
&lt;br /&gt;
    matched_count = sum(result.matched for result in results)&lt;br /&gt;
    analysis = RegexMilestoneAnalysisEvent(&lt;br /&gt;
        session_id=command.session_id,&lt;br /&gt;
        command_index=command.command_index,&lt;br /&gt;
        results=tuple(results),&lt;br /&gt;
        matched_count=matched_count,&lt;br /&gt;
        expression_count=len(expressions),&lt;br /&gt;
    )&lt;br /&gt;
    return analysis&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def evaluate_regex_milestone(&lt;br /&gt;
    command: ShellCommandEvent,&lt;br /&gt;
    expression: RegexMilestoneExpression,&lt;br /&gt;
) -&amp;gt; RegexMilestoneResult:&lt;br /&gt;
    text = getattr(command, expression.field)&lt;br /&gt;
    match = re.search(expression.pattern, text)&lt;br /&gt;
    match_text = None&lt;br /&gt;
&lt;br /&gt;
    if match is not None:&lt;br /&gt;
        match_text = match.group(0)&lt;br /&gt;
&lt;br /&gt;
    result = RegexMilestoneResult(&lt;br /&gt;
        expression_name=expression.name,&lt;br /&gt;
        field=expression.field,&lt;br /&gt;
        matched=match is not None,&lt;br /&gt;
        match_text=match_text,&lt;br /&gt;
    )&lt;br /&gt;
    return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then set up the bus:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.regex_milestones import RegexMilestoneProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Subscribe to the analysis topic before running the processor:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.shell.regex_milestone&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;regex-milestone-processor&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;regex-milestone-analysis&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create the shell command emitter and processor:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.shell.command&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;shell-reconstructor&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;shell-command&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
expressions = (&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;used-ls&amp;quot;,&lt;br /&gt;
        field=&amp;quot;input_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;\bls\b&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;saw-notes&amp;quot;,&lt;br /&gt;
        field=&amp;quot;output_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;notes\.txt&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;home-directory&amp;quot;,&lt;br /&gt;
        field=&amp;quot;cwd&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;^/home/student$&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = RegexMilestoneProcessor(bus, expressions)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit a shell command event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
command_emitter.emit(command)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the command:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis = processor.process_one()&lt;br /&gt;
analysis&lt;br /&gt;
analysis.results&lt;br /&gt;
analysis.matched_count&lt;br /&gt;
analysis.expression_count&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
RegexMilestoneAnalysisEvent(... matched_count=3, expression_count=3)&lt;br /&gt;
(RegexMilestoneResult(...), RegexMilestoneResult(...), RegexMilestoneResult(...))&lt;br /&gt;
3&lt;br /&gt;
3&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the derived analysis event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis_message = analysis_receiver.receive()&lt;br /&gt;
&lt;br /&gt;
analysis_message.msg_topic&lt;br /&gt;
analysis_message.msg_type&lt;br /&gt;
analysis_message.msg_producer&lt;br /&gt;
analysis_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;demo.shell.regex_milestone&#039;&lt;br /&gt;
&#039;regex-milestone-analysis&#039;&lt;br /&gt;
&#039;regex-milestone-processor&#039;&lt;br /&gt;
RegexMilestoneAnalysisEvent(... matched_count=3, expression_count=3)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
messages = history.messages()&lt;br /&gt;
&lt;br /&gt;
for message in messages:&lt;br /&gt;
    print(message.bus_sequence, message.msg_topic, message.msg_type)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
0 demo.shell.command shell-command&lt;br /&gt;
1 demo.shell.regex_milestone regex-milestone-analysis&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The regex milestone processor consumes one event and emits another. The derived analysis event is available to any later subscriber. History contains both the source command event and the derived analysis event.&lt;br /&gt;
&lt;br /&gt;
== Phase 7: Explore why shell reconstruction is separate ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Show that raw TTY observations are not the same thing as high-level shell command events.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Regex milestones should inspect high-level command events.&lt;br /&gt;
* TTY reads, TTY writes, and line-discipline lines are lower-level observations.&lt;br /&gt;
* A shell command event is reconstructed by correlating those observations.&lt;br /&gt;
* Keeping reconstruction separate prevents the milestone processor from becoming a large TTY parser.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Create a few low-level observations:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    LineDisciplineEvent,&lt;br /&gt;
    TTYReadEvent,&lt;br /&gt;
    TTYWriteEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_1 = TTYReadEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=0,&lt;br /&gt;
    timestamp_ns=1000,&lt;br /&gt;
    data=b&amp;quot;l&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_2 = TTYReadEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=1,&lt;br /&gt;
    timestamp_ns=1100,&lt;br /&gt;
    data=b&amp;quot;s&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
line = LineDisciplineEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=2,&lt;br /&gt;
    timestamp_ns=1200,&lt;br /&gt;
    line=&amp;quot;ls\n&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
write = TTYWriteEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=3,&lt;br /&gt;
    timestamp_ns=1600,&lt;br /&gt;
    data=b&amp;quot;notes.txt\n&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the raw pieces:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_1.data&lt;br /&gt;
read_2.data&lt;br /&gt;
line.line&lt;br /&gt;
write.data&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
b&#039;l&#039;&lt;br /&gt;
b&#039;s&#039;&lt;br /&gt;
&#039;ls\n&#039;&lt;br /&gt;
b&#039;notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Assemble command fields manually:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
input_text = line.line&lt;br /&gt;
output_text = write.data.decode(&amp;quot;utf-8&amp;quot;, errors=&amp;quot;replace&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
input_text&lt;br /&gt;
output_text&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;ls\n&#039;&lt;br /&gt;
&#039;notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The useful command event is derived from several lower-level observations. A later shell reconstructor can subscribe to line-discipline events, query recent reads/writes/timing observations, and emit &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; records.&lt;br /&gt;
&lt;br /&gt;
== Phase 8: Add keystroke timing as a smaller TTY-derived processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a simple stateful processor before attempting full shell reconstruction.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Timing requires comparing a TTY read with the previous read in the same session.&lt;br /&gt;
* A derived timing event makes the result explicit.&lt;br /&gt;
* Other processors can consume timing events instead of recalculating deltas.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration before the file edit&lt;br /&gt;
&lt;br /&gt;
Calculate timing manually from the two read events:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_events = [read_1, read_2]&lt;br /&gt;
previous_timestamp = None&lt;br /&gt;
timing_rows = []&lt;br /&gt;
&lt;br /&gt;
for event in read_events:&lt;br /&gt;
    delta = None&lt;br /&gt;
    if previous_timestamp is not None:&lt;br /&gt;
        delta = event.timestamp_ns - previous_timestamp&lt;br /&gt;
    timing_rows.append((event.source_index, previous_timestamp, delta))&lt;br /&gt;
    previous_timestamp = event.timestamp_ns&lt;br /&gt;
&lt;br /&gt;
timing_rows&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, None, None), (1, 1000, 100)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/keystroke_timing.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/keystroke_timing.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Keystroke timing processor for TTY read events.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    KeystrokeTimingEvent,&lt;br /&gt;
    TTYReadEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class KeystrokeTimingProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _last_timestamp_by_session: dict[str, int]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.tty.read&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;tty-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;tty-read&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.keystroke.timing&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._last_timestamp_by_session = {}&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; KeystrokeTimingEvent:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        event = message.payload&lt;br /&gt;
&lt;br /&gt;
        if not isinstance(event, TTYReadEvent):&lt;br /&gt;
            raise TypeError(f&amp;quot;expected TTYReadEvent, got {type(event).__name__}&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
        previous_timestamp = self._last_timestamp_by_session.get(&lt;br /&gt;
            event.session_id&lt;br /&gt;
        )&lt;br /&gt;
        delta = None&lt;br /&gt;
&lt;br /&gt;
        if previous_timestamp is not None:&lt;br /&gt;
            delta = event.timestamp_ns - previous_timestamp&lt;br /&gt;
&lt;br /&gt;
        self._last_timestamp_by_session[event.session_id] = event.timestamp_ns&lt;br /&gt;
&lt;br /&gt;
        text = event.data.decode(&amp;quot;utf-8&amp;quot;, errors=&amp;quot;replace&amp;quot;)&lt;br /&gt;
        timing = KeystrokeTimingEvent(&lt;br /&gt;
            session_id=event.session_id,&lt;br /&gt;
            source_index=event.source_index,&lt;br /&gt;
            timestamp_ns=event.timestamp_ns,&lt;br /&gt;
            previous_timestamp_ns=previous_timestamp,&lt;br /&gt;
            delta_ns=delta,&lt;br /&gt;
            text=text,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter.emit(timing)&lt;br /&gt;
        return timing&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
from demos.event_processors.events import TTYReadEvent&lt;br /&gt;
from demos.event_processors.keystroke_timing import KeystrokeTimingProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
timing_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.keystroke.timing&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.tty.read&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;tty-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;tty-read&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = KeystrokeTimingProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit reads and process them:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_emitter.emit(&lt;br /&gt;
    TTYReadEvent(&lt;br /&gt;
        session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
        source_index=0,&lt;br /&gt;
        timestamp_ns=1000,&lt;br /&gt;
        data=b&amp;quot;l&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_emitter.emit(&lt;br /&gt;
    TTYReadEvent(&lt;br /&gt;
        session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
        source_index=1,&lt;br /&gt;
        timestamp_ns=1100,&lt;br /&gt;
        data=b&amp;quot;s&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor.process_one()&lt;br /&gt;
processor.process_one()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeystrokeTimingEvent(... previous_timestamp_ns=None, delta_ns=None, text=&#039;l&#039;)&lt;br /&gt;
KeystrokeTimingEvent(... previous_timestamp_ns=1000, delta_ns=100, text=&#039;s&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive timing events:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
timing_receiver.receive().payload&lt;br /&gt;
timing_receiver.receive().payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeystrokeTimingEvent(... source_index=0 ...)&lt;br /&gt;
KeystrokeTimingEvent(... source_index=1 ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 9: Introduce graph events ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Switch to a small graph problem that can demonstrate safe feedback loops in an illustratable form.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* An edge is a source fact.&lt;br /&gt;
* A path is a derived fact.&lt;br /&gt;
* A direct edge implies a direct path.&lt;br /&gt;
* Longer paths can be derived later from known paths and edges.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_events.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/graph_events.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Graph event payloads for message bus demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from dataclasses import dataclass&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class EdgeDeclaredEvent:&lt;br /&gt;
    graph_id: str&lt;br /&gt;
    source: str&lt;br /&gt;
    target: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class PathKnownEvent:&lt;br /&gt;
    graph_id: str&lt;br /&gt;
    source: str&lt;br /&gt;
    target: str&lt;br /&gt;
    hop_count: int&lt;br /&gt;
&lt;br /&gt;
    def fact_key(self) -&amp;gt; tuple[str, str, str]:&lt;br /&gt;
        result = (self.graph_id, self.source, self.target)&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge = EdgeDeclaredEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;A&amp;quot;,&lt;br /&gt;
    target=&amp;quot;B&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path = PathKnownEvent(&lt;br /&gt;
    graph_id=edge.graph_id,&lt;br /&gt;
    source=edge.source,&lt;br /&gt;
    target=edge.target,&lt;br /&gt;
    hop_count=1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge&lt;br /&gt;
path&lt;br /&gt;
path.fact_key()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
EdgeDeclaredEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;)&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;, hop_count=1)&lt;br /&gt;
(&#039;demo-graph&#039;, &#039;A&#039;, &#039;B&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 10: Create a demo-only nonblocking receive helper ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Prepare for processor scheduling without editing &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;Receiver.receive()&amp;lt;/code&amp;gt; blocks until a message is available.&lt;br /&gt;
* A scheduler needs to try a processor and move on if no message is waiting.&lt;br /&gt;
* The current package does not expose a public nonblocking receiver method.&lt;br /&gt;
* This helper is local demonstration code. It can be replaced by a real public API later.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/demo_receive.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/demo_receive.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Demo-only helpers for receiver inspection and scheduling.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from queue import Empty&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.endpoints import Receiver&lt;br /&gt;
from mbus.message.records import ReceivedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class DemoReceiverError(RuntimeError):&lt;br /&gt;
    pass&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def try_receive(receiver: Receiver) -&amp;gt; ReceivedMessage | None:&lt;br /&gt;
    queue = getattr(receiver, &amp;quot;_queue&amp;quot;, None)&lt;br /&gt;
&lt;br /&gt;
    if queue is None:&lt;br /&gt;
        raise DemoReceiverError(&lt;br /&gt;
            &amp;quot;demo try_receive requires the current direct broker receiver&amp;quot;&lt;br /&gt;
        )&lt;br /&gt;
&lt;br /&gt;
    try:&lt;br /&gt;
        message = queue.get_nowait()&lt;br /&gt;
    except Empty:&lt;br /&gt;
        message = None&lt;br /&gt;
&lt;br /&gt;
    return message&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.demo_receive import try_receive&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
try_receive(receiver)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
None&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Now emit a message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;demo nonblocking receive&amp;quot;})&lt;br /&gt;
message = try_receive(receiver)&lt;br /&gt;
&lt;br /&gt;
message&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ReceivedMessage(...)&lt;br /&gt;
{&#039;text&#039;: &#039;demo nonblocking receive&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 11: Create the direct path processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Implement the graph rule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
edge(A, B) -&amp;gt; path(A, B)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The source emits edge events.&lt;br /&gt;
* The direct path processor emits derived path events.&lt;br /&gt;
* Other processors can subscribe to derived path events.&lt;br /&gt;
* The processor can be run by hand or by a scheduler.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_reachability.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/graph_reachability.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Graph reachability processors for message bus demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.demo_receive import try_receive&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class DirectPathProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _known_paths: set[tuple[str, str, str]]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._known_paths = set()&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; PathKnownEvent | None:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        result = self._process_payload(message.payload)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
    def process_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            self._process_payload(message.payload)&lt;br /&gt;
            work_count = 1&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def _process_payload(self, payload: object) -&amp;gt; PathKnownEvent | None:&lt;br /&gt;
        if not isinstance(payload, EdgeDeclaredEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected EdgeDeclaredEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        path = PathKnownEvent(&lt;br /&gt;
            graph_id=payload.graph_id,&lt;br /&gt;
            source=payload.source,&lt;br /&gt;
            target=payload.target,&lt;br /&gt;
            hop_count=1,&lt;br /&gt;
        )&lt;br /&gt;
&lt;br /&gt;
        if path.fact_key() not in self._known_paths:&lt;br /&gt;
            self._known_paths.add(path.fact_key())&lt;br /&gt;
            self._emitter.emit(path)&lt;br /&gt;
            result = path&lt;br /&gt;
        else:&lt;br /&gt;
            result = None&lt;br /&gt;
&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
from demos.event_processors.graph_events import EdgeDeclaredEvent&lt;br /&gt;
from demos.event_processors.graph_reachability import DirectPathProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
path_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = DirectPathProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Call the processor before any edge exists:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
0&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit one edge:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;A&amp;quot;,&lt;br /&gt;
        target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the edge:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the derived path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_message = path_receiver.receive()&lt;br /&gt;
path_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;, hop_count=1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 12: Explore the path extension rule by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Understand the feedback rule before implementing it.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* If &amp;lt;code&amp;gt;A&amp;lt;/code&amp;gt; reaches &amp;lt;code&amp;gt;B&amp;lt;/code&amp;gt;, and there is an edge from &amp;lt;code&amp;gt;B&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;C&amp;lt;/code&amp;gt;, then &amp;lt;code&amp;gt;A&amp;lt;/code&amp;gt; reaches &amp;lt;code&amp;gt;C&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Derived path facts can lead to more derived path facts.&lt;br /&gt;
* Duplicate prevention keeps the feedback loop from repeating the same fact forever.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_bc = EdgeDeclaredEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;B&amp;quot;,&lt;br /&gt;
    target=&amp;quot;C&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path_ab = PathKnownEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;A&amp;quot;,&lt;br /&gt;
    target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    hop_count=1,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Check whether the path and edge connect:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_ab.target&lt;br /&gt;
edge_bc.source&lt;br /&gt;
path_ab.target == edge_bc.source&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;B&#039;&lt;br /&gt;
&#039;B&#039;&lt;br /&gt;
True&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Derive a new path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_ac = PathKnownEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=path_ab.source,&lt;br /&gt;
    target=edge_bc.target,&lt;br /&gt;
    hop_count=path_ab.hop_count + 1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path_ac&lt;br /&gt;
path_ac.fact_key()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;C&#039;, hop_count=2)&lt;br /&gt;
(&#039;demo-graph&#039;, &#039;A&#039;, &#039;C&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Manually prevent duplicate path facts:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
known_paths = set()&lt;br /&gt;
known_paths.add(path_ab.fact_key())&lt;br /&gt;
&lt;br /&gt;
path_ac.fact_key() in known_paths&lt;br /&gt;
&lt;br /&gt;
if path_ac.fact_key() not in known_paths:&lt;br /&gt;
    known_paths.add(path_ac.fact_key())&lt;br /&gt;
&lt;br /&gt;
known_paths&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
False&lt;br /&gt;
{(&#039;demo-graph&#039;, &#039;A&#039;, &#039;B&#039;), (&#039;demo-graph&#039;, &#039;A&#039;, &#039;C&#039;)}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 13: Add the path extension processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Implement the feedback rule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
path(A, B) + edge(B, C) -&amp;gt; path(A, C)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The extension processor listens to edge events and path events.&lt;br /&gt;
* It emits new path events when a known path can be extended.&lt;br /&gt;
* It also listens to its own output because an extended path may be extendable again.&lt;br /&gt;
* This is a controlled feedback loop.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Append this class to:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_reachability.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
class PathExtensionProcessor:&lt;br /&gt;
    _edge_receiver: Receiver&lt;br /&gt;
    _direct_path_receiver: Receiver&lt;br /&gt;
    _extension_path_receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _edges: set[tuple[str, str, str]]&lt;br /&gt;
    _paths: dict[tuple[str, str, str], PathKnownEvent]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._edge_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._direct_path_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._extension_path_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._edges = set()&lt;br /&gt;
        self._paths = {}&lt;br /&gt;
&lt;br /&gt;
    def process_edge_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._edge_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_edge_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def process_direct_path_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._direct_path_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_path_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def process_extension_path_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._extension_path_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_path_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def _process_edge_payload(&lt;br /&gt;
        self,&lt;br /&gt;
        payload: object,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        if not isinstance(payload, EdgeDeclaredEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected EdgeDeclaredEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        edge = payload&lt;br /&gt;
        self._edges.add((edge.graph_id, edge.source, edge.target))&lt;br /&gt;
        paths = self._derive_from_new_edge(edge)&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _process_path_payload(&lt;br /&gt;
        self,&lt;br /&gt;
        payload: object,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        if not isinstance(payload, PathKnownEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected PathKnownEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        path = payload&lt;br /&gt;
        self._paths[path.fact_key()] = path&lt;br /&gt;
        paths = self._derive_from_new_path(path)&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _derive_from_new_edge(&lt;br /&gt;
        self,&lt;br /&gt;
        edge: EdgeDeclaredEvent,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        paths = []&lt;br /&gt;
&lt;br /&gt;
        for known_path in self._paths.values():&lt;br /&gt;
            if known_path.graph_id != edge.graph_id:&lt;br /&gt;
                continue&lt;br /&gt;
            if known_path.target != edge.source:&lt;br /&gt;
                continue&lt;br /&gt;
&lt;br /&gt;
            next_path = PathKnownEvent(&lt;br /&gt;
                graph_id=edge.graph_id,&lt;br /&gt;
                source=known_path.source,&lt;br /&gt;
                target=edge.target,&lt;br /&gt;
                hop_count=known_path.hop_count + 1,&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
            if self._emit_if_new(next_path):&lt;br /&gt;
                paths.append(next_path)&lt;br /&gt;
&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _derive_from_new_path(&lt;br /&gt;
        self,&lt;br /&gt;
        path: PathKnownEvent,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        paths = []&lt;br /&gt;
&lt;br /&gt;
        for graph_id, edge_source, edge_target in self._edges:&lt;br /&gt;
            if graph_id != path.graph_id:&lt;br /&gt;
                continue&lt;br /&gt;
            if edge_source != path.target:&lt;br /&gt;
                continue&lt;br /&gt;
&lt;br /&gt;
            next_path = PathKnownEvent(&lt;br /&gt;
                graph_id=path.graph_id,&lt;br /&gt;
                source=path.source,&lt;br /&gt;
                target=edge_target,&lt;br /&gt;
                hop_count=path.hop_count + 1,&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
            if self._emit_if_new(next_path):&lt;br /&gt;
                paths.append(next_path)&lt;br /&gt;
&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _emit_if_new(self, path: PathKnownEvent) -&amp;gt; bool:&lt;br /&gt;
        if path.fact_key() in self._paths:&lt;br /&gt;
            result = False&lt;br /&gt;
        else:&lt;br /&gt;
            self._paths[path.fact_key()] = path&lt;br /&gt;
            self._emitter.emit(path)&lt;br /&gt;
            result = True&lt;br /&gt;
&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_events import EdgeDeclaredEvent&lt;br /&gt;
from demos.event_processors.graph_reachability import (&lt;br /&gt;
    DirectPathProcessor,&lt;br /&gt;
    PathExtensionProcessor,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
extension_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
direct_processor = DirectPathProcessor(bus)&lt;br /&gt;
extension_processor = PathExtensionProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit two edges in fixed order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;A&amp;quot;,&lt;br /&gt;
        target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;B&amp;quot;,&lt;br /&gt;
        target=&amp;quot;C&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the direct paths:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
direct_processor.process_available()&lt;br /&gt;
direct_processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Let the extension processor learn the edges:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
extension_processor.process_edge_available()&lt;br /&gt;
extension_processor.process_edge_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Let the extension processor learn the direct paths:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
extension_processor.process_direct_path_available()&lt;br /&gt;
extension_processor.process_direct_path_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The second call may emit the extended &amp;lt;code&amp;gt;A -&amp;gt; C&amp;lt;/code&amp;gt; path after the processor has seen both direct paths and both edges.&lt;br /&gt;
&lt;br /&gt;
Receive the extended path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
message = extension_receiver.receive()&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;C&#039;, hop_count=2)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect captured graph path history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
path_messages = history.by_topic(&amp;quot;demo.graph.path&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
for message in path_messages:&lt;br /&gt;
    path = message.payload&lt;br /&gt;
    print(&lt;br /&gt;
        message.bus_sequence,&lt;br /&gt;
        message.msg_producer,&lt;br /&gt;
        path.source,&lt;br /&gt;
        path.target,&lt;br /&gt;
        path.hop_count,&lt;br /&gt;
    )&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
... direct-path A B 1&lt;br /&gt;
... direct-path B C 1&lt;br /&gt;
... path-extension A C 2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 14: Add a randomized processor scheduler ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Simulate concurrency effects without changing source input order.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The source will emit the same edge events in the same order every run.&lt;br /&gt;
* The scheduler varies which processor gets a chance to run first.&lt;br /&gt;
* This simulates uncertainty from processing time, scheduling priority, startup timing, or transmission delay.&lt;br /&gt;
* The graph example should converge to the same final path facts even when processor scheduling - and the order of intermediate derived processor events - differs.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/runners.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/runners.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Small processor runners for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from collections.abc import Callable&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
from random import Random&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class ProcessorStep:&lt;br /&gt;
    name: str&lt;br /&gt;
    run: Callable[[], int]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class RandomProcessorRunner:&lt;br /&gt;
    steps: tuple[ProcessorStep, ...]&lt;br /&gt;
    seed: int&lt;br /&gt;
    max_rounds: int = 30&lt;br /&gt;
    trace: list[tuple[int, str, int]] = field(default_factory=list)&lt;br /&gt;
&lt;br /&gt;
    def run_until_quiet(self) -&amp;gt; list[tuple[int, str, int]]:&lt;br /&gt;
        rng = Random(self.seed)&lt;br /&gt;
&lt;br /&gt;
        for round_index in range(self.max_rounds):&lt;br /&gt;
            work_count = self._run_round(rng, round_index)&lt;br /&gt;
&lt;br /&gt;
            if work_count == 0:&lt;br /&gt;
                break&lt;br /&gt;
&lt;br /&gt;
        result = list(self.trace)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
    def _run_round(self, rng: Random, round_index: int) -&amp;gt; int:&lt;br /&gt;
        steps = list(self.steps)&lt;br /&gt;
        rng.shuffle(steps)&lt;br /&gt;
        round_work_count = 0&lt;br /&gt;
&lt;br /&gt;
        for step in steps:&lt;br /&gt;
            step_work_count = step.run()&lt;br /&gt;
            self.trace.append((round_index, step.name, step_work_count))&lt;br /&gt;
            round_work_count += step_work_count&lt;br /&gt;
&lt;br /&gt;
        return round_work_count&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then inspect how a runner can shuffle placeholder steps:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.runners import (&lt;br /&gt;
    ProcessorStep,&lt;br /&gt;
    RandomProcessorRunner,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
calls = []&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def first_step() -&amp;gt; int:&lt;br /&gt;
    calls.append(&amp;quot;first&amp;quot;)&lt;br /&gt;
    return 0&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def second_step() -&amp;gt; int:&lt;br /&gt;
    calls.append(&amp;quot;second&amp;quot;)&lt;br /&gt;
    return 0&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
runner = RandomProcessorRunner(&lt;br /&gt;
    steps=(&lt;br /&gt;
        ProcessorStep(name=&amp;quot;first&amp;quot;, run=first_step),&lt;br /&gt;
        ProcessorStep(name=&amp;quot;second&amp;quot;, run=second_step),&lt;br /&gt;
    ),&lt;br /&gt;
    seed=1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
runner.run_until_quiet()&lt;br /&gt;
calls&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, &#039;...&#039;, 0), (0, &#039;...&#039;, 0)]&lt;br /&gt;
[&#039;...&#039;, &#039;...&#039;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 15: Run the graph example with randomized scheduling ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Run the same graph input under different processor schedules and compare the final path facts.&lt;br /&gt;
&lt;br /&gt;
; Interpreter setup&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_reachability import (&lt;br /&gt;
    DirectPathProcessor,&lt;br /&gt;
    PathExtensionProcessor,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.runners import (&lt;br /&gt;
    ProcessorStep,&lt;br /&gt;
    RandomProcessorRunner,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Define a helper function in the interpreter:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
def run_graph_demo(seed: int) -&amp;gt; tuple[&lt;br /&gt;
    list[tuple[int, str, int]],&lt;br /&gt;
    list[tuple[int, str, str, int]],&lt;br /&gt;
]:&lt;br /&gt;
    bus = DirectMessageBus()&lt;br /&gt;
    sink = AOStoreCaptureSink()&lt;br /&gt;
    bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
    edge_emitter = bus.register_emitter(&lt;br /&gt;
        msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
        msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
        default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    direct_processor = DirectPathProcessor(bus)&lt;br /&gt;
    extension_processor = PathExtensionProcessor(bus)&lt;br /&gt;
&lt;br /&gt;
    edges = (&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;A&amp;quot;,&lt;br /&gt;
            target=&amp;quot;B&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;B&amp;quot;,&lt;br /&gt;
            target=&amp;quot;C&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;C&amp;quot;,&lt;br /&gt;
            target=&amp;quot;D&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    for edge in edges:&lt;br /&gt;
        edge_emitter.emit(edge)&lt;br /&gt;
&lt;br /&gt;
    steps = (&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;direct path from edge&amp;quot;,&lt;br /&gt;
            run=direct_processor.process_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns edge&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_edge_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns direct path&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_direct_path_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns extension path&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_extension_path_available,&lt;br /&gt;
        ),&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    runner = RandomProcessorRunner(&lt;br /&gt;
        steps=steps,&lt;br /&gt;
        seed=seed,&lt;br /&gt;
    )&lt;br /&gt;
    trace = runner.run_until_quiet()&lt;br /&gt;
&lt;br /&gt;
    history = CaptureHistory(sink.all_records())&lt;br /&gt;
    path_facts = []&lt;br /&gt;
&lt;br /&gt;
    for message in history.by_topic(&amp;quot;demo.graph.path&amp;quot;):&lt;br /&gt;
        path = message.payload&lt;br /&gt;
        if isinstance(path, PathKnownEvent):&lt;br /&gt;
            fact = (&lt;br /&gt;
                message.bus_sequence,&lt;br /&gt;
                path.source,&lt;br /&gt;
                path.target,&lt;br /&gt;
                path.hop_count,&lt;br /&gt;
            )&lt;br /&gt;
            path_facts.append(fact)&lt;br /&gt;
&lt;br /&gt;
    result = (trace, path_facts)&lt;br /&gt;
    return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Run one schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_1, paths_1 = run_graph_demo(1)&lt;br /&gt;
&lt;br /&gt;
trace_1&lt;br /&gt;
paths_1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, &#039;...&#039;, ...), (0, &#039;...&#039;, ...), ...]&lt;br /&gt;
[(..., &#039;A&#039;, &#039;B&#039;, 1), (..., &#039;B&#039;, &#039;C&#039;, 1), (..., &#039;C&#039;, &#039;D&#039;, 1), ...]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Run another schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_2, paths_2 = run_graph_demo(2)&lt;br /&gt;
&lt;br /&gt;
trace_2&lt;br /&gt;
paths_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the schedules:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_1 == trace_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
False&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the final path facts without bus sequence numbers:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
final_paths_1 = {&lt;br /&gt;
    (source, target, hop_count)&lt;br /&gt;
    for _, source, target, hop_count in paths_1&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
final_paths_2 = {&lt;br /&gt;
    (source, target, hop_count)&lt;br /&gt;
    for _, source, target, hop_count in paths_2&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
final_paths_1&lt;br /&gt;
final_paths_2&lt;br /&gt;
final_paths_1 == final_paths_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
{(&#039;A&#039;, &#039;B&#039;, 1), (&#039;B&#039;, &#039;C&#039;, 1), (&#039;C&#039;, &#039;D&#039;, 1), (&#039;A&#039;, &#039;C&#039;, 2), (&#039;B&#039;, &#039;D&#039;, 2), (&#039;A&#039;, &#039;D&#039;, 3)}&lt;br /&gt;
{(&#039;A&#039;, &#039;B&#039;, 1), (&#039;B&#039;, &#039;C&#039;, 1), (&#039;C&#039;, &#039;D&#039;, 1), (&#039;A&#039;, &#039;C&#039;, 2), (&#039;B&#039;, &#039;D&#039;, 2), (&#039;A&#039;, &#039;D&#039;, 3)}&lt;br /&gt;
True&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for round_index, step_name, work_count in trace_1:&lt;br /&gt;
    print(round_index, step_name, work_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first captured path order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for bus_sequence, source, target, hop_count in paths_1:&lt;br /&gt;
    print(bus_sequence, source, target, hop_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the second schedule and path order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for round_index, step_name, work_count in trace_2:&lt;br /&gt;
    print(round_index, step_name, work_count)&lt;br /&gt;
&lt;br /&gt;
for bus_sequence, source, target, hop_count in paths_2:&lt;br /&gt;
    print(bus_sequence, source, target, hop_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The source input order did not change. The processor scheduling changed. The captured event order may differ. The final reachability facts should match.&lt;br /&gt;
&lt;br /&gt;
This models an important distributed-systems lesson: event history order and final derived state are related, but they are not the same thing.&lt;br /&gt;
&lt;br /&gt;
== Phase 16: Later refinement: graph index from history ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Introduce restart/recovery thinking only after the basic graph feedback example is familiar.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The first graph processors keep local working state.&lt;br /&gt;
* Local working state is enough for a short demonstration.&lt;br /&gt;
* Longer-lived processors need recovery behavior after restart.&lt;br /&gt;
* Since edge and path facts are captured as events, a graph index can later be rebuilt from history.&lt;br /&gt;
* This points toward projection-style services without changing the basic processor model.&lt;br /&gt;
&lt;br /&gt;
; Suggested later file&lt;br /&gt;
&lt;br /&gt;
Create later:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_index.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Suggested responsibilities:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
read captured history&lt;br /&gt;
collect EdgeDeclaredEvent payloads&lt;br /&gt;
collect PathKnownEvent payloads&lt;br /&gt;
answer whether a path fact exists&lt;br /&gt;
find edges starting at a node&lt;br /&gt;
find paths ending at a node&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This refinement belongs after the team has already run the graph reachability demo successfully.&lt;br /&gt;
&lt;br /&gt;
== Phase 17: Later refinement: shell command reconstruction ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Connect the TTY examples back to the shell command event shape used by milestones.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; is the high-level event consumed by the milestone processor.&lt;br /&gt;
* &amp;lt;code&amp;gt;TTYReadEvent&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;TTYWriteEvent&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;LineDisciplineEvent&amp;lt;/code&amp;gt; are lower-level observations.&lt;br /&gt;
* A reconstructor can subscribe to line-discipline events and query recent related observations.&lt;br /&gt;
* The reconstructor should emit shell command events; it should not perform regex analysis itself.&lt;br /&gt;
&lt;br /&gt;
; Suggested responsibilities&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
listen for completed line-discipline events&lt;br /&gt;
query history since the previous command boundary for the same session&lt;br /&gt;
collect matching reads, writes, and timing events&lt;br /&gt;
construct ShellCommandEvent&lt;br /&gt;
emit ShellCommandEvent on demo.shell.command&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This is an appropriate follow-up exercise after the team understands both milestone processing and history queries.&lt;br /&gt;
&lt;br /&gt;
= Related =&lt;br /&gt;
&lt;br /&gt;
== Top-level topic page ==&lt;br /&gt;
[[Message Bus Service]]&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=Message_Bus_Live_Coding_Exercise&amp;diff=943</id>
		<title>Message Bus Live Coding Exercise</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=Message_Bus_Live_Coding_Exercise&amp;diff=943"/>
		<updated>2026-06-16T16:48:57Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* Phase 3: Explore shell command data by hand */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Introduction =&lt;br /&gt;
&lt;br /&gt;
== Audience and scope ==&lt;br /&gt;
&lt;br /&gt;
This exercise is intended for EDURange developers learning how to write event processors with the message bus.&lt;br /&gt;
&lt;br /&gt;
The packaged &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; modules are treated as read-only for this exercise. All new code belongs under a demonstration directory such as:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
workspace/&lt;br /&gt;
    mbus/&lt;br /&gt;
    aostore/&lt;br /&gt;
    demos/&lt;br /&gt;
        __init__.py&lt;br /&gt;
        event_processors/&lt;br /&gt;
            __init__.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Pre-release copies of &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; are available on the Discord fileshare channel. Modules may be released under other package names; see also the EDURange GitHub.&lt;br /&gt;
&lt;br /&gt;
The exercise uses two problem families.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;1. Shell and TTY event processing&#039;&#039;&#039;&lt;br /&gt;
** Send and receive messages through the bus.&lt;br /&gt;
** Capture and inspect message history.&lt;br /&gt;
** Represent high-level shell command events.&lt;br /&gt;
** Recreate the regex-based milestone role in a simplified form.&lt;br /&gt;
** Mock up TTY-derived observations such as keystroke timing.&lt;br /&gt;
* &#039;&#039;&#039;2. Graph event processing&#039;&#039;&#039;&lt;br /&gt;
** Represent graph edge relationships as source events.&lt;br /&gt;
** Derive direct path facts from edge relation events.&lt;br /&gt;
** Add feedback by deriving longer paths from known paths.&lt;br /&gt;
** Simulate nondeterministic processor scheduling while keeping source input fixed.&lt;br /&gt;
&lt;br /&gt;
The shell examples draw from EDURange&#039;s institutional knowledge. The graph examples provide a common model problem family for meaningful use of feedback loops.&lt;br /&gt;
&lt;br /&gt;
== Running the examples ==&lt;br /&gt;
&lt;br /&gt;
Adjust paths to match the local checkout layout if necessary. Start an interpreter from the workspace root with project packages available.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
PYTHONPATH=&amp;quot;$PWD/mbus:$PWD/aostore:$PWD&amp;quot; python3.14&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
When module files are changed, restart the interpreter unless the group is deliberately practicing &amp;lt;code&amp;gt;importlib.reload()&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Create the demonstration package ==&lt;br /&gt;
&lt;br /&gt;
Before the first durable file edit, create the demonstration package directories:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir -p demos/event_processors&lt;br /&gt;
touch demos/__init__.py&lt;br /&gt;
touch demos/event_processors/__init__.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
== Important note about capture records ==&lt;br /&gt;
&lt;br /&gt;
The current demonstration works close to the low-level capture layer. As a result, it exposes &amp;lt;code&amp;gt;MessageSymbolRegistration&amp;lt;/code&amp;gt; records while building the temporary history helper.&lt;br /&gt;
&lt;br /&gt;
That is not the intended ordinary user experience. In a more complete release, higher-level capture and history infrastructure should handle symbol registration bookkeeping on behalf of processor authors. In this exercise, symbol registration records are visible because the demonstration history helper reconstructs readable messages from compact captured records directly.&lt;br /&gt;
&lt;br /&gt;
== Suggested group session flow ==&lt;br /&gt;
&lt;br /&gt;
; Session 1 - Bus and capture basics&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 1:&#039;&#039;&#039;&lt;br /&gt;
** Signal over the bus by hand.&lt;br /&gt;
** Inspect received messages.&lt;br /&gt;
** Inspect low-level captured records.&lt;br /&gt;
* &#039;&#039;&#039;Phase 2:&#039;&#039;&#039;&lt;br /&gt;
** Discuss why symbol registration appears in this temporary demo.&lt;br /&gt;
** Build the capture history helper.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1-3 hours (1-2 hours work, 0.5-1 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 2 - Shell commands and milestones&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 3:&#039;&#039;&#039;&lt;br /&gt;
** Represent a shell command as a dictionary.&lt;br /&gt;
* &#039;&#039;&#039;Phase 4:&#039;&#039;&#039;&lt;br /&gt;
** Replace it with &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Phase 5:&#039;&#039;&#039;&lt;br /&gt;
** Manually evaluate milestone expressions.&lt;br /&gt;
* &#039;&#039;&#039;Phase 6:&#039;&#039;&#039;&lt;br /&gt;
** Implement &amp;lt;code&amp;gt;RegexMilestoneProcessor&amp;lt;/code&amp;gt;.&lt;br /&gt;
** Inspect source and analysis events in history.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3 hours (1.5-2.5 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 3 - TTY-derived observations&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 7:&#039;&#039;&#039;&lt;br /&gt;
** Create raw TTY read, write, and line events.&lt;br /&gt;
** Show why milestones need reconstructed shell command events.&lt;br /&gt;
* &#039;&#039;&#039;Phase 8:&#039;&#039;&#039;&lt;br /&gt;
** Implement keystroke timing.&lt;br /&gt;
** Discuss shell command reconstruction as the next derived-event problem (to be continued in Session 6 onward).&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1-2.5 hours (1-2 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 4 - Graph-derived facts&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 9:&#039;&#039;&#039;&lt;br /&gt;
** Define graph edges and paths.&lt;br /&gt;
* &#039;&#039;&#039;Phase 10:&#039;&#039;&#039;&lt;br /&gt;
** Add the demo-only nonblocking receive helper.&lt;br /&gt;
* &#039;&#039;&#039;Phase 11:&#039;&#039;&#039;&lt;br /&gt;
** Implement direct path derivation.&lt;br /&gt;
* &#039;&#039;&#039;Phase 12:&#039;&#039;&#039;&lt;br /&gt;
** Explore the extension rule by hand.&lt;br /&gt;
* &#039;&#039;&#039;Phase 13:&#039;&#039;&#039;&lt;br /&gt;
** Add path extension feedback.&lt;br /&gt;
** Discuss duplicate prevention.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3.5 hours (1.5-3 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 5 - Nondeterministic processor scheduling&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 14:&#039;&#039;&#039;&lt;br /&gt;
** Add the randomized processor runner.&lt;br /&gt;
* &#039;&#039;&#039;Phase 15:&#039;&#039;&#039;&lt;br /&gt;
** Keep source events fixed.&lt;br /&gt;
** Vary processor scheduling by seed.&lt;br /&gt;
** Compare final path facts across runs.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3.5 hours (1.5-3 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 6 - Follow-up refinements&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 16:&#039;&#039;&#039;&lt;br /&gt;
** Rebuild a graph index from history.&lt;br /&gt;
* &#039;&#039;&#039;Phase 17:&#039;&#039;&#039;&lt;br /&gt;
** Implement shell command reconstruction.&lt;br /&gt;
** Replace local Python payload assumptions with explicit payload projection formats when the bus format layer is ready for the exercise.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-4.5 hours (1.5-4 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
= Event Processor Demonstration Plan =&lt;br /&gt;
&lt;br /&gt;
== Phase 1: Signal over the bus by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Send one message through the direct bus and inspect what the receiver sees.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* A bus is useful only when something emits and something receives.&lt;br /&gt;
* Public message setup uses readable strings: topic, producer, and message type.&lt;br /&gt;
* The direct broker delivers local Python payloads in this demonstration.&lt;br /&gt;
* Capture must be attached before delivery in the current prototype.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Import the bus and capture interfaces:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.capture.sink import CaptureSink&lt;br /&gt;
from mbus.message.records import CapturedMessage&lt;br /&gt;
from mbus.message.symbols import MessageSymbolRegistration&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create a temporary capture sink in the interpreter. This sink stores whatever the bus sends to the capture layer:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
CapturedRecord = MessageSymbolRegistration | CapturedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class ListCaptureSink(CaptureSink):&lt;br /&gt;
    records: list[CapturedRecord] = field(default_factory=list)&lt;br /&gt;
&lt;br /&gt;
    def capture_symbol_registration(&lt;br /&gt;
        self,&lt;br /&gt;
        registration: MessageSymbolRegistration,&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self.records.append(registration)&lt;br /&gt;
&lt;br /&gt;
    def capture(self, message: CapturedMessage) -&amp;gt; None:&lt;br /&gt;
        self.records.append(message)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create the bus and attach capture:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = ListCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the empty capture sink:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
sink&lt;br /&gt;
sink.records&lt;br /&gt;
len(sink.records)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ListCaptureSink(records=[])&lt;br /&gt;
[]&lt;br /&gt;
0&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create a receiver. This describes the messages the receiver wants to hear:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the receiver and confirm that capture is still empty:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
type(receiver)&lt;br /&gt;
vars(receiver)&lt;br /&gt;
sink.records&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;class &#039;mbus.broker.direct._BrokerReceiver&#039;&amp;gt;&lt;br /&gt;
{&#039;_queue&#039;: ...}&lt;br /&gt;
[]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create an emitter. Registering the emitter gives the bus enough information to bind readable names to compact internal IDs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the captured registration records:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
sink.records&lt;br /&gt;
[type(record).__name__ for record in sink.records]&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    print(record)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[&#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;]&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;demo.signals&#039; ...)&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;ping&#039; ...)&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;demo-source&#039; ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit one message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;hello bus&amp;quot;})&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect capture before receiving:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
len(sink.records)&lt;br /&gt;
[type(record).__name__ for record in sink.records]&lt;br /&gt;
sink.records[-1]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
4&lt;br /&gt;
[&#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;CapturedMessage&#039;]&lt;br /&gt;
CapturedMessage(... payload={&#039;text&#039;: &#039;hello bus&#039;} ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
message = receiver.receive()&lt;br /&gt;
&lt;br /&gt;
message&lt;br /&gt;
message.msg_topic&lt;br /&gt;
message.msg_producer&lt;br /&gt;
message.msg_type&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ReceivedMessage(...)&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&#039;demo-source&#039;&lt;br /&gt;
&#039;ping&#039;&lt;br /&gt;
{&#039;text&#039;: &#039;hello bus&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the received message with the captured record:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured = sink.records[-1]&lt;br /&gt;
&lt;br /&gt;
captured&lt;br /&gt;
captured.msg_topic_id&lt;br /&gt;
captured.msg_type_id&lt;br /&gt;
captured.msg_producer_id&lt;br /&gt;
captured.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
CapturedMessage(...)&lt;br /&gt;
TopicID(...)&lt;br /&gt;
MessageTypeID(...)&lt;br /&gt;
ProducerID(...)&lt;br /&gt;
{&#039;text&#039;: &#039;hello bus&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The receiver gets the readable message view. The capture sink sees compact IDs and local payload data. The readable names are still available through symbol registration records, but manually reconstructing them is awkward.&lt;br /&gt;
&lt;br /&gt;
That awkwardness motivates the next phase.&lt;br /&gt;
&lt;br /&gt;
== Phase 2: Build a temporary capture history helper ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a small demonstration helper that turns captured records back into readable history messages.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Capture records are useful, but raw captured messages use compact IDs.&lt;br /&gt;
* A history helper can rebuild readable message history from registration records.&lt;br /&gt;
* This is demonstration infrastructure, not the final persistence/query API.&lt;br /&gt;
* The helper writes captured records into an in-memory &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; sequential unit.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration before the file edit&lt;br /&gt;
&lt;br /&gt;
List only the captured messages:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured_messages = []&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    if isinstance(record, CapturedMessage):&lt;br /&gt;
        captured_messages.append(record)&lt;br /&gt;
&lt;br /&gt;
captured_messages&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[CapturedMessage(... payload={&#039;text&#039;: &#039;hello bus&#039;} ...)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first captured message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured_message = captured_messages[0]&lt;br /&gt;
&lt;br /&gt;
captured_message.msg_topic_id&lt;br /&gt;
captured_message.msg_type_id&lt;br /&gt;
captured_message.msg_producer_id&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
TopicID(...)&lt;br /&gt;
MessageTypeID(...)&lt;br /&gt;
ProducerID(...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Manually reconstruct a topic name:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
topic_names = {}&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    if isinstance(record, MessageSymbolRegistration):&lt;br /&gt;
        if record.symbol_kind.name == &amp;quot;TOPIC&amp;quot;:&lt;br /&gt;
            topic_names[record.symbol_id] = record.symbol&lt;br /&gt;
&lt;br /&gt;
topic_names&lt;br /&gt;
topic_names[captured_message.msg_topic_id]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
{TopicID(...): &#039;demo.signals&#039;}&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This works, but repeating it by hand would distract from writing event processors.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/capture_history.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/capture_history.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Capture and history helpers for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from collections.abc import Iterable&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
&lt;br /&gt;
from aostore.sequentialunit.listsequentialunit import ListSequentialUnit&lt;br /&gt;
from mbus.capture.sink import CaptureSink&lt;br /&gt;
from mbus.message.records import CapturedMessage&lt;br /&gt;
from mbus.message.symbols import (&lt;br /&gt;
    MessageSymbolKind,&lt;br /&gt;
    MessageSymbolRegistration,&lt;br /&gt;
    MessageTypeID,&lt;br /&gt;
    ProducerID,&lt;br /&gt;
    TopicID,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
CapturedRecord = MessageSymbolRegistration | CapturedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class AOStoreCaptureSink(CaptureSink):&lt;br /&gt;
    records: ListSequentialUnit[CapturedRecord] = field(&lt;br /&gt;
        default_factory=ListSequentialUnit&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    def capture_symbol_registration(&lt;br /&gt;
        self,&lt;br /&gt;
        registration: MessageSymbolRegistration,&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self.records.append(registration)&lt;br /&gt;
&lt;br /&gt;
    def capture(self, message: CapturedMessage) -&amp;gt; None:&lt;br /&gt;
        self.records.append(message)&lt;br /&gt;
&lt;br /&gt;
    def all_records(self) -&amp;gt; tuple[CapturedRecord, ...]:&lt;br /&gt;
        records = self.records.sequential_read(0, len(self.records))&lt;br /&gt;
        result = tuple(records)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class HistoryMessage:&lt;br /&gt;
    msg_topic: str&lt;br /&gt;
    msg_type: str&lt;br /&gt;
    msg_producer: str&lt;br /&gt;
    payload: object&lt;br /&gt;
    bus_sequence: int&lt;br /&gt;
    topic_sequence: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class CaptureHistory:&lt;br /&gt;
    _records: tuple[CapturedRecord, ...]&lt;br /&gt;
    _topics: dict[TopicID, str]&lt;br /&gt;
    _types: dict[MessageTypeID, str]&lt;br /&gt;
    _producers: dict[ProducerID, str]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, records: Iterable[CapturedRecord]) -&amp;gt; None:&lt;br /&gt;
        self._records = tuple(records)&lt;br /&gt;
        self._topics = {}&lt;br /&gt;
        self._types = {}&lt;br /&gt;
        self._producers = {}&lt;br /&gt;
        self._index_symbols()&lt;br /&gt;
&lt;br /&gt;
    def _index_symbols(self) -&amp;gt; None:&lt;br /&gt;
        for record in self._records:&lt;br /&gt;
            if isinstance(record, MessageSymbolRegistration):&lt;br /&gt;
                self._record_symbol(record)&lt;br /&gt;
&lt;br /&gt;
    def _record_symbol(self, record: MessageSymbolRegistration) -&amp;gt; None:&lt;br /&gt;
        if record.symbol_kind is MessageSymbolKind.TOPIC:&lt;br /&gt;
            self._topics[record.symbol_id] = record.symbol&lt;br /&gt;
        elif record.symbol_kind is MessageSymbolKind.MSG_TYPE:&lt;br /&gt;
            self._types[record.symbol_id] = record.symbol&lt;br /&gt;
        elif record.symbol_kind is MessageSymbolKind.PRODUCER:&lt;br /&gt;
            self._producers[record.symbol_id] = record.symbol&lt;br /&gt;
&lt;br /&gt;
    def messages(self) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for record in self._records:&lt;br /&gt;
            if isinstance(record, CapturedMessage):&lt;br /&gt;
                message = self._decode_message(record)&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def by_topic(self, msg_topic: str) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for message in self.messages():&lt;br /&gt;
            if message.msg_topic == msg_topic:&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def since(self, bus_sequence: int) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for message in self.messages():&lt;br /&gt;
            if message.bus_sequence &amp;gt;= bus_sequence:&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def _decode_message(self, message: CapturedMessage) -&amp;gt; HistoryMessage:&lt;br /&gt;
        history_message = HistoryMessage(&lt;br /&gt;
            msg_topic=self._topics[message.msg_topic_id],&lt;br /&gt;
            msg_type=self._types[message.msg_type_id],&lt;br /&gt;
            msg_producer=self._producers[message.msg_producer_id],&lt;br /&gt;
            payload=message.payload,&lt;br /&gt;
            bus_sequence=message.bus_sequence,&lt;br /&gt;
            topic_sequence=message.topic_sequence,&lt;br /&gt;
        )&lt;br /&gt;
        return history_message&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;history helper&amp;quot;})&lt;br /&gt;
message = receiver.receive()&lt;br /&gt;
&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
history.messages()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[HistoryMessage(msg_topic=&#039;demo.signals&#039;, msg_type=&#039;ping&#039;, msg_producer=&#039;demo-source&#039;, payload={&#039;text&#039;: &#039;history helper&#039;}, ...)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the decoded history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history_message = history.messages()[0]&lt;br /&gt;
&lt;br /&gt;
history_message.msg_topic&lt;br /&gt;
history_message.msg_type&lt;br /&gt;
history_message.msg_producer&lt;br /&gt;
history_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&#039;ping&#039;&lt;br /&gt;
&#039;demo-source&#039;&lt;br /&gt;
{&#039;text&#039;: &#039;history helper&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 3: Explore shell command data by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Identify the high-level event shape needed by a milestone processor.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The old milestone strategy operated on command-level records.&lt;br /&gt;
* A command-level record includes context such as host and working directory.&lt;br /&gt;
* It also includes shell input and shell output.&lt;br /&gt;
* Raw TTY events do not directly have this shape; reconstruction comes later.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Represent a shell command as a dictionary:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = {&lt;br /&gt;
    &amp;quot;session_id&amp;quot;: &amp;quot;session-1&amp;quot;,&lt;br /&gt;
    &amp;quot;command_index&amp;quot;: 0,&lt;br /&gt;
    &amp;quot;host&amp;quot;: &amp;quot;alpha&amp;quot;,&lt;br /&gt;
    &amp;quot;cwd&amp;quot;: &amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    &amp;quot;input_text&amp;quot;: &amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    &amp;quot;output_text&amp;quot;: &amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    &amp;quot;started_at_ns&amp;quot;: 1000,&lt;br /&gt;
    &amp;quot;ended_at_ns&amp;quot;: 2000,&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the fields used by milestone checks:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command[&amp;quot;host&amp;quot;]&lt;br /&gt;
command[&amp;quot;cwd&amp;quot;]&lt;br /&gt;
command[&amp;quot;input_text&amp;quot;]&lt;br /&gt;
command[&amp;quot;output_text&amp;quot;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;alpha&#039;&lt;br /&gt;
&#039;/home/student&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Try an accidental wrong key:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command[&amp;quot;working_directory&amp;quot;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeyError: &#039;working_directory&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The dictionary contains the information, but it does not document the shape of the event. A typo is discovered only when the field is used. A named event class makes the processor code easier to read and easier to explain.&lt;br /&gt;
&lt;br /&gt;
Positional encoding like tuples and CSV-like records also suffer from this innate limitation. They are inherently detached from the structural contract of the record type; they describe the data they carry, not any particular schema or type contract to which it belongs.&lt;br /&gt;
&lt;br /&gt;
== Phase 4: Persist the event record classes ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create reusable payload classes for the TTY, shell command, and milestone examples.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/events.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/events.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Payload records for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from dataclasses import dataclass&lt;br /&gt;
from typing import Literal&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class TTYReadEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    data: bytes&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class TTYWriteEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    data: bytes&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class LineDisciplineEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    line: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class KeystrokeTimingEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    previous_timestamp_ns: int | None&lt;br /&gt;
    delta_ns: int | None&lt;br /&gt;
    text: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class ShellCommandEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    command_index: int&lt;br /&gt;
    host: str&lt;br /&gt;
    cwd: str&lt;br /&gt;
    input_text: str&lt;br /&gt;
    output_text: str&lt;br /&gt;
    started_at_ns: int&lt;br /&gt;
    ended_at_ns: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
RegexMilestoneField = Literal[&lt;br /&gt;
    &amp;quot;host&amp;quot;,&lt;br /&gt;
    &amp;quot;cwd&amp;quot;,&lt;br /&gt;
    &amp;quot;input_text&amp;quot;,&lt;br /&gt;
    &amp;quot;output_text&amp;quot;,&lt;br /&gt;
]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneExpression:&lt;br /&gt;
    name: str&lt;br /&gt;
    field: RegexMilestoneField&lt;br /&gt;
    pattern: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneResult:&lt;br /&gt;
    expression_name: str&lt;br /&gt;
    field: RegexMilestoneField&lt;br /&gt;
    matched: bool&lt;br /&gt;
    match_text: str | None = None&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneAnalysisEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    command_index: int&lt;br /&gt;
    results: tuple[RegexMilestoneResult, ...]&lt;br /&gt;
    matched_count: int&lt;br /&gt;
    expression_count: int&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.events import ShellCommandEvent&lt;br /&gt;
&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
command&lt;br /&gt;
command.host&lt;br /&gt;
command.cwd&lt;br /&gt;
command.input_text&lt;br /&gt;
command.output_text&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ShellCommandEvent(session_id=&#039;session-1&#039;, command_index=0, ...)&lt;br /&gt;
&#039;alpha&#039;&lt;br /&gt;
&#039;/home/student&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The event shape is now explicit. Other code can depend on a named &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; instead of assuming that a loose dictionary has the expected fields.&lt;br /&gt;
&lt;br /&gt;
== Phase 5: Evaluate milestones manually ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Recreate the core milestone check in a small, transparent form.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* A milestone expression names one check against one shell command field.&lt;br /&gt;
* The field may be command input, command output, working directory, or host context.&lt;br /&gt;
* The result records whether the expression matched and, when available, the matched text.&lt;br /&gt;
* Manual evaluation is useful for learning the shape of the problem before writing a processor.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Import regular expressions and the payload classes:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create one command event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create three milestone expressions:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
expressions = (&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;used-ls&amp;quot;,&lt;br /&gt;
        field=&amp;quot;input_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;\bls\b&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;saw-notes&amp;quot;,&lt;br /&gt;
        field=&amp;quot;output_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;notes\.txt&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;home-directory&amp;quot;,&lt;br /&gt;
        field=&amp;quot;cwd&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;^/home/student$&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Evaluate one expression:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
expression = expressions[0]&lt;br /&gt;
text = getattr(command, expression.field)&lt;br /&gt;
match = re.search(expression.pattern, text)&lt;br /&gt;
&lt;br /&gt;
expression.name&lt;br /&gt;
expression.field&lt;br /&gt;
text&lt;br /&gt;
match.group(0)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;used-ls&#039;&lt;br /&gt;
&#039;input_text&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;ls&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Evaluate all expressions:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
manual_results = []&lt;br /&gt;
&lt;br /&gt;
for expression in expressions:&lt;br /&gt;
    text = getattr(command, expression.field)&lt;br /&gt;
    match = re.search(expression.pattern, text)&lt;br /&gt;
    manual_results.append((expression.name, match is not None))&lt;br /&gt;
&lt;br /&gt;
manual_results&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(&#039;used-ls&#039;, True), (&#039;saw-notes&#039;, True), (&#039;home-directory&#039;, True)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
Manual evaluation is simple for one command, but the loop is already event processor logic. The next phase preserves the loop in a reusable processor.&lt;br /&gt;
&lt;br /&gt;
== Phase 6: Persist the RegexMilestone processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a processor that receives shell command events and emits regex milestone analysis events.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/regex_milestones.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/regex_milestones.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Regex milestone processor for shell command events.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneAnalysisEvent,&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    RegexMilestoneResult,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class RegexMilestoneProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _expressions: tuple[RegexMilestoneExpression, ...]&lt;br /&gt;
&lt;br /&gt;
    def __init__(&lt;br /&gt;
        self,&lt;br /&gt;
        bus: DirectMessageBus,&lt;br /&gt;
        expressions: tuple[RegexMilestoneExpression, ...],&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.shell.command&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;shell-reconstructor&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;shell-command&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.shell.regex_milestone&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;regex-milestone-processor&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;regex-milestone-analysis&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._expressions = expressions&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; RegexMilestoneAnalysisEvent:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        command = message.payload&lt;br /&gt;
&lt;br /&gt;
        if not isinstance(command, ShellCommandEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected ShellCommandEvent, got {type(command).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        analysis = analyze_regex_milestones(command, self._expressions)&lt;br /&gt;
        self._emitter.emit(analysis)&lt;br /&gt;
        return analysis&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def analyze_regex_milestones(&lt;br /&gt;
    command: ShellCommandEvent,&lt;br /&gt;
    expressions: tuple[RegexMilestoneExpression, ...],&lt;br /&gt;
) -&amp;gt; RegexMilestoneAnalysisEvent:&lt;br /&gt;
    results = []&lt;br /&gt;
    for expression in expressions:&lt;br /&gt;
        result = evaluate_regex_milestone(command, expression)&lt;br /&gt;
        results.append(result)&lt;br /&gt;
&lt;br /&gt;
    matched_count = sum(result.matched for result in results)&lt;br /&gt;
    analysis = RegexMilestoneAnalysisEvent(&lt;br /&gt;
        session_id=command.session_id,&lt;br /&gt;
        command_index=command.command_index,&lt;br /&gt;
        results=tuple(results),&lt;br /&gt;
        matched_count=matched_count,&lt;br /&gt;
        expression_count=len(expressions),&lt;br /&gt;
    )&lt;br /&gt;
    return analysis&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def evaluate_regex_milestone(&lt;br /&gt;
    command: ShellCommandEvent,&lt;br /&gt;
    expression: RegexMilestoneExpression,&lt;br /&gt;
) -&amp;gt; RegexMilestoneResult:&lt;br /&gt;
    text = getattr(command, expression.field)&lt;br /&gt;
    match = re.search(expression.pattern, text)&lt;br /&gt;
    match_text = None&lt;br /&gt;
&lt;br /&gt;
    if match is not None:&lt;br /&gt;
        match_text = match.group(0)&lt;br /&gt;
&lt;br /&gt;
    result = RegexMilestoneResult(&lt;br /&gt;
        expression_name=expression.name,&lt;br /&gt;
        field=expression.field,&lt;br /&gt;
        matched=match is not None,&lt;br /&gt;
        match_text=match_text,&lt;br /&gt;
    )&lt;br /&gt;
    return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then set up the bus:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.regex_milestones import RegexMilestoneProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Subscribe to the analysis topic before running the processor:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.shell.regex_milestone&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;regex-milestone-processor&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;regex-milestone-analysis&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create the shell command emitter and processor:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.shell.command&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;shell-reconstructor&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;shell-command&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
expressions = (&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;used-ls&amp;quot;,&lt;br /&gt;
        field=&amp;quot;input_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;\bls\b&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;saw-notes&amp;quot;,&lt;br /&gt;
        field=&amp;quot;output_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;notes\.txt&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;home-directory&amp;quot;,&lt;br /&gt;
        field=&amp;quot;cwd&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;^/home/student$&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = RegexMilestoneProcessor(bus, expressions)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit a shell command event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
command_emitter.emit(command)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the command:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis = processor.process_one()&lt;br /&gt;
analysis&lt;br /&gt;
analysis.results&lt;br /&gt;
analysis.matched_count&lt;br /&gt;
analysis.expression_count&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
RegexMilestoneAnalysisEvent(... matched_count=3, expression_count=3)&lt;br /&gt;
(RegexMilestoneResult(...), RegexMilestoneResult(...), RegexMilestoneResult(...))&lt;br /&gt;
3&lt;br /&gt;
3&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the derived analysis event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis_message = analysis_receiver.receive()&lt;br /&gt;
&lt;br /&gt;
analysis_message.msg_topic&lt;br /&gt;
analysis_message.msg_type&lt;br /&gt;
analysis_message.msg_producer&lt;br /&gt;
analysis_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;demo.shell.regex_milestone&#039;&lt;br /&gt;
&#039;regex-milestone-analysis&#039;&lt;br /&gt;
&#039;regex-milestone-processor&#039;&lt;br /&gt;
RegexMilestoneAnalysisEvent(... matched_count=3, expression_count=3)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
messages = history.messages()&lt;br /&gt;
&lt;br /&gt;
for message in messages:&lt;br /&gt;
    print(message.bus_sequence, message.msg_topic, message.msg_type)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
0 demo.shell.command shell-command&lt;br /&gt;
1 demo.shell.regex_milestone regex-milestone-analysis&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The regex milestone processor consumes one event and emits another. The derived analysis event is available to any later subscriber. History contains both the source command event and the derived analysis event.&lt;br /&gt;
&lt;br /&gt;
== Phase 7: Explore why shell reconstruction is separate ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Show that raw TTY observations are not the same thing as high-level shell command events.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Regex milestones should inspect high-level command events.&lt;br /&gt;
* TTY reads, TTY writes, and line-discipline lines are lower-level observations.&lt;br /&gt;
* A shell command event is reconstructed by correlating those observations.&lt;br /&gt;
* Keeping reconstruction separate prevents the milestone processor from becoming a large TTY parser.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Create a few low-level observations:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    LineDisciplineEvent,&lt;br /&gt;
    TTYReadEvent,&lt;br /&gt;
    TTYWriteEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_1 = TTYReadEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=0,&lt;br /&gt;
    timestamp_ns=1000,&lt;br /&gt;
    data=b&amp;quot;l&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_2 = TTYReadEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=1,&lt;br /&gt;
    timestamp_ns=1100,&lt;br /&gt;
    data=b&amp;quot;s&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
line = LineDisciplineEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=2,&lt;br /&gt;
    timestamp_ns=1200,&lt;br /&gt;
    line=&amp;quot;ls\n&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
write = TTYWriteEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=3,&lt;br /&gt;
    timestamp_ns=1600,&lt;br /&gt;
    data=b&amp;quot;notes.txt\n&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the raw pieces:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_1.data&lt;br /&gt;
read_2.data&lt;br /&gt;
line.line&lt;br /&gt;
write.data&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
b&#039;l&#039;&lt;br /&gt;
b&#039;s&#039;&lt;br /&gt;
&#039;ls\n&#039;&lt;br /&gt;
b&#039;notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Assemble command fields manually:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
input_text = line.line&lt;br /&gt;
output_text = write.data.decode(&amp;quot;utf-8&amp;quot;, errors=&amp;quot;replace&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
input_text&lt;br /&gt;
output_text&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;ls\n&#039;&lt;br /&gt;
&#039;notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The useful command event is derived from several lower-level observations. A later shell reconstructor can subscribe to line-discipline events, query recent reads/writes/timing observations, and emit &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; records.&lt;br /&gt;
&lt;br /&gt;
== Phase 8: Add keystroke timing as a smaller TTY-derived processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a simple stateful processor before attempting full shell reconstruction.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Timing requires comparing a TTY read with the previous read in the same session.&lt;br /&gt;
* A derived timing event makes the result explicit.&lt;br /&gt;
* Other processors can consume timing events instead of recalculating deltas.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration before the file edit&lt;br /&gt;
&lt;br /&gt;
Calculate timing manually from the two read events:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_events = [read_1, read_2]&lt;br /&gt;
previous_timestamp = None&lt;br /&gt;
timing_rows = []&lt;br /&gt;
&lt;br /&gt;
for event in read_events:&lt;br /&gt;
    delta = None&lt;br /&gt;
    if previous_timestamp is not None:&lt;br /&gt;
        delta = event.timestamp_ns - previous_timestamp&lt;br /&gt;
    timing_rows.append((event.source_index, previous_timestamp, delta))&lt;br /&gt;
    previous_timestamp = event.timestamp_ns&lt;br /&gt;
&lt;br /&gt;
timing_rows&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, None, None), (1, 1000, 100)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/keystroke_timing.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/keystroke_timing.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Keystroke timing processor for TTY read events.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    KeystrokeTimingEvent,&lt;br /&gt;
    TTYReadEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class KeystrokeTimingProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _last_timestamp_by_session: dict[str, int]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.tty.read&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;tty-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;tty-read&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.keystroke.timing&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._last_timestamp_by_session = {}&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; KeystrokeTimingEvent:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        event = message.payload&lt;br /&gt;
&lt;br /&gt;
        if not isinstance(event, TTYReadEvent):&lt;br /&gt;
            raise TypeError(f&amp;quot;expected TTYReadEvent, got {type(event).__name__}&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
        previous_timestamp = self._last_timestamp_by_session.get(&lt;br /&gt;
            event.session_id&lt;br /&gt;
        )&lt;br /&gt;
        delta = None&lt;br /&gt;
&lt;br /&gt;
        if previous_timestamp is not None:&lt;br /&gt;
            delta = event.timestamp_ns - previous_timestamp&lt;br /&gt;
&lt;br /&gt;
        self._last_timestamp_by_session[event.session_id] = event.timestamp_ns&lt;br /&gt;
&lt;br /&gt;
        text = event.data.decode(&amp;quot;utf-8&amp;quot;, errors=&amp;quot;replace&amp;quot;)&lt;br /&gt;
        timing = KeystrokeTimingEvent(&lt;br /&gt;
            session_id=event.session_id,&lt;br /&gt;
            source_index=event.source_index,&lt;br /&gt;
            timestamp_ns=event.timestamp_ns,&lt;br /&gt;
            previous_timestamp_ns=previous_timestamp,&lt;br /&gt;
            delta_ns=delta,&lt;br /&gt;
            text=text,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter.emit(timing)&lt;br /&gt;
        return timing&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
from demos.event_processors.events import TTYReadEvent&lt;br /&gt;
from demos.event_processors.keystroke_timing import KeystrokeTimingProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
timing_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.keystroke.timing&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.tty.read&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;tty-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;tty-read&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = KeystrokeTimingProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit reads and process them:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_emitter.emit(&lt;br /&gt;
    TTYReadEvent(&lt;br /&gt;
        session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
        source_index=0,&lt;br /&gt;
        timestamp_ns=1000,&lt;br /&gt;
        data=b&amp;quot;l&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_emitter.emit(&lt;br /&gt;
    TTYReadEvent(&lt;br /&gt;
        session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
        source_index=1,&lt;br /&gt;
        timestamp_ns=1100,&lt;br /&gt;
        data=b&amp;quot;s&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor.process_one()&lt;br /&gt;
processor.process_one()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeystrokeTimingEvent(... previous_timestamp_ns=None, delta_ns=None, text=&#039;l&#039;)&lt;br /&gt;
KeystrokeTimingEvent(... previous_timestamp_ns=1000, delta_ns=100, text=&#039;s&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive timing events:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
timing_receiver.receive().payload&lt;br /&gt;
timing_receiver.receive().payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeystrokeTimingEvent(... source_index=0 ...)&lt;br /&gt;
KeystrokeTimingEvent(... source_index=1 ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 9: Introduce graph events ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Switch to a small graph problem that can demonstrate safe feedback loops in an illustratable form.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* An edge is a source fact.&lt;br /&gt;
* A path is a derived fact.&lt;br /&gt;
* A direct edge implies a direct path.&lt;br /&gt;
* Longer paths can be derived later from known paths and edges.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_events.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/graph_events.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Graph event payloads for message bus demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from dataclasses import dataclass&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class EdgeDeclaredEvent:&lt;br /&gt;
    graph_id: str&lt;br /&gt;
    source: str&lt;br /&gt;
    target: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class PathKnownEvent:&lt;br /&gt;
    graph_id: str&lt;br /&gt;
    source: str&lt;br /&gt;
    target: str&lt;br /&gt;
    hop_count: int&lt;br /&gt;
&lt;br /&gt;
    def fact_key(self) -&amp;gt; tuple[str, str, str]:&lt;br /&gt;
        result = (self.graph_id, self.source, self.target)&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge = EdgeDeclaredEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;A&amp;quot;,&lt;br /&gt;
    target=&amp;quot;B&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path = PathKnownEvent(&lt;br /&gt;
    graph_id=edge.graph_id,&lt;br /&gt;
    source=edge.source,&lt;br /&gt;
    target=edge.target,&lt;br /&gt;
    hop_count=1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge&lt;br /&gt;
path&lt;br /&gt;
path.fact_key()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
EdgeDeclaredEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;)&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;, hop_count=1)&lt;br /&gt;
(&#039;demo-graph&#039;, &#039;A&#039;, &#039;B&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 10: Create a demo-only nonblocking receive helper ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Prepare for processor scheduling without editing &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;Receiver.receive()&amp;lt;/code&amp;gt; blocks until a message is available.&lt;br /&gt;
* A scheduler needs to try a processor and move on if no message is waiting.&lt;br /&gt;
* The current package does not expose a public nonblocking receiver method.&lt;br /&gt;
* This helper is local demonstration code. It can be replaced by a real public API later.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/demo_receive.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/demo_receive.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Demo-only helpers for receiver inspection and scheduling.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from queue import Empty&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.endpoints import Receiver&lt;br /&gt;
from mbus.message.records import ReceivedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class DemoReceiverError(RuntimeError):&lt;br /&gt;
    pass&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def try_receive(receiver: Receiver) -&amp;gt; ReceivedMessage | None:&lt;br /&gt;
    queue = getattr(receiver, &amp;quot;_queue&amp;quot;, None)&lt;br /&gt;
&lt;br /&gt;
    if queue is None:&lt;br /&gt;
        raise DemoReceiverError(&lt;br /&gt;
            &amp;quot;demo try_receive requires the current direct broker receiver&amp;quot;&lt;br /&gt;
        )&lt;br /&gt;
&lt;br /&gt;
    try:&lt;br /&gt;
        message = queue.get_nowait()&lt;br /&gt;
    except Empty:&lt;br /&gt;
        message = None&lt;br /&gt;
&lt;br /&gt;
    return message&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.demo_receive import try_receive&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
try_receive(receiver)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
None&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Now emit a message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;demo nonblocking receive&amp;quot;})&lt;br /&gt;
message = try_receive(receiver)&lt;br /&gt;
&lt;br /&gt;
message&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ReceivedMessage(...)&lt;br /&gt;
{&#039;text&#039;: &#039;demo nonblocking receive&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 11: Create the direct path processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Implement the graph rule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
edge(A, B) -&amp;gt; path(A, B)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The source emits edge events.&lt;br /&gt;
* The direct path processor emits derived path events.&lt;br /&gt;
* Other processors can subscribe to derived path events.&lt;br /&gt;
* The processor can be run by hand or by a scheduler.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_reachability.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/graph_reachability.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Graph reachability processors for message bus demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.demo_receive import try_receive&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class DirectPathProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _known_paths: set[tuple[str, str, str]]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._known_paths = set()&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; PathKnownEvent | None:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        result = self._process_payload(message.payload)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
    def process_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            self._process_payload(message.payload)&lt;br /&gt;
            work_count = 1&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def _process_payload(self, payload: object) -&amp;gt; PathKnownEvent | None:&lt;br /&gt;
        if not isinstance(payload, EdgeDeclaredEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected EdgeDeclaredEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        path = PathKnownEvent(&lt;br /&gt;
            graph_id=payload.graph_id,&lt;br /&gt;
            source=payload.source,&lt;br /&gt;
            target=payload.target,&lt;br /&gt;
            hop_count=1,&lt;br /&gt;
        )&lt;br /&gt;
&lt;br /&gt;
        if path.fact_key() not in self._known_paths:&lt;br /&gt;
            self._known_paths.add(path.fact_key())&lt;br /&gt;
            self._emitter.emit(path)&lt;br /&gt;
            result = path&lt;br /&gt;
        else:&lt;br /&gt;
            result = None&lt;br /&gt;
&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
from demos.event_processors.graph_events import EdgeDeclaredEvent&lt;br /&gt;
from demos.event_processors.graph_reachability import DirectPathProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
path_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = DirectPathProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Call the processor before any edge exists:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
0&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit one edge:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;A&amp;quot;,&lt;br /&gt;
        target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the edge:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the derived path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_message = path_receiver.receive()&lt;br /&gt;
path_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;, hop_count=1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 12: Explore the path extension rule by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Understand the feedback rule before implementing it.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* If &amp;lt;code&amp;gt;A&amp;lt;/code&amp;gt; reaches &amp;lt;code&amp;gt;B&amp;lt;/code&amp;gt;, and there is an edge from &amp;lt;code&amp;gt;B&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;C&amp;lt;/code&amp;gt;, then &amp;lt;code&amp;gt;A&amp;lt;/code&amp;gt; reaches &amp;lt;code&amp;gt;C&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Derived path facts can lead to more derived path facts.&lt;br /&gt;
* Duplicate prevention keeps the feedback loop from repeating the same fact forever.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_bc = EdgeDeclaredEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;B&amp;quot;,&lt;br /&gt;
    target=&amp;quot;C&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path_ab = PathKnownEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;A&amp;quot;,&lt;br /&gt;
    target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    hop_count=1,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Check whether the path and edge connect:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_ab.target&lt;br /&gt;
edge_bc.source&lt;br /&gt;
path_ab.target == edge_bc.source&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;B&#039;&lt;br /&gt;
&#039;B&#039;&lt;br /&gt;
True&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Derive a new path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_ac = PathKnownEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=path_ab.source,&lt;br /&gt;
    target=edge_bc.target,&lt;br /&gt;
    hop_count=path_ab.hop_count + 1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path_ac&lt;br /&gt;
path_ac.fact_key()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;C&#039;, hop_count=2)&lt;br /&gt;
(&#039;demo-graph&#039;, &#039;A&#039;, &#039;C&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Manually prevent duplicate path facts:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
known_paths = set()&lt;br /&gt;
known_paths.add(path_ab.fact_key())&lt;br /&gt;
&lt;br /&gt;
path_ac.fact_key() in known_paths&lt;br /&gt;
&lt;br /&gt;
if path_ac.fact_key() not in known_paths:&lt;br /&gt;
    known_paths.add(path_ac.fact_key())&lt;br /&gt;
&lt;br /&gt;
known_paths&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
False&lt;br /&gt;
{(&#039;demo-graph&#039;, &#039;A&#039;, &#039;B&#039;), (&#039;demo-graph&#039;, &#039;A&#039;, &#039;C&#039;)}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 13: Add the path extension processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Implement the feedback rule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
path(A, B) + edge(B, C) -&amp;gt; path(A, C)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The extension processor listens to edge events and path events.&lt;br /&gt;
* It emits new path events when a known path can be extended.&lt;br /&gt;
* It also listens to its own output because an extended path may be extendable again.&lt;br /&gt;
* This is a controlled feedback loop.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Append this class to:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_reachability.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
class PathExtensionProcessor:&lt;br /&gt;
    _edge_receiver: Receiver&lt;br /&gt;
    _direct_path_receiver: Receiver&lt;br /&gt;
    _extension_path_receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _edges: set[tuple[str, str, str]]&lt;br /&gt;
    _paths: dict[tuple[str, str, str], PathKnownEvent]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._edge_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._direct_path_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._extension_path_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._edges = set()&lt;br /&gt;
        self._paths = {}&lt;br /&gt;
&lt;br /&gt;
    def process_edge_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._edge_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_edge_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def process_direct_path_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._direct_path_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_path_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def process_extension_path_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._extension_path_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_path_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def _process_edge_payload(&lt;br /&gt;
        self,&lt;br /&gt;
        payload: object,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        if not isinstance(payload, EdgeDeclaredEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected EdgeDeclaredEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        edge = payload&lt;br /&gt;
        self._edges.add((edge.graph_id, edge.source, edge.target))&lt;br /&gt;
        paths = self._derive_from_new_edge(edge)&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _process_path_payload(&lt;br /&gt;
        self,&lt;br /&gt;
        payload: object,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        if not isinstance(payload, PathKnownEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected PathKnownEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        path = payload&lt;br /&gt;
        self._paths[path.fact_key()] = path&lt;br /&gt;
        paths = self._derive_from_new_path(path)&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _derive_from_new_edge(&lt;br /&gt;
        self,&lt;br /&gt;
        edge: EdgeDeclaredEvent,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        paths = []&lt;br /&gt;
&lt;br /&gt;
        for known_path in self._paths.values():&lt;br /&gt;
            if known_path.graph_id != edge.graph_id:&lt;br /&gt;
                continue&lt;br /&gt;
            if known_path.target != edge.source:&lt;br /&gt;
                continue&lt;br /&gt;
&lt;br /&gt;
            next_path = PathKnownEvent(&lt;br /&gt;
                graph_id=edge.graph_id,&lt;br /&gt;
                source=known_path.source,&lt;br /&gt;
                target=edge.target,&lt;br /&gt;
                hop_count=known_path.hop_count + 1,&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
            if self._emit_if_new(next_path):&lt;br /&gt;
                paths.append(next_path)&lt;br /&gt;
&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _derive_from_new_path(&lt;br /&gt;
        self,&lt;br /&gt;
        path: PathKnownEvent,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        paths = []&lt;br /&gt;
&lt;br /&gt;
        for graph_id, edge_source, edge_target in self._edges:&lt;br /&gt;
            if graph_id != path.graph_id:&lt;br /&gt;
                continue&lt;br /&gt;
            if edge_source != path.target:&lt;br /&gt;
                continue&lt;br /&gt;
&lt;br /&gt;
            next_path = PathKnownEvent(&lt;br /&gt;
                graph_id=path.graph_id,&lt;br /&gt;
                source=path.source,&lt;br /&gt;
                target=edge_target,&lt;br /&gt;
                hop_count=path.hop_count + 1,&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
            if self._emit_if_new(next_path):&lt;br /&gt;
                paths.append(next_path)&lt;br /&gt;
&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _emit_if_new(self, path: PathKnownEvent) -&amp;gt; bool:&lt;br /&gt;
        if path.fact_key() in self._paths:&lt;br /&gt;
            result = False&lt;br /&gt;
        else:&lt;br /&gt;
            self._paths[path.fact_key()] = path&lt;br /&gt;
            self._emitter.emit(path)&lt;br /&gt;
            result = True&lt;br /&gt;
&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_events import EdgeDeclaredEvent&lt;br /&gt;
from demos.event_processors.graph_reachability import (&lt;br /&gt;
    DirectPathProcessor,&lt;br /&gt;
    PathExtensionProcessor,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
extension_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
direct_processor = DirectPathProcessor(bus)&lt;br /&gt;
extension_processor = PathExtensionProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit two edges in fixed order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;A&amp;quot;,&lt;br /&gt;
        target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;B&amp;quot;,&lt;br /&gt;
        target=&amp;quot;C&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the direct paths:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
direct_processor.process_available()&lt;br /&gt;
direct_processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Let the extension processor learn the edges:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
extension_processor.process_edge_available()&lt;br /&gt;
extension_processor.process_edge_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Let the extension processor learn the direct paths:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
extension_processor.process_direct_path_available()&lt;br /&gt;
extension_processor.process_direct_path_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The second call may emit the extended &amp;lt;code&amp;gt;A -&amp;gt; C&amp;lt;/code&amp;gt; path after the processor has seen both direct paths and both edges.&lt;br /&gt;
&lt;br /&gt;
Receive the extended path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
message = extension_receiver.receive()&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;C&#039;, hop_count=2)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect captured graph path history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
path_messages = history.by_topic(&amp;quot;demo.graph.path&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
for message in path_messages:&lt;br /&gt;
    path = message.payload&lt;br /&gt;
    print(&lt;br /&gt;
        message.bus_sequence,&lt;br /&gt;
        message.msg_producer,&lt;br /&gt;
        path.source,&lt;br /&gt;
        path.target,&lt;br /&gt;
        path.hop_count,&lt;br /&gt;
    )&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
... direct-path A B 1&lt;br /&gt;
... direct-path B C 1&lt;br /&gt;
... path-extension A C 2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 14: Add a randomized processor scheduler ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Simulate concurrency effects without changing source input order.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The source will emit the same edge events in the same order every run.&lt;br /&gt;
* The scheduler varies which processor gets a chance to run first.&lt;br /&gt;
* This simulates uncertainty from processing time, scheduling priority, startup timing, or transmission delay.&lt;br /&gt;
* The graph example should converge to the same final path facts even when processor scheduling - and the order of intermediate derived processor events - differs.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/runners.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/runners.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Small processor runners for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from collections.abc import Callable&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
from random import Random&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class ProcessorStep:&lt;br /&gt;
    name: str&lt;br /&gt;
    run: Callable[[], int]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class RandomProcessorRunner:&lt;br /&gt;
    steps: tuple[ProcessorStep, ...]&lt;br /&gt;
    seed: int&lt;br /&gt;
    max_rounds: int = 30&lt;br /&gt;
    trace: list[tuple[int, str, int]] = field(default_factory=list)&lt;br /&gt;
&lt;br /&gt;
    def run_until_quiet(self) -&amp;gt; list[tuple[int, str, int]]:&lt;br /&gt;
        rng = Random(self.seed)&lt;br /&gt;
&lt;br /&gt;
        for round_index in range(self.max_rounds):&lt;br /&gt;
            work_count = self._run_round(rng, round_index)&lt;br /&gt;
&lt;br /&gt;
            if work_count == 0:&lt;br /&gt;
                break&lt;br /&gt;
&lt;br /&gt;
        result = list(self.trace)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
    def _run_round(self, rng: Random, round_index: int) -&amp;gt; int:&lt;br /&gt;
        steps = list(self.steps)&lt;br /&gt;
        rng.shuffle(steps)&lt;br /&gt;
        round_work_count = 0&lt;br /&gt;
&lt;br /&gt;
        for step in steps:&lt;br /&gt;
            step_work_count = step.run()&lt;br /&gt;
            self.trace.append((round_index, step.name, step_work_count))&lt;br /&gt;
            round_work_count += step_work_count&lt;br /&gt;
&lt;br /&gt;
        return round_work_count&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then inspect how a runner can shuffle placeholder steps:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.runners import (&lt;br /&gt;
    ProcessorStep,&lt;br /&gt;
    RandomProcessorRunner,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
calls = []&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def first_step() -&amp;gt; int:&lt;br /&gt;
    calls.append(&amp;quot;first&amp;quot;)&lt;br /&gt;
    return 0&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def second_step() -&amp;gt; int:&lt;br /&gt;
    calls.append(&amp;quot;second&amp;quot;)&lt;br /&gt;
    return 0&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
runner = RandomProcessorRunner(&lt;br /&gt;
    steps=(&lt;br /&gt;
        ProcessorStep(name=&amp;quot;first&amp;quot;, run=first_step),&lt;br /&gt;
        ProcessorStep(name=&amp;quot;second&amp;quot;, run=second_step),&lt;br /&gt;
    ),&lt;br /&gt;
    seed=1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
runner.run_until_quiet()&lt;br /&gt;
calls&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, &#039;...&#039;, 0), (0, &#039;...&#039;, 0)]&lt;br /&gt;
[&#039;...&#039;, &#039;...&#039;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 15: Run the graph example with randomized scheduling ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Run the same graph input under different processor schedules and compare the final path facts.&lt;br /&gt;
&lt;br /&gt;
; Interpreter setup&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_reachability import (&lt;br /&gt;
    DirectPathProcessor,&lt;br /&gt;
    PathExtensionProcessor,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.runners import (&lt;br /&gt;
    ProcessorStep,&lt;br /&gt;
    RandomProcessorRunner,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Define a helper function in the interpreter:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
def run_graph_demo(seed: int) -&amp;gt; tuple[&lt;br /&gt;
    list[tuple[int, str, int]],&lt;br /&gt;
    list[tuple[int, str, str, int]],&lt;br /&gt;
]:&lt;br /&gt;
    bus = DirectMessageBus()&lt;br /&gt;
    sink = AOStoreCaptureSink()&lt;br /&gt;
    bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
    edge_emitter = bus.register_emitter(&lt;br /&gt;
        msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
        msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
        default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    direct_processor = DirectPathProcessor(bus)&lt;br /&gt;
    extension_processor = PathExtensionProcessor(bus)&lt;br /&gt;
&lt;br /&gt;
    edges = (&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;A&amp;quot;,&lt;br /&gt;
            target=&amp;quot;B&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;B&amp;quot;,&lt;br /&gt;
            target=&amp;quot;C&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;C&amp;quot;,&lt;br /&gt;
            target=&amp;quot;D&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    for edge in edges:&lt;br /&gt;
        edge_emitter.emit(edge)&lt;br /&gt;
&lt;br /&gt;
    steps = (&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;direct path from edge&amp;quot;,&lt;br /&gt;
            run=direct_processor.process_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns edge&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_edge_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns direct path&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_direct_path_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns extension path&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_extension_path_available,&lt;br /&gt;
        ),&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    runner = RandomProcessorRunner(&lt;br /&gt;
        steps=steps,&lt;br /&gt;
        seed=seed,&lt;br /&gt;
    )&lt;br /&gt;
    trace = runner.run_until_quiet()&lt;br /&gt;
&lt;br /&gt;
    history = CaptureHistory(sink.all_records())&lt;br /&gt;
    path_facts = []&lt;br /&gt;
&lt;br /&gt;
    for message in history.by_topic(&amp;quot;demo.graph.path&amp;quot;):&lt;br /&gt;
        path = message.payload&lt;br /&gt;
        if isinstance(path, PathKnownEvent):&lt;br /&gt;
            fact = (&lt;br /&gt;
                message.bus_sequence,&lt;br /&gt;
                path.source,&lt;br /&gt;
                path.target,&lt;br /&gt;
                path.hop_count,&lt;br /&gt;
            )&lt;br /&gt;
            path_facts.append(fact)&lt;br /&gt;
&lt;br /&gt;
    result = (trace, path_facts)&lt;br /&gt;
    return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Run one schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_1, paths_1 = run_graph_demo(1)&lt;br /&gt;
&lt;br /&gt;
trace_1&lt;br /&gt;
paths_1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, &#039;...&#039;, ...), (0, &#039;...&#039;, ...), ...]&lt;br /&gt;
[(..., &#039;A&#039;, &#039;B&#039;, 1), (..., &#039;B&#039;, &#039;C&#039;, 1), (..., &#039;C&#039;, &#039;D&#039;, 1), ...]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Run another schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_2, paths_2 = run_graph_demo(2)&lt;br /&gt;
&lt;br /&gt;
trace_2&lt;br /&gt;
paths_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the schedules:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_1 == trace_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
False&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the final path facts without bus sequence numbers:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
final_paths_1 = {&lt;br /&gt;
    (source, target, hop_count)&lt;br /&gt;
    for _, source, target, hop_count in paths_1&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
final_paths_2 = {&lt;br /&gt;
    (source, target, hop_count)&lt;br /&gt;
    for _, source, target, hop_count in paths_2&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
final_paths_1&lt;br /&gt;
final_paths_2&lt;br /&gt;
final_paths_1 == final_paths_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
{(&#039;A&#039;, &#039;B&#039;, 1), (&#039;B&#039;, &#039;C&#039;, 1), (&#039;C&#039;, &#039;D&#039;, 1), (&#039;A&#039;, &#039;C&#039;, 2), (&#039;B&#039;, &#039;D&#039;, 2), (&#039;A&#039;, &#039;D&#039;, 3)}&lt;br /&gt;
{(&#039;A&#039;, &#039;B&#039;, 1), (&#039;B&#039;, &#039;C&#039;, 1), (&#039;C&#039;, &#039;D&#039;, 1), (&#039;A&#039;, &#039;C&#039;, 2), (&#039;B&#039;, &#039;D&#039;, 2), (&#039;A&#039;, &#039;D&#039;, 3)}&lt;br /&gt;
True&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for round_index, step_name, work_count in trace_1:&lt;br /&gt;
    print(round_index, step_name, work_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first captured path order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for bus_sequence, source, target, hop_count in paths_1:&lt;br /&gt;
    print(bus_sequence, source, target, hop_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the second schedule and path order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for round_index, step_name, work_count in trace_2:&lt;br /&gt;
    print(round_index, step_name, work_count)&lt;br /&gt;
&lt;br /&gt;
for bus_sequence, source, target, hop_count in paths_2:&lt;br /&gt;
    print(bus_sequence, source, target, hop_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The source input order did not change. The processor scheduling changed. The captured event order may differ. The final reachability facts should match.&lt;br /&gt;
&lt;br /&gt;
This models an important distributed-systems lesson: event history order and final derived state are related, but they are not the same thing.&lt;br /&gt;
&lt;br /&gt;
== Phase 16: Later refinement: graph index from history ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Introduce restart/recovery thinking only after the basic graph feedback example is familiar.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The first graph processors keep local working state.&lt;br /&gt;
* Local working state is enough for a short demonstration.&lt;br /&gt;
* Longer-lived processors need recovery behavior after restart.&lt;br /&gt;
* Since edge and path facts are captured as events, a graph index can later be rebuilt from history.&lt;br /&gt;
* This points toward projection-style services without changing the basic processor model.&lt;br /&gt;
&lt;br /&gt;
; Suggested later file&lt;br /&gt;
&lt;br /&gt;
Create later:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_index.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Suggested responsibilities:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
read captured history&lt;br /&gt;
collect EdgeDeclaredEvent payloads&lt;br /&gt;
collect PathKnownEvent payloads&lt;br /&gt;
answer whether a path fact exists&lt;br /&gt;
find edges starting at a node&lt;br /&gt;
find paths ending at a node&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This refinement belongs after the team has already run the graph reachability demo successfully.&lt;br /&gt;
&lt;br /&gt;
== Phase 17: Later refinement: shell command reconstruction ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Connect the TTY examples back to the shell command event shape used by milestones.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; is the high-level event consumed by the milestone processor.&lt;br /&gt;
* &amp;lt;code&amp;gt;TTYReadEvent&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;TTYWriteEvent&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;LineDisciplineEvent&amp;lt;/code&amp;gt; are lower-level observations.&lt;br /&gt;
* A reconstructor can subscribe to line-discipline events and query recent related observations.&lt;br /&gt;
* The reconstructor should emit shell command events; it should not perform regex analysis itself.&lt;br /&gt;
&lt;br /&gt;
; Suggested responsibilities&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
listen for completed line-discipline events&lt;br /&gt;
query history since the previous command boundary for the same session&lt;br /&gt;
collect matching reads, writes, and timing events&lt;br /&gt;
construct ShellCommandEvent&lt;br /&gt;
emit ShellCommandEvent on demo.shell.command&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This is an appropriate follow-up exercise after the team understands both milestone processing and history queries.&lt;br /&gt;
&lt;br /&gt;
= Related =&lt;br /&gt;
&lt;br /&gt;
== Top-level topic page ==&lt;br /&gt;
[[Message Bus Service]]&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=Message_Bus_Live_Coding_Exercise&amp;diff=942</id>
		<title>Message Bus Live Coding Exercise</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=Message_Bus_Live_Coding_Exercise&amp;diff=942"/>
		<updated>2026-06-16T15:04:16Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* Suggested group session flow */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Introduction =&lt;br /&gt;
&lt;br /&gt;
== Audience and scope ==&lt;br /&gt;
&lt;br /&gt;
This exercise is intended for EDURange developers learning how to write event processors with the message bus.&lt;br /&gt;
&lt;br /&gt;
The packaged &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; modules are treated as read-only for this exercise. All new code belongs under a demonstration directory such as:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
workspace/&lt;br /&gt;
    mbus/&lt;br /&gt;
    aostore/&lt;br /&gt;
    demos/&lt;br /&gt;
        __init__.py&lt;br /&gt;
        event_processors/&lt;br /&gt;
            __init__.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Pre-release copies of &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; are available on the Discord fileshare channel. Modules may be released under other package names; see also the EDURange GitHub.&lt;br /&gt;
&lt;br /&gt;
The exercise uses two problem families.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;1. Shell and TTY event processing&#039;&#039;&#039;&lt;br /&gt;
** Send and receive messages through the bus.&lt;br /&gt;
** Capture and inspect message history.&lt;br /&gt;
** Represent high-level shell command events.&lt;br /&gt;
** Recreate the regex-based milestone role in a simplified form.&lt;br /&gt;
** Mock up TTY-derived observations such as keystroke timing.&lt;br /&gt;
* &#039;&#039;&#039;2. Graph event processing&#039;&#039;&#039;&lt;br /&gt;
** Represent graph edge relationships as source events.&lt;br /&gt;
** Derive direct path facts from edge relation events.&lt;br /&gt;
** Add feedback by deriving longer paths from known paths.&lt;br /&gt;
** Simulate nondeterministic processor scheduling while keeping source input fixed.&lt;br /&gt;
&lt;br /&gt;
The shell examples draw from EDURange&#039;s institutional knowledge. The graph examples provide a common model problem family for meaningful use of feedback loops.&lt;br /&gt;
&lt;br /&gt;
== Running the examples ==&lt;br /&gt;
&lt;br /&gt;
Adjust paths to match the local checkout layout if necessary. Start an interpreter from the workspace root with project packages available.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
PYTHONPATH=&amp;quot;$PWD/mbus:$PWD/aostore:$PWD&amp;quot; python3.14&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
When module files are changed, restart the interpreter unless the group is deliberately practicing &amp;lt;code&amp;gt;importlib.reload()&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Create the demonstration package ==&lt;br /&gt;
&lt;br /&gt;
Before the first durable file edit, create the demonstration package directories:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir -p demos/event_processors&lt;br /&gt;
touch demos/__init__.py&lt;br /&gt;
touch demos/event_processors/__init__.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
== Important note about capture records ==&lt;br /&gt;
&lt;br /&gt;
The current demonstration works close to the low-level capture layer. As a result, it exposes &amp;lt;code&amp;gt;MessageSymbolRegistration&amp;lt;/code&amp;gt; records while building the temporary history helper.&lt;br /&gt;
&lt;br /&gt;
That is not the intended ordinary user experience. In a more complete release, higher-level capture and history infrastructure should handle symbol registration bookkeeping on behalf of processor authors. In this exercise, symbol registration records are visible because the demonstration history helper reconstructs readable messages from compact captured records directly.&lt;br /&gt;
&lt;br /&gt;
== Suggested group session flow ==&lt;br /&gt;
&lt;br /&gt;
; Session 1 - Bus and capture basics&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 1:&#039;&#039;&#039;&lt;br /&gt;
** Signal over the bus by hand.&lt;br /&gt;
** Inspect received messages.&lt;br /&gt;
** Inspect low-level captured records.&lt;br /&gt;
* &#039;&#039;&#039;Phase 2:&#039;&#039;&#039;&lt;br /&gt;
** Discuss why symbol registration appears in this temporary demo.&lt;br /&gt;
** Build the capture history helper.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3 hours (1-2 hours work, 0.5-1 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 2 - Shell commands and milestones&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 3:&#039;&#039;&#039;&lt;br /&gt;
** Represent a shell command as a dictionary.&lt;br /&gt;
* &#039;&#039;&#039;Phase 4:&#039;&#039;&#039;&lt;br /&gt;
** Replace it with &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Phase 5:&#039;&#039;&#039;&lt;br /&gt;
** Manually evaluate milestone expressions.&lt;br /&gt;
* &#039;&#039;&#039;Phase 6:&#039;&#039;&#039;&lt;br /&gt;
** Implement &amp;lt;code&amp;gt;RegexMilestoneProcessor&amp;lt;/code&amp;gt;.&lt;br /&gt;
** Inspect source and analysis events in history.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3 hours (1.5-2.5 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 3 - TTY-derived observations&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 7:&#039;&#039;&#039;&lt;br /&gt;
** Create raw TTY read, write, and line events.&lt;br /&gt;
** Show why milestones need reconstructed shell command events.&lt;br /&gt;
* &#039;&#039;&#039;Phase 8:&#039;&#039;&#039;&lt;br /&gt;
** Implement keystroke timing.&lt;br /&gt;
** Discuss shell command reconstruction as the next derived-event problem (to be continued in Session 6 onward).&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1-2.5 hours (1-2 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 4 - Graph-derived facts&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 9:&#039;&#039;&#039;&lt;br /&gt;
** Define graph edges and paths.&lt;br /&gt;
* &#039;&#039;&#039;Phase 10:&#039;&#039;&#039;&lt;br /&gt;
** Add the demo-only nonblocking receive helper.&lt;br /&gt;
* &#039;&#039;&#039;Phase 11:&#039;&#039;&#039;&lt;br /&gt;
** Implement direct path derivation.&lt;br /&gt;
* &#039;&#039;&#039;Phase 12:&#039;&#039;&#039;&lt;br /&gt;
** Explore the extension rule by hand.&lt;br /&gt;
* &#039;&#039;&#039;Phase 13:&#039;&#039;&#039;&lt;br /&gt;
** Add path extension feedback.&lt;br /&gt;
** Discuss duplicate prevention.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3.5 hours (1.5-3 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 5 - Nondeterministic processor scheduling&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 14:&#039;&#039;&#039;&lt;br /&gt;
** Add the randomized processor runner.&lt;br /&gt;
* &#039;&#039;&#039;Phase 15:&#039;&#039;&#039;&lt;br /&gt;
** Keep source events fixed.&lt;br /&gt;
** Vary processor scheduling by seed.&lt;br /&gt;
** Compare final path facts across runs.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3.5 hours (1.5-3 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 6 - Follow-up refinements&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 16:&#039;&#039;&#039;&lt;br /&gt;
** Rebuild a graph index from history.&lt;br /&gt;
* &#039;&#039;&#039;Phase 17:&#039;&#039;&#039;&lt;br /&gt;
** Implement shell command reconstruction.&lt;br /&gt;
** Replace local Python payload assumptions with explicit payload projection formats when the bus format layer is ready for the exercise.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-4.5 hours (1.5-4 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
= Event Processor Demonstration Plan =&lt;br /&gt;
&lt;br /&gt;
== Phase 1: Signal over the bus by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Send one message through the direct bus and inspect what the receiver sees.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* A bus is useful only when something emits and something receives.&lt;br /&gt;
* Public message setup uses readable strings: topic, producer, and message type.&lt;br /&gt;
* The direct broker delivers local Python payloads in this demonstration.&lt;br /&gt;
* Capture must be attached before delivery in the current prototype.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Import the bus and capture interfaces:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.capture.sink import CaptureSink&lt;br /&gt;
from mbus.message.records import CapturedMessage&lt;br /&gt;
from mbus.message.symbols import MessageSymbolRegistration&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create a temporary capture sink in the interpreter. This sink stores whatever the bus sends to the capture layer:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
CapturedRecord = MessageSymbolRegistration | CapturedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class ListCaptureSink(CaptureSink):&lt;br /&gt;
    records: list[CapturedRecord] = field(default_factory=list)&lt;br /&gt;
&lt;br /&gt;
    def capture_symbol_registration(&lt;br /&gt;
        self,&lt;br /&gt;
        registration: MessageSymbolRegistration,&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self.records.append(registration)&lt;br /&gt;
&lt;br /&gt;
    def capture(self, message: CapturedMessage) -&amp;gt; None:&lt;br /&gt;
        self.records.append(message)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create the bus and attach capture:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = ListCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the empty capture sink:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
sink&lt;br /&gt;
sink.records&lt;br /&gt;
len(sink.records)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ListCaptureSink(records=[])&lt;br /&gt;
[]&lt;br /&gt;
0&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create a receiver. This describes the messages the receiver wants to hear:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the receiver and confirm that capture is still empty:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
type(receiver)&lt;br /&gt;
vars(receiver)&lt;br /&gt;
sink.records&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;class &#039;mbus.broker.direct._BrokerReceiver&#039;&amp;gt;&lt;br /&gt;
{&#039;_queue&#039;: ...}&lt;br /&gt;
[]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create an emitter. Registering the emitter gives the bus enough information to bind readable names to compact internal IDs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the captured registration records:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
sink.records&lt;br /&gt;
[type(record).__name__ for record in sink.records]&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    print(record)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[&#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;]&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;demo.signals&#039; ...)&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;ping&#039; ...)&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;demo-source&#039; ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit one message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;hello bus&amp;quot;})&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect capture before receiving:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
len(sink.records)&lt;br /&gt;
[type(record).__name__ for record in sink.records]&lt;br /&gt;
sink.records[-1]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
4&lt;br /&gt;
[&#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;CapturedMessage&#039;]&lt;br /&gt;
CapturedMessage(... payload={&#039;text&#039;: &#039;hello bus&#039;} ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
message = receiver.receive()&lt;br /&gt;
&lt;br /&gt;
message&lt;br /&gt;
message.msg_topic&lt;br /&gt;
message.msg_producer&lt;br /&gt;
message.msg_type&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ReceivedMessage(...)&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&#039;demo-source&#039;&lt;br /&gt;
&#039;ping&#039;&lt;br /&gt;
{&#039;text&#039;: &#039;hello bus&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the received message with the captured record:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured = sink.records[-1]&lt;br /&gt;
&lt;br /&gt;
captured&lt;br /&gt;
captured.msg_topic_id&lt;br /&gt;
captured.msg_type_id&lt;br /&gt;
captured.msg_producer_id&lt;br /&gt;
captured.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
CapturedMessage(...)&lt;br /&gt;
TopicID(...)&lt;br /&gt;
MessageTypeID(...)&lt;br /&gt;
ProducerID(...)&lt;br /&gt;
{&#039;text&#039;: &#039;hello bus&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The receiver gets the readable message view. The capture sink sees compact IDs and local payload data. The readable names are still available through symbol registration records, but manually reconstructing them is awkward.&lt;br /&gt;
&lt;br /&gt;
That awkwardness motivates the next phase.&lt;br /&gt;
&lt;br /&gt;
== Phase 2: Build a temporary capture history helper ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a small demonstration helper that turns captured records back into readable history messages.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Capture records are useful, but raw captured messages use compact IDs.&lt;br /&gt;
* A history helper can rebuild readable message history from registration records.&lt;br /&gt;
* This is demonstration infrastructure, not the final persistence/query API.&lt;br /&gt;
* The helper writes captured records into an in-memory &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; sequential unit.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration before the file edit&lt;br /&gt;
&lt;br /&gt;
List only the captured messages:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured_messages = []&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    if isinstance(record, CapturedMessage):&lt;br /&gt;
        captured_messages.append(record)&lt;br /&gt;
&lt;br /&gt;
captured_messages&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[CapturedMessage(... payload={&#039;text&#039;: &#039;hello bus&#039;} ...)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first captured message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured_message = captured_messages[0]&lt;br /&gt;
&lt;br /&gt;
captured_message.msg_topic_id&lt;br /&gt;
captured_message.msg_type_id&lt;br /&gt;
captured_message.msg_producer_id&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
TopicID(...)&lt;br /&gt;
MessageTypeID(...)&lt;br /&gt;
ProducerID(...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Manually reconstruct a topic name:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
topic_names = {}&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    if isinstance(record, MessageSymbolRegistration):&lt;br /&gt;
        if record.symbol_kind.name == &amp;quot;TOPIC&amp;quot;:&lt;br /&gt;
            topic_names[record.symbol_id] = record.symbol&lt;br /&gt;
&lt;br /&gt;
topic_names&lt;br /&gt;
topic_names[captured_message.msg_topic_id]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
{TopicID(...): &#039;demo.signals&#039;}&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This works, but repeating it by hand would distract from writing event processors.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/capture_history.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/capture_history.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Capture and history helpers for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from collections.abc import Iterable&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
&lt;br /&gt;
from aostore.sequentialunit.listsequentialunit import ListSequentialUnit&lt;br /&gt;
from mbus.capture.sink import CaptureSink&lt;br /&gt;
from mbus.message.records import CapturedMessage&lt;br /&gt;
from mbus.message.symbols import (&lt;br /&gt;
    MessageSymbolKind,&lt;br /&gt;
    MessageSymbolRegistration,&lt;br /&gt;
    MessageTypeID,&lt;br /&gt;
    ProducerID,&lt;br /&gt;
    TopicID,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
CapturedRecord = MessageSymbolRegistration | CapturedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class AOStoreCaptureSink(CaptureSink):&lt;br /&gt;
    records: ListSequentialUnit[CapturedRecord] = field(&lt;br /&gt;
        default_factory=ListSequentialUnit&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    def capture_symbol_registration(&lt;br /&gt;
        self,&lt;br /&gt;
        registration: MessageSymbolRegistration,&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self.records.append(registration)&lt;br /&gt;
&lt;br /&gt;
    def capture(self, message: CapturedMessage) -&amp;gt; None:&lt;br /&gt;
        self.records.append(message)&lt;br /&gt;
&lt;br /&gt;
    def all_records(self) -&amp;gt; tuple[CapturedRecord, ...]:&lt;br /&gt;
        records = self.records.sequential_read(0, len(self.records))&lt;br /&gt;
        result = tuple(records)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class HistoryMessage:&lt;br /&gt;
    msg_topic: str&lt;br /&gt;
    msg_type: str&lt;br /&gt;
    msg_producer: str&lt;br /&gt;
    payload: object&lt;br /&gt;
    bus_sequence: int&lt;br /&gt;
    topic_sequence: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class CaptureHistory:&lt;br /&gt;
    _records: tuple[CapturedRecord, ...]&lt;br /&gt;
    _topics: dict[TopicID, str]&lt;br /&gt;
    _types: dict[MessageTypeID, str]&lt;br /&gt;
    _producers: dict[ProducerID, str]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, records: Iterable[CapturedRecord]) -&amp;gt; None:&lt;br /&gt;
        self._records = tuple(records)&lt;br /&gt;
        self._topics = {}&lt;br /&gt;
        self._types = {}&lt;br /&gt;
        self._producers = {}&lt;br /&gt;
        self._index_symbols()&lt;br /&gt;
&lt;br /&gt;
    def _index_symbols(self) -&amp;gt; None:&lt;br /&gt;
        for record in self._records:&lt;br /&gt;
            if isinstance(record, MessageSymbolRegistration):&lt;br /&gt;
                self._record_symbol(record)&lt;br /&gt;
&lt;br /&gt;
    def _record_symbol(self, record: MessageSymbolRegistration) -&amp;gt; None:&lt;br /&gt;
        if record.symbol_kind is MessageSymbolKind.TOPIC:&lt;br /&gt;
            self._topics[record.symbol_id] = record.symbol&lt;br /&gt;
        elif record.symbol_kind is MessageSymbolKind.MSG_TYPE:&lt;br /&gt;
            self._types[record.symbol_id] = record.symbol&lt;br /&gt;
        elif record.symbol_kind is MessageSymbolKind.PRODUCER:&lt;br /&gt;
            self._producers[record.symbol_id] = record.symbol&lt;br /&gt;
&lt;br /&gt;
    def messages(self) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for record in self._records:&lt;br /&gt;
            if isinstance(record, CapturedMessage):&lt;br /&gt;
                message = self._decode_message(record)&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def by_topic(self, msg_topic: str) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for message in self.messages():&lt;br /&gt;
            if message.msg_topic == msg_topic:&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def since(self, bus_sequence: int) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for message in self.messages():&lt;br /&gt;
            if message.bus_sequence &amp;gt;= bus_sequence:&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def _decode_message(self, message: CapturedMessage) -&amp;gt; HistoryMessage:&lt;br /&gt;
        history_message = HistoryMessage(&lt;br /&gt;
            msg_topic=self._topics[message.msg_topic_id],&lt;br /&gt;
            msg_type=self._types[message.msg_type_id],&lt;br /&gt;
            msg_producer=self._producers[message.msg_producer_id],&lt;br /&gt;
            payload=message.payload,&lt;br /&gt;
            bus_sequence=message.bus_sequence,&lt;br /&gt;
            topic_sequence=message.topic_sequence,&lt;br /&gt;
        )&lt;br /&gt;
        return history_message&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;history helper&amp;quot;})&lt;br /&gt;
message = receiver.receive()&lt;br /&gt;
&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
history.messages()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[HistoryMessage(msg_topic=&#039;demo.signals&#039;, msg_type=&#039;ping&#039;, msg_producer=&#039;demo-source&#039;, payload={&#039;text&#039;: &#039;history helper&#039;}, ...)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the decoded history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history_message = history.messages()[0]&lt;br /&gt;
&lt;br /&gt;
history_message.msg_topic&lt;br /&gt;
history_message.msg_type&lt;br /&gt;
history_message.msg_producer&lt;br /&gt;
history_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&#039;ping&#039;&lt;br /&gt;
&#039;demo-source&#039;&lt;br /&gt;
{&#039;text&#039;: &#039;history helper&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 3: Explore shell command data by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Identify the high-level event shape needed by a milestone processor.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The old milestone strategy operated on command-level records.&lt;br /&gt;
* A command-level record includes context such as host and working directory.&lt;br /&gt;
* It also includes shell input and shell output.&lt;br /&gt;
* Raw TTY events do not directly have this shape; reconstruction comes later.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Represent a shell command as a dictionary:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = {&lt;br /&gt;
    &amp;quot;session_id&amp;quot;: &amp;quot;session-1&amp;quot;,&lt;br /&gt;
    &amp;quot;command_index&amp;quot;: 0,&lt;br /&gt;
    &amp;quot;host&amp;quot;: &amp;quot;alpha&amp;quot;,&lt;br /&gt;
    &amp;quot;cwd&amp;quot;: &amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    &amp;quot;input_text&amp;quot;: &amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    &amp;quot;output_text&amp;quot;: &amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    &amp;quot;started_at_ns&amp;quot;: 1000,&lt;br /&gt;
    &amp;quot;ended_at_ns&amp;quot;: 2000,&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the fields used by milestone checks:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command[&amp;quot;host&amp;quot;]&lt;br /&gt;
command[&amp;quot;cwd&amp;quot;]&lt;br /&gt;
command[&amp;quot;input_text&amp;quot;]&lt;br /&gt;
command[&amp;quot;output_text&amp;quot;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;alpha&#039;&lt;br /&gt;
&#039;/home/student&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Try an accidental wrong key:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command[&amp;quot;working_directory&amp;quot;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeyError: &#039;working_directory&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The dictionary contains the information, but it does not document the shape of the event. A typo is discovered only when the field is used. A named event class makes the processor code easier to read and easier to explain.&lt;br /&gt;
&lt;br /&gt;
== Phase 4: Persist the event record classes ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create reusable payload classes for the TTY, shell command, and milestone examples.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/events.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/events.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Payload records for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from dataclasses import dataclass&lt;br /&gt;
from typing import Literal&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class TTYReadEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    data: bytes&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class TTYWriteEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    data: bytes&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class LineDisciplineEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    line: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class KeystrokeTimingEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    previous_timestamp_ns: int | None&lt;br /&gt;
    delta_ns: int | None&lt;br /&gt;
    text: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class ShellCommandEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    command_index: int&lt;br /&gt;
    host: str&lt;br /&gt;
    cwd: str&lt;br /&gt;
    input_text: str&lt;br /&gt;
    output_text: str&lt;br /&gt;
    started_at_ns: int&lt;br /&gt;
    ended_at_ns: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
RegexMilestoneField = Literal[&lt;br /&gt;
    &amp;quot;host&amp;quot;,&lt;br /&gt;
    &amp;quot;cwd&amp;quot;,&lt;br /&gt;
    &amp;quot;input_text&amp;quot;,&lt;br /&gt;
    &amp;quot;output_text&amp;quot;,&lt;br /&gt;
]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneExpression:&lt;br /&gt;
    name: str&lt;br /&gt;
    field: RegexMilestoneField&lt;br /&gt;
    pattern: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneResult:&lt;br /&gt;
    expression_name: str&lt;br /&gt;
    field: RegexMilestoneField&lt;br /&gt;
    matched: bool&lt;br /&gt;
    match_text: str | None = None&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneAnalysisEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    command_index: int&lt;br /&gt;
    results: tuple[RegexMilestoneResult, ...]&lt;br /&gt;
    matched_count: int&lt;br /&gt;
    expression_count: int&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.events import ShellCommandEvent&lt;br /&gt;
&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
command&lt;br /&gt;
command.host&lt;br /&gt;
command.cwd&lt;br /&gt;
command.input_text&lt;br /&gt;
command.output_text&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ShellCommandEvent(session_id=&#039;session-1&#039;, command_index=0, ...)&lt;br /&gt;
&#039;alpha&#039;&lt;br /&gt;
&#039;/home/student&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The event shape is now explicit. Other code can depend on a named &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; instead of assuming that a loose dictionary has the expected fields.&lt;br /&gt;
&lt;br /&gt;
== Phase 5: Evaluate milestones manually ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Recreate the core milestone check in a small, transparent form.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* A milestone expression names one check against one shell command field.&lt;br /&gt;
* The field may be command input, command output, working directory, or host context.&lt;br /&gt;
* The result records whether the expression matched and, when available, the matched text.&lt;br /&gt;
* Manual evaluation is useful for learning the shape of the problem before writing a processor.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Import regular expressions and the payload classes:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create one command event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create three milestone expressions:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
expressions = (&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;used-ls&amp;quot;,&lt;br /&gt;
        field=&amp;quot;input_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;\bls\b&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;saw-notes&amp;quot;,&lt;br /&gt;
        field=&amp;quot;output_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;notes\.txt&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;home-directory&amp;quot;,&lt;br /&gt;
        field=&amp;quot;cwd&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;^/home/student$&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Evaluate one expression:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
expression = expressions[0]&lt;br /&gt;
text = getattr(command, expression.field)&lt;br /&gt;
match = re.search(expression.pattern, text)&lt;br /&gt;
&lt;br /&gt;
expression.name&lt;br /&gt;
expression.field&lt;br /&gt;
text&lt;br /&gt;
match.group(0)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;used-ls&#039;&lt;br /&gt;
&#039;input_text&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;ls&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Evaluate all expressions:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
manual_results = []&lt;br /&gt;
&lt;br /&gt;
for expression in expressions:&lt;br /&gt;
    text = getattr(command, expression.field)&lt;br /&gt;
    match = re.search(expression.pattern, text)&lt;br /&gt;
    manual_results.append((expression.name, match is not None))&lt;br /&gt;
&lt;br /&gt;
manual_results&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(&#039;used-ls&#039;, True), (&#039;saw-notes&#039;, True), (&#039;home-directory&#039;, True)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
Manual evaluation is simple for one command, but the loop is already event processor logic. The next phase preserves the loop in a reusable processor.&lt;br /&gt;
&lt;br /&gt;
== Phase 6: Persist the RegexMilestone processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a processor that receives shell command events and emits regex milestone analysis events.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/regex_milestones.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/regex_milestones.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Regex milestone processor for shell command events.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneAnalysisEvent,&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    RegexMilestoneResult,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class RegexMilestoneProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _expressions: tuple[RegexMilestoneExpression, ...]&lt;br /&gt;
&lt;br /&gt;
    def __init__(&lt;br /&gt;
        self,&lt;br /&gt;
        bus: DirectMessageBus,&lt;br /&gt;
        expressions: tuple[RegexMilestoneExpression, ...],&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.shell.command&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;shell-reconstructor&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;shell-command&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.shell.regex_milestone&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;regex-milestone-processor&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;regex-milestone-analysis&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._expressions = expressions&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; RegexMilestoneAnalysisEvent:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        command = message.payload&lt;br /&gt;
&lt;br /&gt;
        if not isinstance(command, ShellCommandEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected ShellCommandEvent, got {type(command).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        analysis = analyze_regex_milestones(command, self._expressions)&lt;br /&gt;
        self._emitter.emit(analysis)&lt;br /&gt;
        return analysis&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def analyze_regex_milestones(&lt;br /&gt;
    command: ShellCommandEvent,&lt;br /&gt;
    expressions: tuple[RegexMilestoneExpression, ...],&lt;br /&gt;
) -&amp;gt; RegexMilestoneAnalysisEvent:&lt;br /&gt;
    results = []&lt;br /&gt;
    for expression in expressions:&lt;br /&gt;
        result = evaluate_regex_milestone(command, expression)&lt;br /&gt;
        results.append(result)&lt;br /&gt;
&lt;br /&gt;
    matched_count = sum(result.matched for result in results)&lt;br /&gt;
    analysis = RegexMilestoneAnalysisEvent(&lt;br /&gt;
        session_id=command.session_id,&lt;br /&gt;
        command_index=command.command_index,&lt;br /&gt;
        results=tuple(results),&lt;br /&gt;
        matched_count=matched_count,&lt;br /&gt;
        expression_count=len(expressions),&lt;br /&gt;
    )&lt;br /&gt;
    return analysis&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def evaluate_regex_milestone(&lt;br /&gt;
    command: ShellCommandEvent,&lt;br /&gt;
    expression: RegexMilestoneExpression,&lt;br /&gt;
) -&amp;gt; RegexMilestoneResult:&lt;br /&gt;
    text = getattr(command, expression.field)&lt;br /&gt;
    match = re.search(expression.pattern, text)&lt;br /&gt;
    match_text = None&lt;br /&gt;
&lt;br /&gt;
    if match is not None:&lt;br /&gt;
        match_text = match.group(0)&lt;br /&gt;
&lt;br /&gt;
    result = RegexMilestoneResult(&lt;br /&gt;
        expression_name=expression.name,&lt;br /&gt;
        field=expression.field,&lt;br /&gt;
        matched=match is not None,&lt;br /&gt;
        match_text=match_text,&lt;br /&gt;
    )&lt;br /&gt;
    return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then set up the bus:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.regex_milestones import RegexMilestoneProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Subscribe to the analysis topic before running the processor:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.shell.regex_milestone&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;regex-milestone-processor&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;regex-milestone-analysis&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create the shell command emitter and processor:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.shell.command&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;shell-reconstructor&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;shell-command&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
expressions = (&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;used-ls&amp;quot;,&lt;br /&gt;
        field=&amp;quot;input_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;\bls\b&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;saw-notes&amp;quot;,&lt;br /&gt;
        field=&amp;quot;output_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;notes\.txt&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;home-directory&amp;quot;,&lt;br /&gt;
        field=&amp;quot;cwd&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;^/home/student$&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = RegexMilestoneProcessor(bus, expressions)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit a shell command event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
command_emitter.emit(command)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the command:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis = processor.process_one()&lt;br /&gt;
analysis&lt;br /&gt;
analysis.results&lt;br /&gt;
analysis.matched_count&lt;br /&gt;
analysis.expression_count&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
RegexMilestoneAnalysisEvent(... matched_count=3, expression_count=3)&lt;br /&gt;
(RegexMilestoneResult(...), RegexMilestoneResult(...), RegexMilestoneResult(...))&lt;br /&gt;
3&lt;br /&gt;
3&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the derived analysis event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis_message = analysis_receiver.receive()&lt;br /&gt;
&lt;br /&gt;
analysis_message.msg_topic&lt;br /&gt;
analysis_message.msg_type&lt;br /&gt;
analysis_message.msg_producer&lt;br /&gt;
analysis_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;demo.shell.regex_milestone&#039;&lt;br /&gt;
&#039;regex-milestone-analysis&#039;&lt;br /&gt;
&#039;regex-milestone-processor&#039;&lt;br /&gt;
RegexMilestoneAnalysisEvent(... matched_count=3, expression_count=3)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
messages = history.messages()&lt;br /&gt;
&lt;br /&gt;
for message in messages:&lt;br /&gt;
    print(message.bus_sequence, message.msg_topic, message.msg_type)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
0 demo.shell.command shell-command&lt;br /&gt;
1 demo.shell.regex_milestone regex-milestone-analysis&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The regex milestone processor consumes one event and emits another. The derived analysis event is available to any later subscriber. History contains both the source command event and the derived analysis event.&lt;br /&gt;
&lt;br /&gt;
== Phase 7: Explore why shell reconstruction is separate ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Show that raw TTY observations are not the same thing as high-level shell command events.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Regex milestones should inspect high-level command events.&lt;br /&gt;
* TTY reads, TTY writes, and line-discipline lines are lower-level observations.&lt;br /&gt;
* A shell command event is reconstructed by correlating those observations.&lt;br /&gt;
* Keeping reconstruction separate prevents the milestone processor from becoming a large TTY parser.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Create a few low-level observations:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    LineDisciplineEvent,&lt;br /&gt;
    TTYReadEvent,&lt;br /&gt;
    TTYWriteEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_1 = TTYReadEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=0,&lt;br /&gt;
    timestamp_ns=1000,&lt;br /&gt;
    data=b&amp;quot;l&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_2 = TTYReadEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=1,&lt;br /&gt;
    timestamp_ns=1100,&lt;br /&gt;
    data=b&amp;quot;s&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
line = LineDisciplineEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=2,&lt;br /&gt;
    timestamp_ns=1200,&lt;br /&gt;
    line=&amp;quot;ls\n&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
write = TTYWriteEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=3,&lt;br /&gt;
    timestamp_ns=1600,&lt;br /&gt;
    data=b&amp;quot;notes.txt\n&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the raw pieces:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_1.data&lt;br /&gt;
read_2.data&lt;br /&gt;
line.line&lt;br /&gt;
write.data&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
b&#039;l&#039;&lt;br /&gt;
b&#039;s&#039;&lt;br /&gt;
&#039;ls\n&#039;&lt;br /&gt;
b&#039;notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Assemble command fields manually:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
input_text = line.line&lt;br /&gt;
output_text = write.data.decode(&amp;quot;utf-8&amp;quot;, errors=&amp;quot;replace&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
input_text&lt;br /&gt;
output_text&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;ls\n&#039;&lt;br /&gt;
&#039;notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The useful command event is derived from several lower-level observations. A later shell reconstructor can subscribe to line-discipline events, query recent reads/writes/timing observations, and emit &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; records.&lt;br /&gt;
&lt;br /&gt;
== Phase 8: Add keystroke timing as a smaller TTY-derived processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a simple stateful processor before attempting full shell reconstruction.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Timing requires comparing a TTY read with the previous read in the same session.&lt;br /&gt;
* A derived timing event makes the result explicit.&lt;br /&gt;
* Other processors can consume timing events instead of recalculating deltas.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration before the file edit&lt;br /&gt;
&lt;br /&gt;
Calculate timing manually from the two read events:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_events = [read_1, read_2]&lt;br /&gt;
previous_timestamp = None&lt;br /&gt;
timing_rows = []&lt;br /&gt;
&lt;br /&gt;
for event in read_events:&lt;br /&gt;
    delta = None&lt;br /&gt;
    if previous_timestamp is not None:&lt;br /&gt;
        delta = event.timestamp_ns - previous_timestamp&lt;br /&gt;
    timing_rows.append((event.source_index, previous_timestamp, delta))&lt;br /&gt;
    previous_timestamp = event.timestamp_ns&lt;br /&gt;
&lt;br /&gt;
timing_rows&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, None, None), (1, 1000, 100)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/keystroke_timing.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/keystroke_timing.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Keystroke timing processor for TTY read events.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    KeystrokeTimingEvent,&lt;br /&gt;
    TTYReadEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class KeystrokeTimingProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _last_timestamp_by_session: dict[str, int]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.tty.read&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;tty-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;tty-read&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.keystroke.timing&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._last_timestamp_by_session = {}&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; KeystrokeTimingEvent:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        event = message.payload&lt;br /&gt;
&lt;br /&gt;
        if not isinstance(event, TTYReadEvent):&lt;br /&gt;
            raise TypeError(f&amp;quot;expected TTYReadEvent, got {type(event).__name__}&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
        previous_timestamp = self._last_timestamp_by_session.get(&lt;br /&gt;
            event.session_id&lt;br /&gt;
        )&lt;br /&gt;
        delta = None&lt;br /&gt;
&lt;br /&gt;
        if previous_timestamp is not None:&lt;br /&gt;
            delta = event.timestamp_ns - previous_timestamp&lt;br /&gt;
&lt;br /&gt;
        self._last_timestamp_by_session[event.session_id] = event.timestamp_ns&lt;br /&gt;
&lt;br /&gt;
        text = event.data.decode(&amp;quot;utf-8&amp;quot;, errors=&amp;quot;replace&amp;quot;)&lt;br /&gt;
        timing = KeystrokeTimingEvent(&lt;br /&gt;
            session_id=event.session_id,&lt;br /&gt;
            source_index=event.source_index,&lt;br /&gt;
            timestamp_ns=event.timestamp_ns,&lt;br /&gt;
            previous_timestamp_ns=previous_timestamp,&lt;br /&gt;
            delta_ns=delta,&lt;br /&gt;
            text=text,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter.emit(timing)&lt;br /&gt;
        return timing&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
from demos.event_processors.events import TTYReadEvent&lt;br /&gt;
from demos.event_processors.keystroke_timing import KeystrokeTimingProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
timing_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.keystroke.timing&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.tty.read&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;tty-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;tty-read&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = KeystrokeTimingProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit reads and process them:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_emitter.emit(&lt;br /&gt;
    TTYReadEvent(&lt;br /&gt;
        session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
        source_index=0,&lt;br /&gt;
        timestamp_ns=1000,&lt;br /&gt;
        data=b&amp;quot;l&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_emitter.emit(&lt;br /&gt;
    TTYReadEvent(&lt;br /&gt;
        session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
        source_index=1,&lt;br /&gt;
        timestamp_ns=1100,&lt;br /&gt;
        data=b&amp;quot;s&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor.process_one()&lt;br /&gt;
processor.process_one()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeystrokeTimingEvent(... previous_timestamp_ns=None, delta_ns=None, text=&#039;l&#039;)&lt;br /&gt;
KeystrokeTimingEvent(... previous_timestamp_ns=1000, delta_ns=100, text=&#039;s&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive timing events:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
timing_receiver.receive().payload&lt;br /&gt;
timing_receiver.receive().payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeystrokeTimingEvent(... source_index=0 ...)&lt;br /&gt;
KeystrokeTimingEvent(... source_index=1 ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 9: Introduce graph events ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Switch to a small graph problem that can demonstrate safe feedback loops in an illustratable form.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* An edge is a source fact.&lt;br /&gt;
* A path is a derived fact.&lt;br /&gt;
* A direct edge implies a direct path.&lt;br /&gt;
* Longer paths can be derived later from known paths and edges.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_events.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/graph_events.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Graph event payloads for message bus demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from dataclasses import dataclass&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class EdgeDeclaredEvent:&lt;br /&gt;
    graph_id: str&lt;br /&gt;
    source: str&lt;br /&gt;
    target: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class PathKnownEvent:&lt;br /&gt;
    graph_id: str&lt;br /&gt;
    source: str&lt;br /&gt;
    target: str&lt;br /&gt;
    hop_count: int&lt;br /&gt;
&lt;br /&gt;
    def fact_key(self) -&amp;gt; tuple[str, str, str]:&lt;br /&gt;
        result = (self.graph_id, self.source, self.target)&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge = EdgeDeclaredEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;A&amp;quot;,&lt;br /&gt;
    target=&amp;quot;B&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path = PathKnownEvent(&lt;br /&gt;
    graph_id=edge.graph_id,&lt;br /&gt;
    source=edge.source,&lt;br /&gt;
    target=edge.target,&lt;br /&gt;
    hop_count=1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge&lt;br /&gt;
path&lt;br /&gt;
path.fact_key()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
EdgeDeclaredEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;)&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;, hop_count=1)&lt;br /&gt;
(&#039;demo-graph&#039;, &#039;A&#039;, &#039;B&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 10: Create a demo-only nonblocking receive helper ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Prepare for processor scheduling without editing &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;Receiver.receive()&amp;lt;/code&amp;gt; blocks until a message is available.&lt;br /&gt;
* A scheduler needs to try a processor and move on if no message is waiting.&lt;br /&gt;
* The current package does not expose a public nonblocking receiver method.&lt;br /&gt;
* This helper is local demonstration code. It can be replaced by a real public API later.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/demo_receive.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/demo_receive.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Demo-only helpers for receiver inspection and scheduling.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from queue import Empty&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.endpoints import Receiver&lt;br /&gt;
from mbus.message.records import ReceivedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class DemoReceiverError(RuntimeError):&lt;br /&gt;
    pass&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def try_receive(receiver: Receiver) -&amp;gt; ReceivedMessage | None:&lt;br /&gt;
    queue = getattr(receiver, &amp;quot;_queue&amp;quot;, None)&lt;br /&gt;
&lt;br /&gt;
    if queue is None:&lt;br /&gt;
        raise DemoReceiverError(&lt;br /&gt;
            &amp;quot;demo try_receive requires the current direct broker receiver&amp;quot;&lt;br /&gt;
        )&lt;br /&gt;
&lt;br /&gt;
    try:&lt;br /&gt;
        message = queue.get_nowait()&lt;br /&gt;
    except Empty:&lt;br /&gt;
        message = None&lt;br /&gt;
&lt;br /&gt;
    return message&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.demo_receive import try_receive&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
try_receive(receiver)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
None&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Now emit a message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;demo nonblocking receive&amp;quot;})&lt;br /&gt;
message = try_receive(receiver)&lt;br /&gt;
&lt;br /&gt;
message&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ReceivedMessage(...)&lt;br /&gt;
{&#039;text&#039;: &#039;demo nonblocking receive&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 11: Create the direct path processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Implement the graph rule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
edge(A, B) -&amp;gt; path(A, B)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The source emits edge events.&lt;br /&gt;
* The direct path processor emits derived path events.&lt;br /&gt;
* Other processors can subscribe to derived path events.&lt;br /&gt;
* The processor can be run by hand or by a scheduler.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_reachability.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/graph_reachability.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Graph reachability processors for message bus demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.demo_receive import try_receive&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class DirectPathProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _known_paths: set[tuple[str, str, str]]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._known_paths = set()&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; PathKnownEvent | None:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        result = self._process_payload(message.payload)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
    def process_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            self._process_payload(message.payload)&lt;br /&gt;
            work_count = 1&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def _process_payload(self, payload: object) -&amp;gt; PathKnownEvent | None:&lt;br /&gt;
        if not isinstance(payload, EdgeDeclaredEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected EdgeDeclaredEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        path = PathKnownEvent(&lt;br /&gt;
            graph_id=payload.graph_id,&lt;br /&gt;
            source=payload.source,&lt;br /&gt;
            target=payload.target,&lt;br /&gt;
            hop_count=1,&lt;br /&gt;
        )&lt;br /&gt;
&lt;br /&gt;
        if path.fact_key() not in self._known_paths:&lt;br /&gt;
            self._known_paths.add(path.fact_key())&lt;br /&gt;
            self._emitter.emit(path)&lt;br /&gt;
            result = path&lt;br /&gt;
        else:&lt;br /&gt;
            result = None&lt;br /&gt;
&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
from demos.event_processors.graph_events import EdgeDeclaredEvent&lt;br /&gt;
from demos.event_processors.graph_reachability import DirectPathProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
path_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = DirectPathProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Call the processor before any edge exists:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
0&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit one edge:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;A&amp;quot;,&lt;br /&gt;
        target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the edge:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the derived path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_message = path_receiver.receive()&lt;br /&gt;
path_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;, hop_count=1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 12: Explore the path extension rule by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Understand the feedback rule before implementing it.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* If &amp;lt;code&amp;gt;A&amp;lt;/code&amp;gt; reaches &amp;lt;code&amp;gt;B&amp;lt;/code&amp;gt;, and there is an edge from &amp;lt;code&amp;gt;B&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;C&amp;lt;/code&amp;gt;, then &amp;lt;code&amp;gt;A&amp;lt;/code&amp;gt; reaches &amp;lt;code&amp;gt;C&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Derived path facts can lead to more derived path facts.&lt;br /&gt;
* Duplicate prevention keeps the feedback loop from repeating the same fact forever.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_bc = EdgeDeclaredEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;B&amp;quot;,&lt;br /&gt;
    target=&amp;quot;C&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path_ab = PathKnownEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;A&amp;quot;,&lt;br /&gt;
    target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    hop_count=1,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Check whether the path and edge connect:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_ab.target&lt;br /&gt;
edge_bc.source&lt;br /&gt;
path_ab.target == edge_bc.source&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;B&#039;&lt;br /&gt;
&#039;B&#039;&lt;br /&gt;
True&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Derive a new path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_ac = PathKnownEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=path_ab.source,&lt;br /&gt;
    target=edge_bc.target,&lt;br /&gt;
    hop_count=path_ab.hop_count + 1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path_ac&lt;br /&gt;
path_ac.fact_key()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;C&#039;, hop_count=2)&lt;br /&gt;
(&#039;demo-graph&#039;, &#039;A&#039;, &#039;C&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Manually prevent duplicate path facts:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
known_paths = set()&lt;br /&gt;
known_paths.add(path_ab.fact_key())&lt;br /&gt;
&lt;br /&gt;
path_ac.fact_key() in known_paths&lt;br /&gt;
&lt;br /&gt;
if path_ac.fact_key() not in known_paths:&lt;br /&gt;
    known_paths.add(path_ac.fact_key())&lt;br /&gt;
&lt;br /&gt;
known_paths&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
False&lt;br /&gt;
{(&#039;demo-graph&#039;, &#039;A&#039;, &#039;B&#039;), (&#039;demo-graph&#039;, &#039;A&#039;, &#039;C&#039;)}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 13: Add the path extension processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Implement the feedback rule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
path(A, B) + edge(B, C) -&amp;gt; path(A, C)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The extension processor listens to edge events and path events.&lt;br /&gt;
* It emits new path events when a known path can be extended.&lt;br /&gt;
* It also listens to its own output because an extended path may be extendable again.&lt;br /&gt;
* This is a controlled feedback loop.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Append this class to:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_reachability.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
class PathExtensionProcessor:&lt;br /&gt;
    _edge_receiver: Receiver&lt;br /&gt;
    _direct_path_receiver: Receiver&lt;br /&gt;
    _extension_path_receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _edges: set[tuple[str, str, str]]&lt;br /&gt;
    _paths: dict[tuple[str, str, str], PathKnownEvent]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._edge_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._direct_path_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._extension_path_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._edges = set()&lt;br /&gt;
        self._paths = {}&lt;br /&gt;
&lt;br /&gt;
    def process_edge_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._edge_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_edge_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def process_direct_path_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._direct_path_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_path_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def process_extension_path_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._extension_path_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_path_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def _process_edge_payload(&lt;br /&gt;
        self,&lt;br /&gt;
        payload: object,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        if not isinstance(payload, EdgeDeclaredEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected EdgeDeclaredEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        edge = payload&lt;br /&gt;
        self._edges.add((edge.graph_id, edge.source, edge.target))&lt;br /&gt;
        paths = self._derive_from_new_edge(edge)&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _process_path_payload(&lt;br /&gt;
        self,&lt;br /&gt;
        payload: object,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        if not isinstance(payload, PathKnownEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected PathKnownEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        path = payload&lt;br /&gt;
        self._paths[path.fact_key()] = path&lt;br /&gt;
        paths = self._derive_from_new_path(path)&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _derive_from_new_edge(&lt;br /&gt;
        self,&lt;br /&gt;
        edge: EdgeDeclaredEvent,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        paths = []&lt;br /&gt;
&lt;br /&gt;
        for known_path in self._paths.values():&lt;br /&gt;
            if known_path.graph_id != edge.graph_id:&lt;br /&gt;
                continue&lt;br /&gt;
            if known_path.target != edge.source:&lt;br /&gt;
                continue&lt;br /&gt;
&lt;br /&gt;
            next_path = PathKnownEvent(&lt;br /&gt;
                graph_id=edge.graph_id,&lt;br /&gt;
                source=known_path.source,&lt;br /&gt;
                target=edge.target,&lt;br /&gt;
                hop_count=known_path.hop_count + 1,&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
            if self._emit_if_new(next_path):&lt;br /&gt;
                paths.append(next_path)&lt;br /&gt;
&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _derive_from_new_path(&lt;br /&gt;
        self,&lt;br /&gt;
        path: PathKnownEvent,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        paths = []&lt;br /&gt;
&lt;br /&gt;
        for graph_id, edge_source, edge_target in self._edges:&lt;br /&gt;
            if graph_id != path.graph_id:&lt;br /&gt;
                continue&lt;br /&gt;
            if edge_source != path.target:&lt;br /&gt;
                continue&lt;br /&gt;
&lt;br /&gt;
            next_path = PathKnownEvent(&lt;br /&gt;
                graph_id=path.graph_id,&lt;br /&gt;
                source=path.source,&lt;br /&gt;
                target=edge_target,&lt;br /&gt;
                hop_count=path.hop_count + 1,&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
            if self._emit_if_new(next_path):&lt;br /&gt;
                paths.append(next_path)&lt;br /&gt;
&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _emit_if_new(self, path: PathKnownEvent) -&amp;gt; bool:&lt;br /&gt;
        if path.fact_key() in self._paths:&lt;br /&gt;
            result = False&lt;br /&gt;
        else:&lt;br /&gt;
            self._paths[path.fact_key()] = path&lt;br /&gt;
            self._emitter.emit(path)&lt;br /&gt;
            result = True&lt;br /&gt;
&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_events import EdgeDeclaredEvent&lt;br /&gt;
from demos.event_processors.graph_reachability import (&lt;br /&gt;
    DirectPathProcessor,&lt;br /&gt;
    PathExtensionProcessor,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
extension_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
direct_processor = DirectPathProcessor(bus)&lt;br /&gt;
extension_processor = PathExtensionProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit two edges in fixed order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;A&amp;quot;,&lt;br /&gt;
        target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;B&amp;quot;,&lt;br /&gt;
        target=&amp;quot;C&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the direct paths:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
direct_processor.process_available()&lt;br /&gt;
direct_processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Let the extension processor learn the edges:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
extension_processor.process_edge_available()&lt;br /&gt;
extension_processor.process_edge_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Let the extension processor learn the direct paths:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
extension_processor.process_direct_path_available()&lt;br /&gt;
extension_processor.process_direct_path_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The second call may emit the extended &amp;lt;code&amp;gt;A -&amp;gt; C&amp;lt;/code&amp;gt; path after the processor has seen both direct paths and both edges.&lt;br /&gt;
&lt;br /&gt;
Receive the extended path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
message = extension_receiver.receive()&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;C&#039;, hop_count=2)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect captured graph path history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
path_messages = history.by_topic(&amp;quot;demo.graph.path&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
for message in path_messages:&lt;br /&gt;
    path = message.payload&lt;br /&gt;
    print(&lt;br /&gt;
        message.bus_sequence,&lt;br /&gt;
        message.msg_producer,&lt;br /&gt;
        path.source,&lt;br /&gt;
        path.target,&lt;br /&gt;
        path.hop_count,&lt;br /&gt;
    )&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
... direct-path A B 1&lt;br /&gt;
... direct-path B C 1&lt;br /&gt;
... path-extension A C 2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 14: Add a randomized processor scheduler ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Simulate concurrency effects without changing source input order.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The source will emit the same edge events in the same order every run.&lt;br /&gt;
* The scheduler varies which processor gets a chance to run first.&lt;br /&gt;
* This simulates uncertainty from processing time, scheduling priority, startup timing, or transmission delay.&lt;br /&gt;
* The graph example should converge to the same final path facts even when processor scheduling - and the order of intermediate derived processor events - differs.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/runners.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/runners.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Small processor runners for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from collections.abc import Callable&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
from random import Random&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class ProcessorStep:&lt;br /&gt;
    name: str&lt;br /&gt;
    run: Callable[[], int]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class RandomProcessorRunner:&lt;br /&gt;
    steps: tuple[ProcessorStep, ...]&lt;br /&gt;
    seed: int&lt;br /&gt;
    max_rounds: int = 30&lt;br /&gt;
    trace: list[tuple[int, str, int]] = field(default_factory=list)&lt;br /&gt;
&lt;br /&gt;
    def run_until_quiet(self) -&amp;gt; list[tuple[int, str, int]]:&lt;br /&gt;
        rng = Random(self.seed)&lt;br /&gt;
&lt;br /&gt;
        for round_index in range(self.max_rounds):&lt;br /&gt;
            work_count = self._run_round(rng, round_index)&lt;br /&gt;
&lt;br /&gt;
            if work_count == 0:&lt;br /&gt;
                break&lt;br /&gt;
&lt;br /&gt;
        result = list(self.trace)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
    def _run_round(self, rng: Random, round_index: int) -&amp;gt; int:&lt;br /&gt;
        steps = list(self.steps)&lt;br /&gt;
        rng.shuffle(steps)&lt;br /&gt;
        round_work_count = 0&lt;br /&gt;
&lt;br /&gt;
        for step in steps:&lt;br /&gt;
            step_work_count = step.run()&lt;br /&gt;
            self.trace.append((round_index, step.name, step_work_count))&lt;br /&gt;
            round_work_count += step_work_count&lt;br /&gt;
&lt;br /&gt;
        return round_work_count&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then inspect how a runner can shuffle placeholder steps:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.runners import (&lt;br /&gt;
    ProcessorStep,&lt;br /&gt;
    RandomProcessorRunner,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
calls = []&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def first_step() -&amp;gt; int:&lt;br /&gt;
    calls.append(&amp;quot;first&amp;quot;)&lt;br /&gt;
    return 0&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def second_step() -&amp;gt; int:&lt;br /&gt;
    calls.append(&amp;quot;second&amp;quot;)&lt;br /&gt;
    return 0&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
runner = RandomProcessorRunner(&lt;br /&gt;
    steps=(&lt;br /&gt;
        ProcessorStep(name=&amp;quot;first&amp;quot;, run=first_step),&lt;br /&gt;
        ProcessorStep(name=&amp;quot;second&amp;quot;, run=second_step),&lt;br /&gt;
    ),&lt;br /&gt;
    seed=1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
runner.run_until_quiet()&lt;br /&gt;
calls&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, &#039;...&#039;, 0), (0, &#039;...&#039;, 0)]&lt;br /&gt;
[&#039;...&#039;, &#039;...&#039;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 15: Run the graph example with randomized scheduling ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Run the same graph input under different processor schedules and compare the final path facts.&lt;br /&gt;
&lt;br /&gt;
; Interpreter setup&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_reachability import (&lt;br /&gt;
    DirectPathProcessor,&lt;br /&gt;
    PathExtensionProcessor,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.runners import (&lt;br /&gt;
    ProcessorStep,&lt;br /&gt;
    RandomProcessorRunner,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Define a helper function in the interpreter:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
def run_graph_demo(seed: int) -&amp;gt; tuple[&lt;br /&gt;
    list[tuple[int, str, int]],&lt;br /&gt;
    list[tuple[int, str, str, int]],&lt;br /&gt;
]:&lt;br /&gt;
    bus = DirectMessageBus()&lt;br /&gt;
    sink = AOStoreCaptureSink()&lt;br /&gt;
    bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
    edge_emitter = bus.register_emitter(&lt;br /&gt;
        msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
        msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
        default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    direct_processor = DirectPathProcessor(bus)&lt;br /&gt;
    extension_processor = PathExtensionProcessor(bus)&lt;br /&gt;
&lt;br /&gt;
    edges = (&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;A&amp;quot;,&lt;br /&gt;
            target=&amp;quot;B&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;B&amp;quot;,&lt;br /&gt;
            target=&amp;quot;C&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;C&amp;quot;,&lt;br /&gt;
            target=&amp;quot;D&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    for edge in edges:&lt;br /&gt;
        edge_emitter.emit(edge)&lt;br /&gt;
&lt;br /&gt;
    steps = (&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;direct path from edge&amp;quot;,&lt;br /&gt;
            run=direct_processor.process_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns edge&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_edge_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns direct path&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_direct_path_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns extension path&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_extension_path_available,&lt;br /&gt;
        ),&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    runner = RandomProcessorRunner(&lt;br /&gt;
        steps=steps,&lt;br /&gt;
        seed=seed,&lt;br /&gt;
    )&lt;br /&gt;
    trace = runner.run_until_quiet()&lt;br /&gt;
&lt;br /&gt;
    history = CaptureHistory(sink.all_records())&lt;br /&gt;
    path_facts = []&lt;br /&gt;
&lt;br /&gt;
    for message in history.by_topic(&amp;quot;demo.graph.path&amp;quot;):&lt;br /&gt;
        path = message.payload&lt;br /&gt;
        if isinstance(path, PathKnownEvent):&lt;br /&gt;
            fact = (&lt;br /&gt;
                message.bus_sequence,&lt;br /&gt;
                path.source,&lt;br /&gt;
                path.target,&lt;br /&gt;
                path.hop_count,&lt;br /&gt;
            )&lt;br /&gt;
            path_facts.append(fact)&lt;br /&gt;
&lt;br /&gt;
    result = (trace, path_facts)&lt;br /&gt;
    return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Run one schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_1, paths_1 = run_graph_demo(1)&lt;br /&gt;
&lt;br /&gt;
trace_1&lt;br /&gt;
paths_1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, &#039;...&#039;, ...), (0, &#039;...&#039;, ...), ...]&lt;br /&gt;
[(..., &#039;A&#039;, &#039;B&#039;, 1), (..., &#039;B&#039;, &#039;C&#039;, 1), (..., &#039;C&#039;, &#039;D&#039;, 1), ...]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Run another schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_2, paths_2 = run_graph_demo(2)&lt;br /&gt;
&lt;br /&gt;
trace_2&lt;br /&gt;
paths_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the schedules:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_1 == trace_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
False&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the final path facts without bus sequence numbers:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
final_paths_1 = {&lt;br /&gt;
    (source, target, hop_count)&lt;br /&gt;
    for _, source, target, hop_count in paths_1&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
final_paths_2 = {&lt;br /&gt;
    (source, target, hop_count)&lt;br /&gt;
    for _, source, target, hop_count in paths_2&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
final_paths_1&lt;br /&gt;
final_paths_2&lt;br /&gt;
final_paths_1 == final_paths_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
{(&#039;A&#039;, &#039;B&#039;, 1), (&#039;B&#039;, &#039;C&#039;, 1), (&#039;C&#039;, &#039;D&#039;, 1), (&#039;A&#039;, &#039;C&#039;, 2), (&#039;B&#039;, &#039;D&#039;, 2), (&#039;A&#039;, &#039;D&#039;, 3)}&lt;br /&gt;
{(&#039;A&#039;, &#039;B&#039;, 1), (&#039;B&#039;, &#039;C&#039;, 1), (&#039;C&#039;, &#039;D&#039;, 1), (&#039;A&#039;, &#039;C&#039;, 2), (&#039;B&#039;, &#039;D&#039;, 2), (&#039;A&#039;, &#039;D&#039;, 3)}&lt;br /&gt;
True&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for round_index, step_name, work_count in trace_1:&lt;br /&gt;
    print(round_index, step_name, work_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first captured path order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for bus_sequence, source, target, hop_count in paths_1:&lt;br /&gt;
    print(bus_sequence, source, target, hop_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the second schedule and path order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for round_index, step_name, work_count in trace_2:&lt;br /&gt;
    print(round_index, step_name, work_count)&lt;br /&gt;
&lt;br /&gt;
for bus_sequence, source, target, hop_count in paths_2:&lt;br /&gt;
    print(bus_sequence, source, target, hop_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The source input order did not change. The processor scheduling changed. The captured event order may differ. The final reachability facts should match.&lt;br /&gt;
&lt;br /&gt;
This models an important distributed-systems lesson: event history order and final derived state are related, but they are not the same thing.&lt;br /&gt;
&lt;br /&gt;
== Phase 16: Later refinement: graph index from history ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Introduce restart/recovery thinking only after the basic graph feedback example is familiar.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The first graph processors keep local working state.&lt;br /&gt;
* Local working state is enough for a short demonstration.&lt;br /&gt;
* Longer-lived processors need recovery behavior after restart.&lt;br /&gt;
* Since edge and path facts are captured as events, a graph index can later be rebuilt from history.&lt;br /&gt;
* This points toward projection-style services without changing the basic processor model.&lt;br /&gt;
&lt;br /&gt;
; Suggested later file&lt;br /&gt;
&lt;br /&gt;
Create later:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_index.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Suggested responsibilities:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
read captured history&lt;br /&gt;
collect EdgeDeclaredEvent payloads&lt;br /&gt;
collect PathKnownEvent payloads&lt;br /&gt;
answer whether a path fact exists&lt;br /&gt;
find edges starting at a node&lt;br /&gt;
find paths ending at a node&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This refinement belongs after the team has already run the graph reachability demo successfully.&lt;br /&gt;
&lt;br /&gt;
== Phase 17: Later refinement: shell command reconstruction ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Connect the TTY examples back to the shell command event shape used by milestones.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; is the high-level event consumed by the milestone processor.&lt;br /&gt;
* &amp;lt;code&amp;gt;TTYReadEvent&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;TTYWriteEvent&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;LineDisciplineEvent&amp;lt;/code&amp;gt; are lower-level observations.&lt;br /&gt;
* A reconstructor can subscribe to line-discipline events and query recent related observations.&lt;br /&gt;
* The reconstructor should emit shell command events; it should not perform regex analysis itself.&lt;br /&gt;
&lt;br /&gt;
; Suggested responsibilities&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
listen for completed line-discipline events&lt;br /&gt;
query history since the previous command boundary for the same session&lt;br /&gt;
collect matching reads, writes, and timing events&lt;br /&gt;
construct ShellCommandEvent&lt;br /&gt;
emit ShellCommandEvent on demo.shell.command&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This is an appropriate follow-up exercise after the team understands both milestone processing and history queries.&lt;br /&gt;
&lt;br /&gt;
= Related =&lt;br /&gt;
&lt;br /&gt;
== Top-level topic page ==&lt;br /&gt;
[[Message Bus Service]]&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=941</id>
		<title>New Version Planning</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=941"/>
		<updated>2026-06-16T14:12:17Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* Links to Subprojects/Related Topics */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As we&#039;re still getting started, please feel free to insert notes and comments where you like - just try to avoid changing or deleting things you yourself didn&#039;t write. We&#039;ll come up with a shared structure as we go. Also consider using the &amp;quot;discussion&amp;quot; tab at the top if you aren&#039;t sure where to comment or want to have a dialogue.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
These items at the top are on the agenda for discussion, but don&#039;t have a clear place they belong yet:&lt;br /&gt;
* POST handler/API reachability&lt;br /&gt;
* Chat capability&lt;br /&gt;
* Containers&lt;br /&gt;
* NSJail, chroot?&lt;br /&gt;
* Physical systems: controlled via ordinary SSH, provisioned with PXE or other net boot methods?&lt;br /&gt;
* How to model shared resources and contention?&lt;br /&gt;
* Staged/prerequisite content in guide/scenario&lt;br /&gt;
* How to refer to observation/logging instruments&lt;br /&gt;
* How do we assess correctness/domain truth in a model of learning objectives?&lt;br /&gt;
** Navigating a filesystem:&lt;br /&gt;
*** &amp;quot;Can a student navigate the filesystem?&amp;quot;&lt;br /&gt;
**** &amp;quot;Can the student compose an absolute path?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student use / to begin an absolute path?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student refer to directories present at the root of the filesystem?&amp;quot;&lt;br /&gt;
**** &amp;quot;Do they correctly use find?&amp;quot;&lt;br /&gt;
**** &amp;quot;Does the student map correctly between paths and the purposes of the file hierarchy?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student look for binaries in /bin/?&amp;quot;&lt;br /&gt;
**** &amp;quot;Does the student use ~ to refer to their home directory?&amp;quot;&lt;br /&gt;
*** What artifacts help to assess this?&lt;br /&gt;
*Sample learning objectives:&lt;br /&gt;
**Hierarchically: course/domain; module/unit; learning objective; concept/skill cluster; knowledge components; tasks&lt;br /&gt;
**Scenario (course): Getting Started&lt;br /&gt;
***A learning objective: Students will be able to navigate the filesystem&lt;br /&gt;
****Compose paths&lt;br /&gt;
****Navigate directories&lt;br /&gt;
****Aware of working directory&lt;br /&gt;
****Understand path substitutions (~, ., etc.)&lt;br /&gt;
****etc...&lt;br /&gt;
***A more specific learning objective (or, alternately, a skill cluster): Student will be able to recognize (name) and locate a hidden file in a known directory&lt;br /&gt;
****Give filename of the file in question&lt;br /&gt;
****Identify the path of the file in question&lt;br /&gt;
****Source and provide information about the file, content in the file&lt;br /&gt;
****Differentiate between a file that is and isn&#039;t hidden&lt;br /&gt;
***Task (independent, but linked to learning objectives and knowledge components):&lt;br /&gt;
****Find all the files that end in &amp;quot;.gif&amp;quot; using the find command&lt;br /&gt;
***Knowledge component (independent, linked to tasks):&lt;br /&gt;
****Student uses &#039;find&amp;quot; when searching files&lt;br /&gt;
****Student uses &#039;ls&#039; to list and examine available files&lt;br /&gt;
****Student appropriately applies path prefix (absolute or relative)&lt;br /&gt;
****Student composes valid pattern for file extension&lt;br /&gt;
*Quality of life:&lt;br /&gt;
**Authentication/account naming is a system-wide standard/service&lt;br /&gt;
***How does this behave in designs where students are meant to seize other identities?&lt;br /&gt;
&lt;br /&gt;
=== Functional requirements: ===&lt;br /&gt;
&lt;br /&gt;
* 20-300 students simultaneously? Semi-simultaneously? ~150 concurrently, 500+ at large institutions?&lt;br /&gt;
* Telemetry layer for instrument/orchestration health&lt;br /&gt;
* Students are provided a learning environment that is safe to explore/attack without threatening the application host infrastructure&lt;br /&gt;
* Two weeks unattended uptime&lt;br /&gt;
* Per-user resource caps configurable per host (if not per-scenario or finer-grained)&lt;br /&gt;
&lt;br /&gt;
=== Ubiquitous language: ===&lt;br /&gt;
(See [https://agilealliance.org/glossary/ubiquitous-language/ What is Ubiquitous Language? | Agile Alliance], [https://www.cosmicpython.com/book/preface.html Cosmic Python] or [https://www.domainlanguage.com/ddd/ DDD Resources - Domain Language])&lt;br /&gt;
* Scenario&lt;br /&gt;
* Learning objectives&lt;br /&gt;
** e.g.&lt;br /&gt;
*** &amp;quot;Students will be able to use ls to list the contents of a directory&amp;quot;&lt;br /&gt;
*** &amp;quot;Students will be able to log in to the exercise via SSH&amp;quot;&lt;br /&gt;
** Milestone&lt;br /&gt;
*** Do these have sufficient resolution to see why/how students arrived at a correct answer?&lt;br /&gt;
** What are indicators of student progress on learning objectives?&lt;br /&gt;
*** How do events (such as those matched by milestones) correlate with questions?&lt;br /&gt;
**** What are students asking about in help requests?&lt;br /&gt;
**** Can we select semantics from log data (commands, chat, questions)?&lt;br /&gt;
** How do we know tangibly that a student is completing learning-related tasks?&lt;br /&gt;
* Questions (are exercises distinct?)&lt;br /&gt;
* Guide&lt;br /&gt;
** Guide section&lt;br /&gt;
* Student&lt;br /&gt;
* Instructor&lt;br /&gt;
* Hint&lt;br /&gt;
* Intervention&lt;br /&gt;
* Prediction&lt;br /&gt;
* Learning environment&lt;br /&gt;
** Underlying system state?&lt;br /&gt;
*** What denotes/distinguishes a command?&lt;br /&gt;
* Observability/instrumentation? (Name?)&lt;br /&gt;
&lt;br /&gt;
=== User stories: ===&lt;br /&gt;
(Supported use cases, written in terms of the ubiquitous language above)&lt;br /&gt;
* &#039;&#039;&#039;Instructors&#039;&#039;&#039; make &#039;&#039;&#039;Scenarios&#039;&#039;&#039; available to &#039;&#039;&#039;Students&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Scenarios&#039;&#039;&#039; configure the &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; to support &#039;&#039;&#039;Learning Objectives&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Scenarios&#039;&#039;&#039; may include a &#039;&#039;&#039;Guide&#039;&#039;&#039; to support exercises and tasks in the &#039;&#039;&#039;Learning Objectives&#039;&#039;&#039;&lt;br /&gt;
* The &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; can offer &#039;&#039;&#039;Interventions&#039;&#039;&#039; based on &#039;&#039;&#039;Student&#039;&#039;&#039; performance&lt;br /&gt;
* &#039;&#039;&#039;Students&#039;&#039;&#039; start and stop the &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; of &#039;&#039;&#039;Scenarios&#039;&#039;&#039; made accessible to them (the &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; does not consume resources when not in use)&lt;br /&gt;
&lt;br /&gt;
== Links to Subprojects/Related Topics ==&lt;br /&gt;
This section is temporary until we settle for a multi-topic organization for new version planning.&lt;br /&gt;
&lt;br /&gt;
* [[Data Store Project Charter]] - just the charter; pending a top-level topic page&lt;br /&gt;
* [[Message Bus Service]] - mostly a placeholder topic page; links to charter and related docs&lt;br /&gt;
* [[TTY BPF Instrument]] - also a placeholder page; links to repos/docs&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=Message_Bus_Live_Coding_Exercise&amp;diff=940</id>
		<title>Message Bus Live Coding Exercise</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=Message_Bus_Live_Coding_Exercise&amp;diff=940"/>
		<updated>2026-06-16T05:31:58Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* Phase 14: Add a randomized processor scheduler */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Introduction =&lt;br /&gt;
&lt;br /&gt;
== Audience and scope ==&lt;br /&gt;
&lt;br /&gt;
This exercise is intended for EDURange developers learning how to write event processors with the message bus.&lt;br /&gt;
&lt;br /&gt;
The packaged &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; modules are treated as read-only for this exercise. All new code belongs under a demonstration directory such as:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
workspace/&lt;br /&gt;
    mbus/&lt;br /&gt;
    aostore/&lt;br /&gt;
    demos/&lt;br /&gt;
        __init__.py&lt;br /&gt;
        event_processors/&lt;br /&gt;
            __init__.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Pre-release copies of &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; are available on the Discord fileshare channel. Modules may be released under other package names; see also the EDURange GitHub.&lt;br /&gt;
&lt;br /&gt;
The exercise uses two problem families.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;1. Shell and TTY event processing&#039;&#039;&#039;&lt;br /&gt;
** Send and receive messages through the bus.&lt;br /&gt;
** Capture and inspect message history.&lt;br /&gt;
** Represent high-level shell command events.&lt;br /&gt;
** Recreate the regex-based milestone role in a simplified form.&lt;br /&gt;
** Mock up TTY-derived observations such as keystroke timing.&lt;br /&gt;
* &#039;&#039;&#039;2. Graph event processing&#039;&#039;&#039;&lt;br /&gt;
** Represent graph edge relationships as source events.&lt;br /&gt;
** Derive direct path facts from edge relation events.&lt;br /&gt;
** Add feedback by deriving longer paths from known paths.&lt;br /&gt;
** Simulate nondeterministic processor scheduling while keeping source input fixed.&lt;br /&gt;
&lt;br /&gt;
The shell examples draw from EDURange&#039;s institutional knowledge. The graph examples provide a common model problem family for meaningful use of feedback loops.&lt;br /&gt;
&lt;br /&gt;
== Running the examples ==&lt;br /&gt;
&lt;br /&gt;
Adjust paths to match the local checkout layout if necessary. Start an interpreter from the workspace root with project packages available.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
PYTHONPATH=&amp;quot;$PWD/mbus:$PWD/aostore:$PWD&amp;quot; python3.14&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
When module files are changed, restart the interpreter unless the group is deliberately practicing &amp;lt;code&amp;gt;importlib.reload()&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Create the demonstration package ==&lt;br /&gt;
&lt;br /&gt;
Before the first durable file edit, create the demonstration package directories:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir -p demos/event_processors&lt;br /&gt;
touch demos/__init__.py&lt;br /&gt;
touch demos/event_processors/__init__.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
== Important note about capture records ==&lt;br /&gt;
&lt;br /&gt;
The current demonstration works close to the low-level capture layer. As a result, it exposes &amp;lt;code&amp;gt;MessageSymbolRegistration&amp;lt;/code&amp;gt; records while building the temporary history helper.&lt;br /&gt;
&lt;br /&gt;
That is not the intended ordinary user experience. In a more complete release, higher-level capture and history infrastructure should handle symbol registration bookkeeping on behalf of processor authors. In this exercise, symbol registration records are visible because the demonstration history helper reconstructs readable messages from compact captured records directly.&lt;br /&gt;
&lt;br /&gt;
== Suggested group session flow ==&lt;br /&gt;
&lt;br /&gt;
; Session 1 - Bus and capture basics&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 1:&#039;&#039;&#039;&lt;br /&gt;
** Signal over the bus by hand.&lt;br /&gt;
** Inspect received messages.&lt;br /&gt;
** Inspect low-level captured records.&lt;br /&gt;
* &#039;&#039;&#039;Phase 2:&#039;&#039;&#039;&lt;br /&gt;
** Discuss why symbol registration appears in this temporary demo.&lt;br /&gt;
** Build the capture history helper.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1-3 hours (1-2 hours work, 0.5-1 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 2 - Shell commands and milestones&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 3:&#039;&#039;&#039;&lt;br /&gt;
** Represent a shell command as a dictionary.&lt;br /&gt;
* &#039;&#039;&#039;Phase 4:&#039;&#039;&#039;&lt;br /&gt;
** Replace it with &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Phase 5:&#039;&#039;&#039;&lt;br /&gt;
** Manually evaluate milestone expressions.&lt;br /&gt;
* &#039;&#039;&#039;Phase 6:&#039;&#039;&#039;&lt;br /&gt;
** Implement &amp;lt;code&amp;gt;RegexMilestoneProcessor&amp;lt;/code&amp;gt;.&lt;br /&gt;
** Inspect source and analysis events in history.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3 hours (1.5-2.5 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 3 - TTY-derived observations&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 7:&#039;&#039;&#039;&lt;br /&gt;
** Create raw TTY read, write, and line events.&lt;br /&gt;
** Show why milestones need reconstructed shell command events.&lt;br /&gt;
* &#039;&#039;&#039;Phase 8:&#039;&#039;&#039;&lt;br /&gt;
** Implement keystroke timing.&lt;br /&gt;
** Discuss shell command reconstruction as the next derived-event problem (to be continued in Session 6 onward).&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1-2.5 hours (1-2 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 4 - Graph-derived facts&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 9:&#039;&#039;&#039;&lt;br /&gt;
** Define graph edges and paths.&lt;br /&gt;
* &#039;&#039;&#039;Phase 10:&#039;&#039;&#039;&lt;br /&gt;
** Add the demo-only nonblocking receive helper.&lt;br /&gt;
* &#039;&#039;&#039;Phase 11:&#039;&#039;&#039;&lt;br /&gt;
** Implement direct path derivation.&lt;br /&gt;
* &#039;&#039;&#039;Phase 12:&#039;&#039;&#039;&lt;br /&gt;
** Explore the extension rule by hand.&lt;br /&gt;
* &#039;&#039;&#039;Phase 13:&#039;&#039;&#039;&lt;br /&gt;
** Add path extension feedback.&lt;br /&gt;
** Discuss duplicate prevention.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3.5 hours (1.5-3 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 5 - Nondeterministic processor scheduling&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 14:&#039;&#039;&#039;&lt;br /&gt;
** Add the randomized processor runner.&lt;br /&gt;
* &#039;&#039;&#039;Phase 15:&#039;&#039;&#039;&lt;br /&gt;
** Keep source events fixed.&lt;br /&gt;
** Vary processor scheduling by seed.&lt;br /&gt;
** Compare final path facts across runs.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3.5 hours (1.5-3 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 6 - Follow-up refinements&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 16:&#039;&#039;&#039;&lt;br /&gt;
** Rebuild a graph index from history.&lt;br /&gt;
* &#039;&#039;&#039;Phase 17:&#039;&#039;&#039;&lt;br /&gt;
** Implement shell command reconstruction.&lt;br /&gt;
** Replace local Python payload assumptions with explicit payload projection formats when the bus format layer is ready for the exercise.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-4.5 hours (1.5-4 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
= Event Processor Demonstration Plan =&lt;br /&gt;
&lt;br /&gt;
== Phase 1: Signal over the bus by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Send one message through the direct bus and inspect what the receiver sees.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* A bus is useful only when something emits and something receives.&lt;br /&gt;
* Public message setup uses readable strings: topic, producer, and message type.&lt;br /&gt;
* The direct broker delivers local Python payloads in this demonstration.&lt;br /&gt;
* Capture must be attached before delivery in the current prototype.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Import the bus and capture interfaces:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.capture.sink import CaptureSink&lt;br /&gt;
from mbus.message.records import CapturedMessage&lt;br /&gt;
from mbus.message.symbols import MessageSymbolRegistration&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create a temporary capture sink in the interpreter. This sink stores whatever the bus sends to the capture layer:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
CapturedRecord = MessageSymbolRegistration | CapturedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class ListCaptureSink(CaptureSink):&lt;br /&gt;
    records: list[CapturedRecord] = field(default_factory=list)&lt;br /&gt;
&lt;br /&gt;
    def capture_symbol_registration(&lt;br /&gt;
        self,&lt;br /&gt;
        registration: MessageSymbolRegistration,&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self.records.append(registration)&lt;br /&gt;
&lt;br /&gt;
    def capture(self, message: CapturedMessage) -&amp;gt; None:&lt;br /&gt;
        self.records.append(message)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create the bus and attach capture:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = ListCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the empty capture sink:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
sink&lt;br /&gt;
sink.records&lt;br /&gt;
len(sink.records)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ListCaptureSink(records=[])&lt;br /&gt;
[]&lt;br /&gt;
0&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create a receiver. This describes the messages the receiver wants to hear:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the receiver and confirm that capture is still empty:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
type(receiver)&lt;br /&gt;
vars(receiver)&lt;br /&gt;
sink.records&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;class &#039;mbus.broker.direct._BrokerReceiver&#039;&amp;gt;&lt;br /&gt;
{&#039;_queue&#039;: ...}&lt;br /&gt;
[]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create an emitter. Registering the emitter gives the bus enough information to bind readable names to compact internal IDs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the captured registration records:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
sink.records&lt;br /&gt;
[type(record).__name__ for record in sink.records]&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    print(record)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[&#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;]&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;demo.signals&#039; ...)&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;ping&#039; ...)&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;demo-source&#039; ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit one message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;hello bus&amp;quot;})&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect capture before receiving:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
len(sink.records)&lt;br /&gt;
[type(record).__name__ for record in sink.records]&lt;br /&gt;
sink.records[-1]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
4&lt;br /&gt;
[&#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;CapturedMessage&#039;]&lt;br /&gt;
CapturedMessage(... payload={&#039;text&#039;: &#039;hello bus&#039;} ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
message = receiver.receive()&lt;br /&gt;
&lt;br /&gt;
message&lt;br /&gt;
message.msg_topic&lt;br /&gt;
message.msg_producer&lt;br /&gt;
message.msg_type&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ReceivedMessage(...)&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&#039;demo-source&#039;&lt;br /&gt;
&#039;ping&#039;&lt;br /&gt;
{&#039;text&#039;: &#039;hello bus&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the received message with the captured record:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured = sink.records[-1]&lt;br /&gt;
&lt;br /&gt;
captured&lt;br /&gt;
captured.msg_topic_id&lt;br /&gt;
captured.msg_type_id&lt;br /&gt;
captured.msg_producer_id&lt;br /&gt;
captured.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
CapturedMessage(...)&lt;br /&gt;
TopicID(...)&lt;br /&gt;
MessageTypeID(...)&lt;br /&gt;
ProducerID(...)&lt;br /&gt;
{&#039;text&#039;: &#039;hello bus&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The receiver gets the readable message view. The capture sink sees compact IDs and local payload data. The readable names are still available through symbol registration records, but manually reconstructing them is awkward.&lt;br /&gt;
&lt;br /&gt;
That awkwardness motivates the next phase.&lt;br /&gt;
&lt;br /&gt;
== Phase 2: Build a temporary capture history helper ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a small demonstration helper that turns captured records back into readable history messages.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Capture records are useful, but raw captured messages use compact IDs.&lt;br /&gt;
* A history helper can rebuild readable message history from registration records.&lt;br /&gt;
* This is demonstration infrastructure, not the final persistence/query API.&lt;br /&gt;
* The helper writes captured records into an in-memory &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; sequential unit.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration before the file edit&lt;br /&gt;
&lt;br /&gt;
List only the captured messages:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured_messages = []&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    if isinstance(record, CapturedMessage):&lt;br /&gt;
        captured_messages.append(record)&lt;br /&gt;
&lt;br /&gt;
captured_messages&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[CapturedMessage(... payload={&#039;text&#039;: &#039;hello bus&#039;} ...)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first captured message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured_message = captured_messages[0]&lt;br /&gt;
&lt;br /&gt;
captured_message.msg_topic_id&lt;br /&gt;
captured_message.msg_type_id&lt;br /&gt;
captured_message.msg_producer_id&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
TopicID(...)&lt;br /&gt;
MessageTypeID(...)&lt;br /&gt;
ProducerID(...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Manually reconstruct a topic name:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
topic_names = {}&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    if isinstance(record, MessageSymbolRegistration):&lt;br /&gt;
        if record.symbol_kind.name == &amp;quot;TOPIC&amp;quot;:&lt;br /&gt;
            topic_names[record.symbol_id] = record.symbol&lt;br /&gt;
&lt;br /&gt;
topic_names&lt;br /&gt;
topic_names[captured_message.msg_topic_id]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
{TopicID(...): &#039;demo.signals&#039;}&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This works, but repeating it by hand would distract from writing event processors.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/capture_history.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/capture_history.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Capture and history helpers for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from collections.abc import Iterable&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
&lt;br /&gt;
from aostore.sequentialunit.listsequentialunit import ListSequentialUnit&lt;br /&gt;
from mbus.capture.sink import CaptureSink&lt;br /&gt;
from mbus.message.records import CapturedMessage&lt;br /&gt;
from mbus.message.symbols import (&lt;br /&gt;
    MessageSymbolKind,&lt;br /&gt;
    MessageSymbolRegistration,&lt;br /&gt;
    MessageTypeID,&lt;br /&gt;
    ProducerID,&lt;br /&gt;
    TopicID,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
CapturedRecord = MessageSymbolRegistration | CapturedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class AOStoreCaptureSink(CaptureSink):&lt;br /&gt;
    records: ListSequentialUnit[CapturedRecord] = field(&lt;br /&gt;
        default_factory=ListSequentialUnit&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    def capture_symbol_registration(&lt;br /&gt;
        self,&lt;br /&gt;
        registration: MessageSymbolRegistration,&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self.records.append(registration)&lt;br /&gt;
&lt;br /&gt;
    def capture(self, message: CapturedMessage) -&amp;gt; None:&lt;br /&gt;
        self.records.append(message)&lt;br /&gt;
&lt;br /&gt;
    def all_records(self) -&amp;gt; tuple[CapturedRecord, ...]:&lt;br /&gt;
        records = self.records.sequential_read(0, len(self.records))&lt;br /&gt;
        result = tuple(records)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class HistoryMessage:&lt;br /&gt;
    msg_topic: str&lt;br /&gt;
    msg_type: str&lt;br /&gt;
    msg_producer: str&lt;br /&gt;
    payload: object&lt;br /&gt;
    bus_sequence: int&lt;br /&gt;
    topic_sequence: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class CaptureHistory:&lt;br /&gt;
    _records: tuple[CapturedRecord, ...]&lt;br /&gt;
    _topics: dict[TopicID, str]&lt;br /&gt;
    _types: dict[MessageTypeID, str]&lt;br /&gt;
    _producers: dict[ProducerID, str]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, records: Iterable[CapturedRecord]) -&amp;gt; None:&lt;br /&gt;
        self._records = tuple(records)&lt;br /&gt;
        self._topics = {}&lt;br /&gt;
        self._types = {}&lt;br /&gt;
        self._producers = {}&lt;br /&gt;
        self._index_symbols()&lt;br /&gt;
&lt;br /&gt;
    def _index_symbols(self) -&amp;gt; None:&lt;br /&gt;
        for record in self._records:&lt;br /&gt;
            if isinstance(record, MessageSymbolRegistration):&lt;br /&gt;
                self._record_symbol(record)&lt;br /&gt;
&lt;br /&gt;
    def _record_symbol(self, record: MessageSymbolRegistration) -&amp;gt; None:&lt;br /&gt;
        if record.symbol_kind is MessageSymbolKind.TOPIC:&lt;br /&gt;
            self._topics[record.symbol_id] = record.symbol&lt;br /&gt;
        elif record.symbol_kind is MessageSymbolKind.MSG_TYPE:&lt;br /&gt;
            self._types[record.symbol_id] = record.symbol&lt;br /&gt;
        elif record.symbol_kind is MessageSymbolKind.PRODUCER:&lt;br /&gt;
            self._producers[record.symbol_id] = record.symbol&lt;br /&gt;
&lt;br /&gt;
    def messages(self) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for record in self._records:&lt;br /&gt;
            if isinstance(record, CapturedMessage):&lt;br /&gt;
                message = self._decode_message(record)&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def by_topic(self, msg_topic: str) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for message in self.messages():&lt;br /&gt;
            if message.msg_topic == msg_topic:&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def since(self, bus_sequence: int) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for message in self.messages():&lt;br /&gt;
            if message.bus_sequence &amp;gt;= bus_sequence:&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def _decode_message(self, message: CapturedMessage) -&amp;gt; HistoryMessage:&lt;br /&gt;
        history_message = HistoryMessage(&lt;br /&gt;
            msg_topic=self._topics[message.msg_topic_id],&lt;br /&gt;
            msg_type=self._types[message.msg_type_id],&lt;br /&gt;
            msg_producer=self._producers[message.msg_producer_id],&lt;br /&gt;
            payload=message.payload,&lt;br /&gt;
            bus_sequence=message.bus_sequence,&lt;br /&gt;
            topic_sequence=message.topic_sequence,&lt;br /&gt;
        )&lt;br /&gt;
        return history_message&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;history helper&amp;quot;})&lt;br /&gt;
message = receiver.receive()&lt;br /&gt;
&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
history.messages()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[HistoryMessage(msg_topic=&#039;demo.signals&#039;, msg_type=&#039;ping&#039;, msg_producer=&#039;demo-source&#039;, payload={&#039;text&#039;: &#039;history helper&#039;}, ...)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the decoded history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history_message = history.messages()[0]&lt;br /&gt;
&lt;br /&gt;
history_message.msg_topic&lt;br /&gt;
history_message.msg_type&lt;br /&gt;
history_message.msg_producer&lt;br /&gt;
history_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&#039;ping&#039;&lt;br /&gt;
&#039;demo-source&#039;&lt;br /&gt;
{&#039;text&#039;: &#039;history helper&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 3: Explore shell command data by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Identify the high-level event shape needed by a milestone processor.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The old milestone strategy operated on command-level records.&lt;br /&gt;
* A command-level record includes context such as host and working directory.&lt;br /&gt;
* It also includes shell input and shell output.&lt;br /&gt;
* Raw TTY events do not directly have this shape; reconstruction comes later.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Represent a shell command as a dictionary:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = {&lt;br /&gt;
    &amp;quot;session_id&amp;quot;: &amp;quot;session-1&amp;quot;,&lt;br /&gt;
    &amp;quot;command_index&amp;quot;: 0,&lt;br /&gt;
    &amp;quot;host&amp;quot;: &amp;quot;alpha&amp;quot;,&lt;br /&gt;
    &amp;quot;cwd&amp;quot;: &amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    &amp;quot;input_text&amp;quot;: &amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    &amp;quot;output_text&amp;quot;: &amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    &amp;quot;started_at_ns&amp;quot;: 1000,&lt;br /&gt;
    &amp;quot;ended_at_ns&amp;quot;: 2000,&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the fields used by milestone checks:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command[&amp;quot;host&amp;quot;]&lt;br /&gt;
command[&amp;quot;cwd&amp;quot;]&lt;br /&gt;
command[&amp;quot;input_text&amp;quot;]&lt;br /&gt;
command[&amp;quot;output_text&amp;quot;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;alpha&#039;&lt;br /&gt;
&#039;/home/student&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Try an accidental wrong key:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command[&amp;quot;working_directory&amp;quot;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeyError: &#039;working_directory&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The dictionary contains the information, but it does not document the shape of the event. A typo is discovered only when the field is used. A named event class makes the processor code easier to read and easier to explain.&lt;br /&gt;
&lt;br /&gt;
== Phase 4: Persist the event record classes ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create reusable payload classes for the TTY, shell command, and milestone examples.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/events.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/events.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Payload records for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from dataclasses import dataclass&lt;br /&gt;
from typing import Literal&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class TTYReadEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    data: bytes&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class TTYWriteEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    data: bytes&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class LineDisciplineEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    line: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class KeystrokeTimingEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    previous_timestamp_ns: int | None&lt;br /&gt;
    delta_ns: int | None&lt;br /&gt;
    text: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class ShellCommandEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    command_index: int&lt;br /&gt;
    host: str&lt;br /&gt;
    cwd: str&lt;br /&gt;
    input_text: str&lt;br /&gt;
    output_text: str&lt;br /&gt;
    started_at_ns: int&lt;br /&gt;
    ended_at_ns: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
RegexMilestoneField = Literal[&lt;br /&gt;
    &amp;quot;host&amp;quot;,&lt;br /&gt;
    &amp;quot;cwd&amp;quot;,&lt;br /&gt;
    &amp;quot;input_text&amp;quot;,&lt;br /&gt;
    &amp;quot;output_text&amp;quot;,&lt;br /&gt;
]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneExpression:&lt;br /&gt;
    name: str&lt;br /&gt;
    field: RegexMilestoneField&lt;br /&gt;
    pattern: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneResult:&lt;br /&gt;
    expression_name: str&lt;br /&gt;
    field: RegexMilestoneField&lt;br /&gt;
    matched: bool&lt;br /&gt;
    match_text: str | None = None&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneAnalysisEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    command_index: int&lt;br /&gt;
    results: tuple[RegexMilestoneResult, ...]&lt;br /&gt;
    matched_count: int&lt;br /&gt;
    expression_count: int&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.events import ShellCommandEvent&lt;br /&gt;
&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
command&lt;br /&gt;
command.host&lt;br /&gt;
command.cwd&lt;br /&gt;
command.input_text&lt;br /&gt;
command.output_text&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ShellCommandEvent(session_id=&#039;session-1&#039;, command_index=0, ...)&lt;br /&gt;
&#039;alpha&#039;&lt;br /&gt;
&#039;/home/student&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The event shape is now explicit. Other code can depend on a named &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; instead of assuming that a loose dictionary has the expected fields.&lt;br /&gt;
&lt;br /&gt;
== Phase 5: Evaluate milestones manually ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Recreate the core milestone check in a small, transparent form.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* A milestone expression names one check against one shell command field.&lt;br /&gt;
* The field may be command input, command output, working directory, or host context.&lt;br /&gt;
* The result records whether the expression matched and, when available, the matched text.&lt;br /&gt;
* Manual evaluation is useful for learning the shape of the problem before writing a processor.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Import regular expressions and the payload classes:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create one command event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create three milestone expressions:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
expressions = (&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;used-ls&amp;quot;,&lt;br /&gt;
        field=&amp;quot;input_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;\bls\b&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;saw-notes&amp;quot;,&lt;br /&gt;
        field=&amp;quot;output_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;notes\.txt&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;home-directory&amp;quot;,&lt;br /&gt;
        field=&amp;quot;cwd&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;^/home/student$&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Evaluate one expression:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
expression = expressions[0]&lt;br /&gt;
text = getattr(command, expression.field)&lt;br /&gt;
match = re.search(expression.pattern, text)&lt;br /&gt;
&lt;br /&gt;
expression.name&lt;br /&gt;
expression.field&lt;br /&gt;
text&lt;br /&gt;
match.group(0)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;used-ls&#039;&lt;br /&gt;
&#039;input_text&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;ls&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Evaluate all expressions:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
manual_results = []&lt;br /&gt;
&lt;br /&gt;
for expression in expressions:&lt;br /&gt;
    text = getattr(command, expression.field)&lt;br /&gt;
    match = re.search(expression.pattern, text)&lt;br /&gt;
    manual_results.append((expression.name, match is not None))&lt;br /&gt;
&lt;br /&gt;
manual_results&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(&#039;used-ls&#039;, True), (&#039;saw-notes&#039;, True), (&#039;home-directory&#039;, True)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
Manual evaluation is simple for one command, but the loop is already event processor logic. The next phase preserves the loop in a reusable processor.&lt;br /&gt;
&lt;br /&gt;
== Phase 6: Persist the RegexMilestone processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a processor that receives shell command events and emits regex milestone analysis events.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/regex_milestones.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/regex_milestones.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Regex milestone processor for shell command events.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneAnalysisEvent,&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    RegexMilestoneResult,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class RegexMilestoneProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _expressions: tuple[RegexMilestoneExpression, ...]&lt;br /&gt;
&lt;br /&gt;
    def __init__(&lt;br /&gt;
        self,&lt;br /&gt;
        bus: DirectMessageBus,&lt;br /&gt;
        expressions: tuple[RegexMilestoneExpression, ...],&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.shell.command&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;shell-reconstructor&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;shell-command&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.shell.regex_milestone&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;regex-milestone-processor&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;regex-milestone-analysis&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._expressions = expressions&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; RegexMilestoneAnalysisEvent:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        command = message.payload&lt;br /&gt;
&lt;br /&gt;
        if not isinstance(command, ShellCommandEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected ShellCommandEvent, got {type(command).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        analysis = analyze_regex_milestones(command, self._expressions)&lt;br /&gt;
        self._emitter.emit(analysis)&lt;br /&gt;
        return analysis&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def analyze_regex_milestones(&lt;br /&gt;
    command: ShellCommandEvent,&lt;br /&gt;
    expressions: tuple[RegexMilestoneExpression, ...],&lt;br /&gt;
) -&amp;gt; RegexMilestoneAnalysisEvent:&lt;br /&gt;
    results = []&lt;br /&gt;
    for expression in expressions:&lt;br /&gt;
        result = evaluate_regex_milestone(command, expression)&lt;br /&gt;
        results.append(result)&lt;br /&gt;
&lt;br /&gt;
    matched_count = sum(result.matched for result in results)&lt;br /&gt;
    analysis = RegexMilestoneAnalysisEvent(&lt;br /&gt;
        session_id=command.session_id,&lt;br /&gt;
        command_index=command.command_index,&lt;br /&gt;
        results=tuple(results),&lt;br /&gt;
        matched_count=matched_count,&lt;br /&gt;
        expression_count=len(expressions),&lt;br /&gt;
    )&lt;br /&gt;
    return analysis&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def evaluate_regex_milestone(&lt;br /&gt;
    command: ShellCommandEvent,&lt;br /&gt;
    expression: RegexMilestoneExpression,&lt;br /&gt;
) -&amp;gt; RegexMilestoneResult:&lt;br /&gt;
    text = getattr(command, expression.field)&lt;br /&gt;
    match = re.search(expression.pattern, text)&lt;br /&gt;
    match_text = None&lt;br /&gt;
&lt;br /&gt;
    if match is not None:&lt;br /&gt;
        match_text = match.group(0)&lt;br /&gt;
&lt;br /&gt;
    result = RegexMilestoneResult(&lt;br /&gt;
        expression_name=expression.name,&lt;br /&gt;
        field=expression.field,&lt;br /&gt;
        matched=match is not None,&lt;br /&gt;
        match_text=match_text,&lt;br /&gt;
    )&lt;br /&gt;
    return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then set up the bus:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.regex_milestones import RegexMilestoneProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Subscribe to the analysis topic before running the processor:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.shell.regex_milestone&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;regex-milestone-processor&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;regex-milestone-analysis&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create the shell command emitter and processor:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.shell.command&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;shell-reconstructor&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;shell-command&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
expressions = (&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;used-ls&amp;quot;,&lt;br /&gt;
        field=&amp;quot;input_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;\bls\b&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;saw-notes&amp;quot;,&lt;br /&gt;
        field=&amp;quot;output_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;notes\.txt&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;home-directory&amp;quot;,&lt;br /&gt;
        field=&amp;quot;cwd&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;^/home/student$&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = RegexMilestoneProcessor(bus, expressions)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit a shell command event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
command_emitter.emit(command)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the command:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis = processor.process_one()&lt;br /&gt;
analysis&lt;br /&gt;
analysis.results&lt;br /&gt;
analysis.matched_count&lt;br /&gt;
analysis.expression_count&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
RegexMilestoneAnalysisEvent(... matched_count=3, expression_count=3)&lt;br /&gt;
(RegexMilestoneResult(...), RegexMilestoneResult(...), RegexMilestoneResult(...))&lt;br /&gt;
3&lt;br /&gt;
3&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the derived analysis event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis_message = analysis_receiver.receive()&lt;br /&gt;
&lt;br /&gt;
analysis_message.msg_topic&lt;br /&gt;
analysis_message.msg_type&lt;br /&gt;
analysis_message.msg_producer&lt;br /&gt;
analysis_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;demo.shell.regex_milestone&#039;&lt;br /&gt;
&#039;regex-milestone-analysis&#039;&lt;br /&gt;
&#039;regex-milestone-processor&#039;&lt;br /&gt;
RegexMilestoneAnalysisEvent(... matched_count=3, expression_count=3)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
messages = history.messages()&lt;br /&gt;
&lt;br /&gt;
for message in messages:&lt;br /&gt;
    print(message.bus_sequence, message.msg_topic, message.msg_type)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
0 demo.shell.command shell-command&lt;br /&gt;
1 demo.shell.regex_milestone regex-milestone-analysis&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The regex milestone processor consumes one event and emits another. The derived analysis event is available to any later subscriber. History contains both the source command event and the derived analysis event.&lt;br /&gt;
&lt;br /&gt;
== Phase 7: Explore why shell reconstruction is separate ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Show that raw TTY observations are not the same thing as high-level shell command events.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Regex milestones should inspect high-level command events.&lt;br /&gt;
* TTY reads, TTY writes, and line-discipline lines are lower-level observations.&lt;br /&gt;
* A shell command event is reconstructed by correlating those observations.&lt;br /&gt;
* Keeping reconstruction separate prevents the milestone processor from becoming a large TTY parser.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Create a few low-level observations:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    LineDisciplineEvent,&lt;br /&gt;
    TTYReadEvent,&lt;br /&gt;
    TTYWriteEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_1 = TTYReadEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=0,&lt;br /&gt;
    timestamp_ns=1000,&lt;br /&gt;
    data=b&amp;quot;l&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_2 = TTYReadEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=1,&lt;br /&gt;
    timestamp_ns=1100,&lt;br /&gt;
    data=b&amp;quot;s&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
line = LineDisciplineEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=2,&lt;br /&gt;
    timestamp_ns=1200,&lt;br /&gt;
    line=&amp;quot;ls\n&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
write = TTYWriteEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=3,&lt;br /&gt;
    timestamp_ns=1600,&lt;br /&gt;
    data=b&amp;quot;notes.txt\n&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the raw pieces:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_1.data&lt;br /&gt;
read_2.data&lt;br /&gt;
line.line&lt;br /&gt;
write.data&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
b&#039;l&#039;&lt;br /&gt;
b&#039;s&#039;&lt;br /&gt;
&#039;ls\n&#039;&lt;br /&gt;
b&#039;notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Assemble command fields manually:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
input_text = line.line&lt;br /&gt;
output_text = write.data.decode(&amp;quot;utf-8&amp;quot;, errors=&amp;quot;replace&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
input_text&lt;br /&gt;
output_text&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;ls\n&#039;&lt;br /&gt;
&#039;notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The useful command event is derived from several lower-level observations. A later shell reconstructor can subscribe to line-discipline events, query recent reads/writes/timing observations, and emit &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; records.&lt;br /&gt;
&lt;br /&gt;
== Phase 8: Add keystroke timing as a smaller TTY-derived processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a simple stateful processor before attempting full shell reconstruction.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Timing requires comparing a TTY read with the previous read in the same session.&lt;br /&gt;
* A derived timing event makes the result explicit.&lt;br /&gt;
* Other processors can consume timing events instead of recalculating deltas.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration before the file edit&lt;br /&gt;
&lt;br /&gt;
Calculate timing manually from the two read events:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_events = [read_1, read_2]&lt;br /&gt;
previous_timestamp = None&lt;br /&gt;
timing_rows = []&lt;br /&gt;
&lt;br /&gt;
for event in read_events:&lt;br /&gt;
    delta = None&lt;br /&gt;
    if previous_timestamp is not None:&lt;br /&gt;
        delta = event.timestamp_ns - previous_timestamp&lt;br /&gt;
    timing_rows.append((event.source_index, previous_timestamp, delta))&lt;br /&gt;
    previous_timestamp = event.timestamp_ns&lt;br /&gt;
&lt;br /&gt;
timing_rows&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, None, None), (1, 1000, 100)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/keystroke_timing.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/keystroke_timing.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Keystroke timing processor for TTY read events.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    KeystrokeTimingEvent,&lt;br /&gt;
    TTYReadEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class KeystrokeTimingProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _last_timestamp_by_session: dict[str, int]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.tty.read&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;tty-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;tty-read&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.keystroke.timing&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._last_timestamp_by_session = {}&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; KeystrokeTimingEvent:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        event = message.payload&lt;br /&gt;
&lt;br /&gt;
        if not isinstance(event, TTYReadEvent):&lt;br /&gt;
            raise TypeError(f&amp;quot;expected TTYReadEvent, got {type(event).__name__}&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
        previous_timestamp = self._last_timestamp_by_session.get(&lt;br /&gt;
            event.session_id&lt;br /&gt;
        )&lt;br /&gt;
        delta = None&lt;br /&gt;
&lt;br /&gt;
        if previous_timestamp is not None:&lt;br /&gt;
            delta = event.timestamp_ns - previous_timestamp&lt;br /&gt;
&lt;br /&gt;
        self._last_timestamp_by_session[event.session_id] = event.timestamp_ns&lt;br /&gt;
&lt;br /&gt;
        text = event.data.decode(&amp;quot;utf-8&amp;quot;, errors=&amp;quot;replace&amp;quot;)&lt;br /&gt;
        timing = KeystrokeTimingEvent(&lt;br /&gt;
            session_id=event.session_id,&lt;br /&gt;
            source_index=event.source_index,&lt;br /&gt;
            timestamp_ns=event.timestamp_ns,&lt;br /&gt;
            previous_timestamp_ns=previous_timestamp,&lt;br /&gt;
            delta_ns=delta,&lt;br /&gt;
            text=text,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter.emit(timing)&lt;br /&gt;
        return timing&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
from demos.event_processors.events import TTYReadEvent&lt;br /&gt;
from demos.event_processors.keystroke_timing import KeystrokeTimingProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
timing_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.keystroke.timing&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.tty.read&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;tty-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;tty-read&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = KeystrokeTimingProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit reads and process them:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_emitter.emit(&lt;br /&gt;
    TTYReadEvent(&lt;br /&gt;
        session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
        source_index=0,&lt;br /&gt;
        timestamp_ns=1000,&lt;br /&gt;
        data=b&amp;quot;l&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_emitter.emit(&lt;br /&gt;
    TTYReadEvent(&lt;br /&gt;
        session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
        source_index=1,&lt;br /&gt;
        timestamp_ns=1100,&lt;br /&gt;
        data=b&amp;quot;s&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor.process_one()&lt;br /&gt;
processor.process_one()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeystrokeTimingEvent(... previous_timestamp_ns=None, delta_ns=None, text=&#039;l&#039;)&lt;br /&gt;
KeystrokeTimingEvent(... previous_timestamp_ns=1000, delta_ns=100, text=&#039;s&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive timing events:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
timing_receiver.receive().payload&lt;br /&gt;
timing_receiver.receive().payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeystrokeTimingEvent(... source_index=0 ...)&lt;br /&gt;
KeystrokeTimingEvent(... source_index=1 ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 9: Introduce graph events ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Switch to a small graph problem that can demonstrate safe feedback loops in an illustratable form.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* An edge is a source fact.&lt;br /&gt;
* A path is a derived fact.&lt;br /&gt;
* A direct edge implies a direct path.&lt;br /&gt;
* Longer paths can be derived later from known paths and edges.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_events.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/graph_events.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Graph event payloads for message bus demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from dataclasses import dataclass&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class EdgeDeclaredEvent:&lt;br /&gt;
    graph_id: str&lt;br /&gt;
    source: str&lt;br /&gt;
    target: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class PathKnownEvent:&lt;br /&gt;
    graph_id: str&lt;br /&gt;
    source: str&lt;br /&gt;
    target: str&lt;br /&gt;
    hop_count: int&lt;br /&gt;
&lt;br /&gt;
    def fact_key(self) -&amp;gt; tuple[str, str, str]:&lt;br /&gt;
        result = (self.graph_id, self.source, self.target)&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge = EdgeDeclaredEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;A&amp;quot;,&lt;br /&gt;
    target=&amp;quot;B&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path = PathKnownEvent(&lt;br /&gt;
    graph_id=edge.graph_id,&lt;br /&gt;
    source=edge.source,&lt;br /&gt;
    target=edge.target,&lt;br /&gt;
    hop_count=1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge&lt;br /&gt;
path&lt;br /&gt;
path.fact_key()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
EdgeDeclaredEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;)&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;, hop_count=1)&lt;br /&gt;
(&#039;demo-graph&#039;, &#039;A&#039;, &#039;B&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 10: Create a demo-only nonblocking receive helper ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Prepare for processor scheduling without editing &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;Receiver.receive()&amp;lt;/code&amp;gt; blocks until a message is available.&lt;br /&gt;
* A scheduler needs to try a processor and move on if no message is waiting.&lt;br /&gt;
* The current package does not expose a public nonblocking receiver method.&lt;br /&gt;
* This helper is local demonstration code. It can be replaced by a real public API later.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/demo_receive.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/demo_receive.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Demo-only helpers for receiver inspection and scheduling.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from queue import Empty&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.endpoints import Receiver&lt;br /&gt;
from mbus.message.records import ReceivedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class DemoReceiverError(RuntimeError):&lt;br /&gt;
    pass&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def try_receive(receiver: Receiver) -&amp;gt; ReceivedMessage | None:&lt;br /&gt;
    queue = getattr(receiver, &amp;quot;_queue&amp;quot;, None)&lt;br /&gt;
&lt;br /&gt;
    if queue is None:&lt;br /&gt;
        raise DemoReceiverError(&lt;br /&gt;
            &amp;quot;demo try_receive requires the current direct broker receiver&amp;quot;&lt;br /&gt;
        )&lt;br /&gt;
&lt;br /&gt;
    try:&lt;br /&gt;
        message = queue.get_nowait()&lt;br /&gt;
    except Empty:&lt;br /&gt;
        message = None&lt;br /&gt;
&lt;br /&gt;
    return message&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.demo_receive import try_receive&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
try_receive(receiver)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
None&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Now emit a message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;demo nonblocking receive&amp;quot;})&lt;br /&gt;
message = try_receive(receiver)&lt;br /&gt;
&lt;br /&gt;
message&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ReceivedMessage(...)&lt;br /&gt;
{&#039;text&#039;: &#039;demo nonblocking receive&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 11: Create the direct path processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Implement the graph rule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
edge(A, B) -&amp;gt; path(A, B)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The source emits edge events.&lt;br /&gt;
* The direct path processor emits derived path events.&lt;br /&gt;
* Other processors can subscribe to derived path events.&lt;br /&gt;
* The processor can be run by hand or by a scheduler.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_reachability.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/graph_reachability.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Graph reachability processors for message bus demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.demo_receive import try_receive&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class DirectPathProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _known_paths: set[tuple[str, str, str]]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._known_paths = set()&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; PathKnownEvent | None:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        result = self._process_payload(message.payload)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
    def process_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            self._process_payload(message.payload)&lt;br /&gt;
            work_count = 1&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def _process_payload(self, payload: object) -&amp;gt; PathKnownEvent | None:&lt;br /&gt;
        if not isinstance(payload, EdgeDeclaredEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected EdgeDeclaredEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        path = PathKnownEvent(&lt;br /&gt;
            graph_id=payload.graph_id,&lt;br /&gt;
            source=payload.source,&lt;br /&gt;
            target=payload.target,&lt;br /&gt;
            hop_count=1,&lt;br /&gt;
        )&lt;br /&gt;
&lt;br /&gt;
        if path.fact_key() not in self._known_paths:&lt;br /&gt;
            self._known_paths.add(path.fact_key())&lt;br /&gt;
            self._emitter.emit(path)&lt;br /&gt;
            result = path&lt;br /&gt;
        else:&lt;br /&gt;
            result = None&lt;br /&gt;
&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
from demos.event_processors.graph_events import EdgeDeclaredEvent&lt;br /&gt;
from demos.event_processors.graph_reachability import DirectPathProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
path_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = DirectPathProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Call the processor before any edge exists:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
0&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit one edge:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;A&amp;quot;,&lt;br /&gt;
        target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the edge:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the derived path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_message = path_receiver.receive()&lt;br /&gt;
path_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;, hop_count=1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 12: Explore the path extension rule by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Understand the feedback rule before implementing it.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* If &amp;lt;code&amp;gt;A&amp;lt;/code&amp;gt; reaches &amp;lt;code&amp;gt;B&amp;lt;/code&amp;gt;, and there is an edge from &amp;lt;code&amp;gt;B&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;C&amp;lt;/code&amp;gt;, then &amp;lt;code&amp;gt;A&amp;lt;/code&amp;gt; reaches &amp;lt;code&amp;gt;C&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Derived path facts can lead to more derived path facts.&lt;br /&gt;
* Duplicate prevention keeps the feedback loop from repeating the same fact forever.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_bc = EdgeDeclaredEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;B&amp;quot;,&lt;br /&gt;
    target=&amp;quot;C&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path_ab = PathKnownEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;A&amp;quot;,&lt;br /&gt;
    target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    hop_count=1,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Check whether the path and edge connect:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_ab.target&lt;br /&gt;
edge_bc.source&lt;br /&gt;
path_ab.target == edge_bc.source&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;B&#039;&lt;br /&gt;
&#039;B&#039;&lt;br /&gt;
True&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Derive a new path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_ac = PathKnownEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=path_ab.source,&lt;br /&gt;
    target=edge_bc.target,&lt;br /&gt;
    hop_count=path_ab.hop_count + 1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path_ac&lt;br /&gt;
path_ac.fact_key()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;C&#039;, hop_count=2)&lt;br /&gt;
(&#039;demo-graph&#039;, &#039;A&#039;, &#039;C&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Manually prevent duplicate path facts:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
known_paths = set()&lt;br /&gt;
known_paths.add(path_ab.fact_key())&lt;br /&gt;
&lt;br /&gt;
path_ac.fact_key() in known_paths&lt;br /&gt;
&lt;br /&gt;
if path_ac.fact_key() not in known_paths:&lt;br /&gt;
    known_paths.add(path_ac.fact_key())&lt;br /&gt;
&lt;br /&gt;
known_paths&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
False&lt;br /&gt;
{(&#039;demo-graph&#039;, &#039;A&#039;, &#039;B&#039;), (&#039;demo-graph&#039;, &#039;A&#039;, &#039;C&#039;)}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 13: Add the path extension processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Implement the feedback rule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
path(A, B) + edge(B, C) -&amp;gt; path(A, C)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The extension processor listens to edge events and path events.&lt;br /&gt;
* It emits new path events when a known path can be extended.&lt;br /&gt;
* It also listens to its own output because an extended path may be extendable again.&lt;br /&gt;
* This is a controlled feedback loop.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Append this class to:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_reachability.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
class PathExtensionProcessor:&lt;br /&gt;
    _edge_receiver: Receiver&lt;br /&gt;
    _direct_path_receiver: Receiver&lt;br /&gt;
    _extension_path_receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _edges: set[tuple[str, str, str]]&lt;br /&gt;
    _paths: dict[tuple[str, str, str], PathKnownEvent]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._edge_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._direct_path_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._extension_path_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._edges = set()&lt;br /&gt;
        self._paths = {}&lt;br /&gt;
&lt;br /&gt;
    def process_edge_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._edge_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_edge_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def process_direct_path_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._direct_path_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_path_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def process_extension_path_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._extension_path_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_path_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def _process_edge_payload(&lt;br /&gt;
        self,&lt;br /&gt;
        payload: object,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        if not isinstance(payload, EdgeDeclaredEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected EdgeDeclaredEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        edge = payload&lt;br /&gt;
        self._edges.add((edge.graph_id, edge.source, edge.target))&lt;br /&gt;
        paths = self._derive_from_new_edge(edge)&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _process_path_payload(&lt;br /&gt;
        self,&lt;br /&gt;
        payload: object,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        if not isinstance(payload, PathKnownEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected PathKnownEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        path = payload&lt;br /&gt;
        self._paths[path.fact_key()] = path&lt;br /&gt;
        paths = self._derive_from_new_path(path)&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _derive_from_new_edge(&lt;br /&gt;
        self,&lt;br /&gt;
        edge: EdgeDeclaredEvent,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        paths = []&lt;br /&gt;
&lt;br /&gt;
        for known_path in self._paths.values():&lt;br /&gt;
            if known_path.graph_id != edge.graph_id:&lt;br /&gt;
                continue&lt;br /&gt;
            if known_path.target != edge.source:&lt;br /&gt;
                continue&lt;br /&gt;
&lt;br /&gt;
            next_path = PathKnownEvent(&lt;br /&gt;
                graph_id=edge.graph_id,&lt;br /&gt;
                source=known_path.source,&lt;br /&gt;
                target=edge.target,&lt;br /&gt;
                hop_count=known_path.hop_count + 1,&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
            if self._emit_if_new(next_path):&lt;br /&gt;
                paths.append(next_path)&lt;br /&gt;
&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _derive_from_new_path(&lt;br /&gt;
        self,&lt;br /&gt;
        path: PathKnownEvent,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        paths = []&lt;br /&gt;
&lt;br /&gt;
        for graph_id, edge_source, edge_target in self._edges:&lt;br /&gt;
            if graph_id != path.graph_id:&lt;br /&gt;
                continue&lt;br /&gt;
            if edge_source != path.target:&lt;br /&gt;
                continue&lt;br /&gt;
&lt;br /&gt;
            next_path = PathKnownEvent(&lt;br /&gt;
                graph_id=path.graph_id,&lt;br /&gt;
                source=path.source,&lt;br /&gt;
                target=edge_target,&lt;br /&gt;
                hop_count=path.hop_count + 1,&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
            if self._emit_if_new(next_path):&lt;br /&gt;
                paths.append(next_path)&lt;br /&gt;
&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _emit_if_new(self, path: PathKnownEvent) -&amp;gt; bool:&lt;br /&gt;
        if path.fact_key() in self._paths:&lt;br /&gt;
            result = False&lt;br /&gt;
        else:&lt;br /&gt;
            self._paths[path.fact_key()] = path&lt;br /&gt;
            self._emitter.emit(path)&lt;br /&gt;
            result = True&lt;br /&gt;
&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_events import EdgeDeclaredEvent&lt;br /&gt;
from demos.event_processors.graph_reachability import (&lt;br /&gt;
    DirectPathProcessor,&lt;br /&gt;
    PathExtensionProcessor,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
extension_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
direct_processor = DirectPathProcessor(bus)&lt;br /&gt;
extension_processor = PathExtensionProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit two edges in fixed order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;A&amp;quot;,&lt;br /&gt;
        target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;B&amp;quot;,&lt;br /&gt;
        target=&amp;quot;C&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the direct paths:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
direct_processor.process_available()&lt;br /&gt;
direct_processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Let the extension processor learn the edges:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
extension_processor.process_edge_available()&lt;br /&gt;
extension_processor.process_edge_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Let the extension processor learn the direct paths:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
extension_processor.process_direct_path_available()&lt;br /&gt;
extension_processor.process_direct_path_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The second call may emit the extended &amp;lt;code&amp;gt;A -&amp;gt; C&amp;lt;/code&amp;gt; path after the processor has seen both direct paths and both edges.&lt;br /&gt;
&lt;br /&gt;
Receive the extended path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
message = extension_receiver.receive()&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;C&#039;, hop_count=2)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect captured graph path history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
path_messages = history.by_topic(&amp;quot;demo.graph.path&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
for message in path_messages:&lt;br /&gt;
    path = message.payload&lt;br /&gt;
    print(&lt;br /&gt;
        message.bus_sequence,&lt;br /&gt;
        message.msg_producer,&lt;br /&gt;
        path.source,&lt;br /&gt;
        path.target,&lt;br /&gt;
        path.hop_count,&lt;br /&gt;
    )&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
... direct-path A B 1&lt;br /&gt;
... direct-path B C 1&lt;br /&gt;
... path-extension A C 2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 14: Add a randomized processor scheduler ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Simulate concurrency effects without changing source input order.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The source will emit the same edge events in the same order every run.&lt;br /&gt;
* The scheduler varies which processor gets a chance to run first.&lt;br /&gt;
* This simulates uncertainty from processing time, scheduling priority, startup timing, or transmission delay.&lt;br /&gt;
* The graph example should converge to the same final path facts even when processor scheduling - and the order of intermediate derived processor events - differs.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/runners.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/runners.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Small processor runners for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from collections.abc import Callable&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
from random import Random&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class ProcessorStep:&lt;br /&gt;
    name: str&lt;br /&gt;
    run: Callable[[], int]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class RandomProcessorRunner:&lt;br /&gt;
    steps: tuple[ProcessorStep, ...]&lt;br /&gt;
    seed: int&lt;br /&gt;
    max_rounds: int = 30&lt;br /&gt;
    trace: list[tuple[int, str, int]] = field(default_factory=list)&lt;br /&gt;
&lt;br /&gt;
    def run_until_quiet(self) -&amp;gt; list[tuple[int, str, int]]:&lt;br /&gt;
        rng = Random(self.seed)&lt;br /&gt;
&lt;br /&gt;
        for round_index in range(self.max_rounds):&lt;br /&gt;
            work_count = self._run_round(rng, round_index)&lt;br /&gt;
&lt;br /&gt;
            if work_count == 0:&lt;br /&gt;
                break&lt;br /&gt;
&lt;br /&gt;
        result = list(self.trace)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
    def _run_round(self, rng: Random, round_index: int) -&amp;gt; int:&lt;br /&gt;
        steps = list(self.steps)&lt;br /&gt;
        rng.shuffle(steps)&lt;br /&gt;
        round_work_count = 0&lt;br /&gt;
&lt;br /&gt;
        for step in steps:&lt;br /&gt;
            step_work_count = step.run()&lt;br /&gt;
            self.trace.append((round_index, step.name, step_work_count))&lt;br /&gt;
            round_work_count += step_work_count&lt;br /&gt;
&lt;br /&gt;
        return round_work_count&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then inspect how a runner can shuffle placeholder steps:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.runners import (&lt;br /&gt;
    ProcessorStep,&lt;br /&gt;
    RandomProcessorRunner,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
calls = []&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def first_step() -&amp;gt; int:&lt;br /&gt;
    calls.append(&amp;quot;first&amp;quot;)&lt;br /&gt;
    return 0&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def second_step() -&amp;gt; int:&lt;br /&gt;
    calls.append(&amp;quot;second&amp;quot;)&lt;br /&gt;
    return 0&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
runner = RandomProcessorRunner(&lt;br /&gt;
    steps=(&lt;br /&gt;
        ProcessorStep(name=&amp;quot;first&amp;quot;, run=first_step),&lt;br /&gt;
        ProcessorStep(name=&amp;quot;second&amp;quot;, run=second_step),&lt;br /&gt;
    ),&lt;br /&gt;
    seed=1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
runner.run_until_quiet()&lt;br /&gt;
calls&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, &#039;...&#039;, 0), (0, &#039;...&#039;, 0)]&lt;br /&gt;
[&#039;...&#039;, &#039;...&#039;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 15: Run the graph example with randomized scheduling ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Run the same graph input under different processor schedules and compare the final path facts.&lt;br /&gt;
&lt;br /&gt;
; Interpreter setup&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_reachability import (&lt;br /&gt;
    DirectPathProcessor,&lt;br /&gt;
    PathExtensionProcessor,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.runners import (&lt;br /&gt;
    ProcessorStep,&lt;br /&gt;
    RandomProcessorRunner,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Define a helper function in the interpreter:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
def run_graph_demo(seed: int) -&amp;gt; tuple[&lt;br /&gt;
    list[tuple[int, str, int]],&lt;br /&gt;
    list[tuple[int, str, str, int]],&lt;br /&gt;
]:&lt;br /&gt;
    bus = DirectMessageBus()&lt;br /&gt;
    sink = AOStoreCaptureSink()&lt;br /&gt;
    bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
    edge_emitter = bus.register_emitter(&lt;br /&gt;
        msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
        msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
        default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    direct_processor = DirectPathProcessor(bus)&lt;br /&gt;
    extension_processor = PathExtensionProcessor(bus)&lt;br /&gt;
&lt;br /&gt;
    edges = (&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;A&amp;quot;,&lt;br /&gt;
            target=&amp;quot;B&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;B&amp;quot;,&lt;br /&gt;
            target=&amp;quot;C&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;C&amp;quot;,&lt;br /&gt;
            target=&amp;quot;D&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    for edge in edges:&lt;br /&gt;
        edge_emitter.emit(edge)&lt;br /&gt;
&lt;br /&gt;
    steps = (&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;direct path from edge&amp;quot;,&lt;br /&gt;
            run=direct_processor.process_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns edge&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_edge_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns direct path&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_direct_path_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns extension path&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_extension_path_available,&lt;br /&gt;
        ),&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    runner = RandomProcessorRunner(&lt;br /&gt;
        steps=steps,&lt;br /&gt;
        seed=seed,&lt;br /&gt;
    )&lt;br /&gt;
    trace = runner.run_until_quiet()&lt;br /&gt;
&lt;br /&gt;
    history = CaptureHistory(sink.all_records())&lt;br /&gt;
    path_facts = []&lt;br /&gt;
&lt;br /&gt;
    for message in history.by_topic(&amp;quot;demo.graph.path&amp;quot;):&lt;br /&gt;
        path = message.payload&lt;br /&gt;
        if isinstance(path, PathKnownEvent):&lt;br /&gt;
            fact = (&lt;br /&gt;
                message.bus_sequence,&lt;br /&gt;
                path.source,&lt;br /&gt;
                path.target,&lt;br /&gt;
                path.hop_count,&lt;br /&gt;
            )&lt;br /&gt;
            path_facts.append(fact)&lt;br /&gt;
&lt;br /&gt;
    result = (trace, path_facts)&lt;br /&gt;
    return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Run one schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_1, paths_1 = run_graph_demo(1)&lt;br /&gt;
&lt;br /&gt;
trace_1&lt;br /&gt;
paths_1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, &#039;...&#039;, ...), (0, &#039;...&#039;, ...), ...]&lt;br /&gt;
[(..., &#039;A&#039;, &#039;B&#039;, 1), (..., &#039;B&#039;, &#039;C&#039;, 1), (..., &#039;C&#039;, &#039;D&#039;, 1), ...]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Run another schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_2, paths_2 = run_graph_demo(2)&lt;br /&gt;
&lt;br /&gt;
trace_2&lt;br /&gt;
paths_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the schedules:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_1 == trace_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
False&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the final path facts without bus sequence numbers:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
final_paths_1 = {&lt;br /&gt;
    (source, target, hop_count)&lt;br /&gt;
    for _, source, target, hop_count in paths_1&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
final_paths_2 = {&lt;br /&gt;
    (source, target, hop_count)&lt;br /&gt;
    for _, source, target, hop_count in paths_2&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
final_paths_1&lt;br /&gt;
final_paths_2&lt;br /&gt;
final_paths_1 == final_paths_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
{(&#039;A&#039;, &#039;B&#039;, 1), (&#039;B&#039;, &#039;C&#039;, 1), (&#039;C&#039;, &#039;D&#039;, 1), (&#039;A&#039;, &#039;C&#039;, 2), (&#039;B&#039;, &#039;D&#039;, 2), (&#039;A&#039;, &#039;D&#039;, 3)}&lt;br /&gt;
{(&#039;A&#039;, &#039;B&#039;, 1), (&#039;B&#039;, &#039;C&#039;, 1), (&#039;C&#039;, &#039;D&#039;, 1), (&#039;A&#039;, &#039;C&#039;, 2), (&#039;B&#039;, &#039;D&#039;, 2), (&#039;A&#039;, &#039;D&#039;, 3)}&lt;br /&gt;
True&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for round_index, step_name, work_count in trace_1:&lt;br /&gt;
    print(round_index, step_name, work_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first captured path order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for bus_sequence, source, target, hop_count in paths_1:&lt;br /&gt;
    print(bus_sequence, source, target, hop_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the second schedule and path order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for round_index, step_name, work_count in trace_2:&lt;br /&gt;
    print(round_index, step_name, work_count)&lt;br /&gt;
&lt;br /&gt;
for bus_sequence, source, target, hop_count in paths_2:&lt;br /&gt;
    print(bus_sequence, source, target, hop_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The source input order did not change. The processor scheduling changed. The captured event order may differ. The final reachability facts should match.&lt;br /&gt;
&lt;br /&gt;
This models an important distributed-systems lesson: event history order and final derived state are related, but they are not the same thing.&lt;br /&gt;
&lt;br /&gt;
== Phase 16: Later refinement: graph index from history ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Introduce restart/recovery thinking only after the basic graph feedback example is familiar.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The first graph processors keep local working state.&lt;br /&gt;
* Local working state is enough for a short demonstration.&lt;br /&gt;
* Longer-lived processors need recovery behavior after restart.&lt;br /&gt;
* Since edge and path facts are captured as events, a graph index can later be rebuilt from history.&lt;br /&gt;
* This points toward projection-style services without changing the basic processor model.&lt;br /&gt;
&lt;br /&gt;
; Suggested later file&lt;br /&gt;
&lt;br /&gt;
Create later:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_index.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Suggested responsibilities:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
read captured history&lt;br /&gt;
collect EdgeDeclaredEvent payloads&lt;br /&gt;
collect PathKnownEvent payloads&lt;br /&gt;
answer whether a path fact exists&lt;br /&gt;
find edges starting at a node&lt;br /&gt;
find paths ending at a node&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This refinement belongs after the team has already run the graph reachability demo successfully.&lt;br /&gt;
&lt;br /&gt;
== Phase 17: Later refinement: shell command reconstruction ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Connect the TTY examples back to the shell command event shape used by milestones.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; is the high-level event consumed by the milestone processor.&lt;br /&gt;
* &amp;lt;code&amp;gt;TTYReadEvent&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;TTYWriteEvent&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;LineDisciplineEvent&amp;lt;/code&amp;gt; are lower-level observations.&lt;br /&gt;
* A reconstructor can subscribe to line-discipline events and query recent related observations.&lt;br /&gt;
* The reconstructor should emit shell command events; it should not perform regex analysis itself.&lt;br /&gt;
&lt;br /&gt;
; Suggested responsibilities&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
listen for completed line-discipline events&lt;br /&gt;
query history since the previous command boundary for the same session&lt;br /&gt;
collect matching reads, writes, and timing events&lt;br /&gt;
construct ShellCommandEvent&lt;br /&gt;
emit ShellCommandEvent on demo.shell.command&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This is an appropriate follow-up exercise after the team understands both milestone processing and history queries.&lt;br /&gt;
&lt;br /&gt;
= Related =&lt;br /&gt;
&lt;br /&gt;
== Top-level topic page ==&lt;br /&gt;
[[Message Bus Service]]&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=Message_Bus_Live_Coding_Exercise&amp;diff=939</id>
		<title>Message Bus Live Coding Exercise</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=Message_Bus_Live_Coding_Exercise&amp;diff=939"/>
		<updated>2026-06-16T05:14:54Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* Phase 9: Introduce graph events */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Introduction =&lt;br /&gt;
&lt;br /&gt;
== Audience and scope ==&lt;br /&gt;
&lt;br /&gt;
This exercise is intended for EDURange developers learning how to write event processors with the message bus.&lt;br /&gt;
&lt;br /&gt;
The packaged &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; modules are treated as read-only for this exercise. All new code belongs under a demonstration directory such as:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
workspace/&lt;br /&gt;
    mbus/&lt;br /&gt;
    aostore/&lt;br /&gt;
    demos/&lt;br /&gt;
        __init__.py&lt;br /&gt;
        event_processors/&lt;br /&gt;
            __init__.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Pre-release copies of &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; are available on the Discord fileshare channel. Modules may be released under other package names; see also the EDURange GitHub.&lt;br /&gt;
&lt;br /&gt;
The exercise uses two problem families.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;1. Shell and TTY event processing&#039;&#039;&#039;&lt;br /&gt;
** Send and receive messages through the bus.&lt;br /&gt;
** Capture and inspect message history.&lt;br /&gt;
** Represent high-level shell command events.&lt;br /&gt;
** Recreate the regex-based milestone role in a simplified form.&lt;br /&gt;
** Mock up TTY-derived observations such as keystroke timing.&lt;br /&gt;
* &#039;&#039;&#039;2. Graph event processing&#039;&#039;&#039;&lt;br /&gt;
** Represent graph edge relationships as source events.&lt;br /&gt;
** Derive direct path facts from edge relation events.&lt;br /&gt;
** Add feedback by deriving longer paths from known paths.&lt;br /&gt;
** Simulate nondeterministic processor scheduling while keeping source input fixed.&lt;br /&gt;
&lt;br /&gt;
The shell examples draw from EDURange&#039;s institutional knowledge. The graph examples provide a common model problem family for meaningful use of feedback loops.&lt;br /&gt;
&lt;br /&gt;
== Running the examples ==&lt;br /&gt;
&lt;br /&gt;
Adjust paths to match the local checkout layout if necessary. Start an interpreter from the workspace root with project packages available.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
PYTHONPATH=&amp;quot;$PWD/mbus:$PWD/aostore:$PWD&amp;quot; python3.14&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
When module files are changed, restart the interpreter unless the group is deliberately practicing &amp;lt;code&amp;gt;importlib.reload()&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Create the demonstration package ==&lt;br /&gt;
&lt;br /&gt;
Before the first durable file edit, create the demonstration package directories:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir -p demos/event_processors&lt;br /&gt;
touch demos/__init__.py&lt;br /&gt;
touch demos/event_processors/__init__.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
== Important note about capture records ==&lt;br /&gt;
&lt;br /&gt;
The current demonstration works close to the low-level capture layer. As a result, it exposes &amp;lt;code&amp;gt;MessageSymbolRegistration&amp;lt;/code&amp;gt; records while building the temporary history helper.&lt;br /&gt;
&lt;br /&gt;
That is not the intended ordinary user experience. In a more complete release, higher-level capture and history infrastructure should handle symbol registration bookkeeping on behalf of processor authors. In this exercise, symbol registration records are visible because the demonstration history helper reconstructs readable messages from compact captured records directly.&lt;br /&gt;
&lt;br /&gt;
== Suggested group session flow ==&lt;br /&gt;
&lt;br /&gt;
; Session 1 - Bus and capture basics&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 1:&#039;&#039;&#039;&lt;br /&gt;
** Signal over the bus by hand.&lt;br /&gt;
** Inspect received messages.&lt;br /&gt;
** Inspect low-level captured records.&lt;br /&gt;
* &#039;&#039;&#039;Phase 2:&#039;&#039;&#039;&lt;br /&gt;
** Discuss why symbol registration appears in this temporary demo.&lt;br /&gt;
** Build the capture history helper.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1-3 hours (1-2 hours work, 0.5-1 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 2 - Shell commands and milestones&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 3:&#039;&#039;&#039;&lt;br /&gt;
** Represent a shell command as a dictionary.&lt;br /&gt;
* &#039;&#039;&#039;Phase 4:&#039;&#039;&#039;&lt;br /&gt;
** Replace it with &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Phase 5:&#039;&#039;&#039;&lt;br /&gt;
** Manually evaluate milestone expressions.&lt;br /&gt;
* &#039;&#039;&#039;Phase 6:&#039;&#039;&#039;&lt;br /&gt;
** Implement &amp;lt;code&amp;gt;RegexMilestoneProcessor&amp;lt;/code&amp;gt;.&lt;br /&gt;
** Inspect source and analysis events in history.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3 hours (1.5-2.5 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 3 - TTY-derived observations&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 7:&#039;&#039;&#039;&lt;br /&gt;
** Create raw TTY read, write, and line events.&lt;br /&gt;
** Show why milestones need reconstructed shell command events.&lt;br /&gt;
* &#039;&#039;&#039;Phase 8:&#039;&#039;&#039;&lt;br /&gt;
** Implement keystroke timing.&lt;br /&gt;
** Discuss shell command reconstruction as the next derived-event problem (to be continued in Session 6 onward).&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1-2.5 hours (1-2 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 4 - Graph-derived facts&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 9:&#039;&#039;&#039;&lt;br /&gt;
** Define graph edges and paths.&lt;br /&gt;
* &#039;&#039;&#039;Phase 10:&#039;&#039;&#039;&lt;br /&gt;
** Add the demo-only nonblocking receive helper.&lt;br /&gt;
* &#039;&#039;&#039;Phase 11:&#039;&#039;&#039;&lt;br /&gt;
** Implement direct path derivation.&lt;br /&gt;
* &#039;&#039;&#039;Phase 12:&#039;&#039;&#039;&lt;br /&gt;
** Explore the extension rule by hand.&lt;br /&gt;
* &#039;&#039;&#039;Phase 13:&#039;&#039;&#039;&lt;br /&gt;
** Add path extension feedback.&lt;br /&gt;
** Discuss duplicate prevention.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3.5 hours (1.5-3 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 5 - Nondeterministic processor scheduling&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 14:&#039;&#039;&#039;&lt;br /&gt;
** Add the randomized processor runner.&lt;br /&gt;
* &#039;&#039;&#039;Phase 15:&#039;&#039;&#039;&lt;br /&gt;
** Keep source events fixed.&lt;br /&gt;
** Vary processor scheduling by seed.&lt;br /&gt;
** Compare final path facts across runs.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3.5 hours (1.5-3 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 6 - Follow-up refinements&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 16:&#039;&#039;&#039;&lt;br /&gt;
** Rebuild a graph index from history.&lt;br /&gt;
* &#039;&#039;&#039;Phase 17:&#039;&#039;&#039;&lt;br /&gt;
** Implement shell command reconstruction.&lt;br /&gt;
** Replace local Python payload assumptions with explicit payload projection formats when the bus format layer is ready for the exercise.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-4.5 hours (1.5-4 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
= Event Processor Demonstration Plan =&lt;br /&gt;
&lt;br /&gt;
== Phase 1: Signal over the bus by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Send one message through the direct bus and inspect what the receiver sees.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* A bus is useful only when something emits and something receives.&lt;br /&gt;
* Public message setup uses readable strings: topic, producer, and message type.&lt;br /&gt;
* The direct broker delivers local Python payloads in this demonstration.&lt;br /&gt;
* Capture must be attached before delivery in the current prototype.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Import the bus and capture interfaces:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.capture.sink import CaptureSink&lt;br /&gt;
from mbus.message.records import CapturedMessage&lt;br /&gt;
from mbus.message.symbols import MessageSymbolRegistration&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create a temporary capture sink in the interpreter. This sink stores whatever the bus sends to the capture layer:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
CapturedRecord = MessageSymbolRegistration | CapturedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class ListCaptureSink(CaptureSink):&lt;br /&gt;
    records: list[CapturedRecord] = field(default_factory=list)&lt;br /&gt;
&lt;br /&gt;
    def capture_symbol_registration(&lt;br /&gt;
        self,&lt;br /&gt;
        registration: MessageSymbolRegistration,&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self.records.append(registration)&lt;br /&gt;
&lt;br /&gt;
    def capture(self, message: CapturedMessage) -&amp;gt; None:&lt;br /&gt;
        self.records.append(message)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create the bus and attach capture:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = ListCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the empty capture sink:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
sink&lt;br /&gt;
sink.records&lt;br /&gt;
len(sink.records)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ListCaptureSink(records=[])&lt;br /&gt;
[]&lt;br /&gt;
0&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create a receiver. This describes the messages the receiver wants to hear:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the receiver and confirm that capture is still empty:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
type(receiver)&lt;br /&gt;
vars(receiver)&lt;br /&gt;
sink.records&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;class &#039;mbus.broker.direct._BrokerReceiver&#039;&amp;gt;&lt;br /&gt;
{&#039;_queue&#039;: ...}&lt;br /&gt;
[]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create an emitter. Registering the emitter gives the bus enough information to bind readable names to compact internal IDs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the captured registration records:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
sink.records&lt;br /&gt;
[type(record).__name__ for record in sink.records]&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    print(record)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[&#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;]&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;demo.signals&#039; ...)&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;ping&#039; ...)&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;demo-source&#039; ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit one message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;hello bus&amp;quot;})&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect capture before receiving:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
len(sink.records)&lt;br /&gt;
[type(record).__name__ for record in sink.records]&lt;br /&gt;
sink.records[-1]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
4&lt;br /&gt;
[&#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;CapturedMessage&#039;]&lt;br /&gt;
CapturedMessage(... payload={&#039;text&#039;: &#039;hello bus&#039;} ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
message = receiver.receive()&lt;br /&gt;
&lt;br /&gt;
message&lt;br /&gt;
message.msg_topic&lt;br /&gt;
message.msg_producer&lt;br /&gt;
message.msg_type&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ReceivedMessage(...)&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&#039;demo-source&#039;&lt;br /&gt;
&#039;ping&#039;&lt;br /&gt;
{&#039;text&#039;: &#039;hello bus&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the received message with the captured record:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured = sink.records[-1]&lt;br /&gt;
&lt;br /&gt;
captured&lt;br /&gt;
captured.msg_topic_id&lt;br /&gt;
captured.msg_type_id&lt;br /&gt;
captured.msg_producer_id&lt;br /&gt;
captured.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
CapturedMessage(...)&lt;br /&gt;
TopicID(...)&lt;br /&gt;
MessageTypeID(...)&lt;br /&gt;
ProducerID(...)&lt;br /&gt;
{&#039;text&#039;: &#039;hello bus&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The receiver gets the readable message view. The capture sink sees compact IDs and local payload data. The readable names are still available through symbol registration records, but manually reconstructing them is awkward.&lt;br /&gt;
&lt;br /&gt;
That awkwardness motivates the next phase.&lt;br /&gt;
&lt;br /&gt;
== Phase 2: Build a temporary capture history helper ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a small demonstration helper that turns captured records back into readable history messages.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Capture records are useful, but raw captured messages use compact IDs.&lt;br /&gt;
* A history helper can rebuild readable message history from registration records.&lt;br /&gt;
* This is demonstration infrastructure, not the final persistence/query API.&lt;br /&gt;
* The helper writes captured records into an in-memory &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; sequential unit.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration before the file edit&lt;br /&gt;
&lt;br /&gt;
List only the captured messages:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured_messages = []&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    if isinstance(record, CapturedMessage):&lt;br /&gt;
        captured_messages.append(record)&lt;br /&gt;
&lt;br /&gt;
captured_messages&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[CapturedMessage(... payload={&#039;text&#039;: &#039;hello bus&#039;} ...)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first captured message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured_message = captured_messages[0]&lt;br /&gt;
&lt;br /&gt;
captured_message.msg_topic_id&lt;br /&gt;
captured_message.msg_type_id&lt;br /&gt;
captured_message.msg_producer_id&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
TopicID(...)&lt;br /&gt;
MessageTypeID(...)&lt;br /&gt;
ProducerID(...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Manually reconstruct a topic name:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
topic_names = {}&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    if isinstance(record, MessageSymbolRegistration):&lt;br /&gt;
        if record.symbol_kind.name == &amp;quot;TOPIC&amp;quot;:&lt;br /&gt;
            topic_names[record.symbol_id] = record.symbol&lt;br /&gt;
&lt;br /&gt;
topic_names&lt;br /&gt;
topic_names[captured_message.msg_topic_id]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
{TopicID(...): &#039;demo.signals&#039;}&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This works, but repeating it by hand would distract from writing event processors.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/capture_history.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/capture_history.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Capture and history helpers for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from collections.abc import Iterable&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
&lt;br /&gt;
from aostore.sequentialunit.listsequentialunit import ListSequentialUnit&lt;br /&gt;
from mbus.capture.sink import CaptureSink&lt;br /&gt;
from mbus.message.records import CapturedMessage&lt;br /&gt;
from mbus.message.symbols import (&lt;br /&gt;
    MessageSymbolKind,&lt;br /&gt;
    MessageSymbolRegistration,&lt;br /&gt;
    MessageTypeID,&lt;br /&gt;
    ProducerID,&lt;br /&gt;
    TopicID,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
CapturedRecord = MessageSymbolRegistration | CapturedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class AOStoreCaptureSink(CaptureSink):&lt;br /&gt;
    records: ListSequentialUnit[CapturedRecord] = field(&lt;br /&gt;
        default_factory=ListSequentialUnit&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    def capture_symbol_registration(&lt;br /&gt;
        self,&lt;br /&gt;
        registration: MessageSymbolRegistration,&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self.records.append(registration)&lt;br /&gt;
&lt;br /&gt;
    def capture(self, message: CapturedMessage) -&amp;gt; None:&lt;br /&gt;
        self.records.append(message)&lt;br /&gt;
&lt;br /&gt;
    def all_records(self) -&amp;gt; tuple[CapturedRecord, ...]:&lt;br /&gt;
        records = self.records.sequential_read(0, len(self.records))&lt;br /&gt;
        result = tuple(records)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class HistoryMessage:&lt;br /&gt;
    msg_topic: str&lt;br /&gt;
    msg_type: str&lt;br /&gt;
    msg_producer: str&lt;br /&gt;
    payload: object&lt;br /&gt;
    bus_sequence: int&lt;br /&gt;
    topic_sequence: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class CaptureHistory:&lt;br /&gt;
    _records: tuple[CapturedRecord, ...]&lt;br /&gt;
    _topics: dict[TopicID, str]&lt;br /&gt;
    _types: dict[MessageTypeID, str]&lt;br /&gt;
    _producers: dict[ProducerID, str]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, records: Iterable[CapturedRecord]) -&amp;gt; None:&lt;br /&gt;
        self._records = tuple(records)&lt;br /&gt;
        self._topics = {}&lt;br /&gt;
        self._types = {}&lt;br /&gt;
        self._producers = {}&lt;br /&gt;
        self._index_symbols()&lt;br /&gt;
&lt;br /&gt;
    def _index_symbols(self) -&amp;gt; None:&lt;br /&gt;
        for record in self._records:&lt;br /&gt;
            if isinstance(record, MessageSymbolRegistration):&lt;br /&gt;
                self._record_symbol(record)&lt;br /&gt;
&lt;br /&gt;
    def _record_symbol(self, record: MessageSymbolRegistration) -&amp;gt; None:&lt;br /&gt;
        if record.symbol_kind is MessageSymbolKind.TOPIC:&lt;br /&gt;
            self._topics[record.symbol_id] = record.symbol&lt;br /&gt;
        elif record.symbol_kind is MessageSymbolKind.MSG_TYPE:&lt;br /&gt;
            self._types[record.symbol_id] = record.symbol&lt;br /&gt;
        elif record.symbol_kind is MessageSymbolKind.PRODUCER:&lt;br /&gt;
            self._producers[record.symbol_id] = record.symbol&lt;br /&gt;
&lt;br /&gt;
    def messages(self) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for record in self._records:&lt;br /&gt;
            if isinstance(record, CapturedMessage):&lt;br /&gt;
                message = self._decode_message(record)&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def by_topic(self, msg_topic: str) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for message in self.messages():&lt;br /&gt;
            if message.msg_topic == msg_topic:&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def since(self, bus_sequence: int) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for message in self.messages():&lt;br /&gt;
            if message.bus_sequence &amp;gt;= bus_sequence:&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def _decode_message(self, message: CapturedMessage) -&amp;gt; HistoryMessage:&lt;br /&gt;
        history_message = HistoryMessage(&lt;br /&gt;
            msg_topic=self._topics[message.msg_topic_id],&lt;br /&gt;
            msg_type=self._types[message.msg_type_id],&lt;br /&gt;
            msg_producer=self._producers[message.msg_producer_id],&lt;br /&gt;
            payload=message.payload,&lt;br /&gt;
            bus_sequence=message.bus_sequence,&lt;br /&gt;
            topic_sequence=message.topic_sequence,&lt;br /&gt;
        )&lt;br /&gt;
        return history_message&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;history helper&amp;quot;})&lt;br /&gt;
message = receiver.receive()&lt;br /&gt;
&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
history.messages()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[HistoryMessage(msg_topic=&#039;demo.signals&#039;, msg_type=&#039;ping&#039;, msg_producer=&#039;demo-source&#039;, payload={&#039;text&#039;: &#039;history helper&#039;}, ...)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the decoded history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history_message = history.messages()[0]&lt;br /&gt;
&lt;br /&gt;
history_message.msg_topic&lt;br /&gt;
history_message.msg_type&lt;br /&gt;
history_message.msg_producer&lt;br /&gt;
history_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&#039;ping&#039;&lt;br /&gt;
&#039;demo-source&#039;&lt;br /&gt;
{&#039;text&#039;: &#039;history helper&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 3: Explore shell command data by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Identify the high-level event shape needed by a milestone processor.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The old milestone strategy operated on command-level records.&lt;br /&gt;
* A command-level record includes context such as host and working directory.&lt;br /&gt;
* It also includes shell input and shell output.&lt;br /&gt;
* Raw TTY events do not directly have this shape; reconstruction comes later.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Represent a shell command as a dictionary:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = {&lt;br /&gt;
    &amp;quot;session_id&amp;quot;: &amp;quot;session-1&amp;quot;,&lt;br /&gt;
    &amp;quot;command_index&amp;quot;: 0,&lt;br /&gt;
    &amp;quot;host&amp;quot;: &amp;quot;alpha&amp;quot;,&lt;br /&gt;
    &amp;quot;cwd&amp;quot;: &amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    &amp;quot;input_text&amp;quot;: &amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    &amp;quot;output_text&amp;quot;: &amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    &amp;quot;started_at_ns&amp;quot;: 1000,&lt;br /&gt;
    &amp;quot;ended_at_ns&amp;quot;: 2000,&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the fields used by milestone checks:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command[&amp;quot;host&amp;quot;]&lt;br /&gt;
command[&amp;quot;cwd&amp;quot;]&lt;br /&gt;
command[&amp;quot;input_text&amp;quot;]&lt;br /&gt;
command[&amp;quot;output_text&amp;quot;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;alpha&#039;&lt;br /&gt;
&#039;/home/student&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Try an accidental wrong key:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command[&amp;quot;working_directory&amp;quot;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeyError: &#039;working_directory&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The dictionary contains the information, but it does not document the shape of the event. A typo is discovered only when the field is used. A named event class makes the processor code easier to read and easier to explain.&lt;br /&gt;
&lt;br /&gt;
== Phase 4: Persist the event record classes ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create reusable payload classes for the TTY, shell command, and milestone examples.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/events.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/events.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Payload records for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from dataclasses import dataclass&lt;br /&gt;
from typing import Literal&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class TTYReadEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    data: bytes&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class TTYWriteEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    data: bytes&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class LineDisciplineEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    line: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class KeystrokeTimingEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    previous_timestamp_ns: int | None&lt;br /&gt;
    delta_ns: int | None&lt;br /&gt;
    text: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class ShellCommandEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    command_index: int&lt;br /&gt;
    host: str&lt;br /&gt;
    cwd: str&lt;br /&gt;
    input_text: str&lt;br /&gt;
    output_text: str&lt;br /&gt;
    started_at_ns: int&lt;br /&gt;
    ended_at_ns: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
RegexMilestoneField = Literal[&lt;br /&gt;
    &amp;quot;host&amp;quot;,&lt;br /&gt;
    &amp;quot;cwd&amp;quot;,&lt;br /&gt;
    &amp;quot;input_text&amp;quot;,&lt;br /&gt;
    &amp;quot;output_text&amp;quot;,&lt;br /&gt;
]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneExpression:&lt;br /&gt;
    name: str&lt;br /&gt;
    field: RegexMilestoneField&lt;br /&gt;
    pattern: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneResult:&lt;br /&gt;
    expression_name: str&lt;br /&gt;
    field: RegexMilestoneField&lt;br /&gt;
    matched: bool&lt;br /&gt;
    match_text: str | None = None&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneAnalysisEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    command_index: int&lt;br /&gt;
    results: tuple[RegexMilestoneResult, ...]&lt;br /&gt;
    matched_count: int&lt;br /&gt;
    expression_count: int&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.events import ShellCommandEvent&lt;br /&gt;
&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
command&lt;br /&gt;
command.host&lt;br /&gt;
command.cwd&lt;br /&gt;
command.input_text&lt;br /&gt;
command.output_text&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ShellCommandEvent(session_id=&#039;session-1&#039;, command_index=0, ...)&lt;br /&gt;
&#039;alpha&#039;&lt;br /&gt;
&#039;/home/student&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The event shape is now explicit. Other code can depend on a named &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; instead of assuming that a loose dictionary has the expected fields.&lt;br /&gt;
&lt;br /&gt;
== Phase 5: Evaluate milestones manually ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Recreate the core milestone check in a small, transparent form.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* A milestone expression names one check against one shell command field.&lt;br /&gt;
* The field may be command input, command output, working directory, or host context.&lt;br /&gt;
* The result records whether the expression matched and, when available, the matched text.&lt;br /&gt;
* Manual evaluation is useful for learning the shape of the problem before writing a processor.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Import regular expressions and the payload classes:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create one command event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create three milestone expressions:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
expressions = (&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;used-ls&amp;quot;,&lt;br /&gt;
        field=&amp;quot;input_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;\bls\b&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;saw-notes&amp;quot;,&lt;br /&gt;
        field=&amp;quot;output_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;notes\.txt&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;home-directory&amp;quot;,&lt;br /&gt;
        field=&amp;quot;cwd&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;^/home/student$&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Evaluate one expression:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
expression = expressions[0]&lt;br /&gt;
text = getattr(command, expression.field)&lt;br /&gt;
match = re.search(expression.pattern, text)&lt;br /&gt;
&lt;br /&gt;
expression.name&lt;br /&gt;
expression.field&lt;br /&gt;
text&lt;br /&gt;
match.group(0)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;used-ls&#039;&lt;br /&gt;
&#039;input_text&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;ls&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Evaluate all expressions:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
manual_results = []&lt;br /&gt;
&lt;br /&gt;
for expression in expressions:&lt;br /&gt;
    text = getattr(command, expression.field)&lt;br /&gt;
    match = re.search(expression.pattern, text)&lt;br /&gt;
    manual_results.append((expression.name, match is not None))&lt;br /&gt;
&lt;br /&gt;
manual_results&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(&#039;used-ls&#039;, True), (&#039;saw-notes&#039;, True), (&#039;home-directory&#039;, True)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
Manual evaluation is simple for one command, but the loop is already event processor logic. The next phase preserves the loop in a reusable processor.&lt;br /&gt;
&lt;br /&gt;
== Phase 6: Persist the RegexMilestone processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a processor that receives shell command events and emits regex milestone analysis events.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/regex_milestones.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/regex_milestones.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Regex milestone processor for shell command events.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneAnalysisEvent,&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    RegexMilestoneResult,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class RegexMilestoneProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _expressions: tuple[RegexMilestoneExpression, ...]&lt;br /&gt;
&lt;br /&gt;
    def __init__(&lt;br /&gt;
        self,&lt;br /&gt;
        bus: DirectMessageBus,&lt;br /&gt;
        expressions: tuple[RegexMilestoneExpression, ...],&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.shell.command&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;shell-reconstructor&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;shell-command&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.shell.regex_milestone&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;regex-milestone-processor&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;regex-milestone-analysis&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._expressions = expressions&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; RegexMilestoneAnalysisEvent:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        command = message.payload&lt;br /&gt;
&lt;br /&gt;
        if not isinstance(command, ShellCommandEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected ShellCommandEvent, got {type(command).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        analysis = analyze_regex_milestones(command, self._expressions)&lt;br /&gt;
        self._emitter.emit(analysis)&lt;br /&gt;
        return analysis&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def analyze_regex_milestones(&lt;br /&gt;
    command: ShellCommandEvent,&lt;br /&gt;
    expressions: tuple[RegexMilestoneExpression, ...],&lt;br /&gt;
) -&amp;gt; RegexMilestoneAnalysisEvent:&lt;br /&gt;
    results = []&lt;br /&gt;
    for expression in expressions:&lt;br /&gt;
        result = evaluate_regex_milestone(command, expression)&lt;br /&gt;
        results.append(result)&lt;br /&gt;
&lt;br /&gt;
    matched_count = sum(result.matched for result in results)&lt;br /&gt;
    analysis = RegexMilestoneAnalysisEvent(&lt;br /&gt;
        session_id=command.session_id,&lt;br /&gt;
        command_index=command.command_index,&lt;br /&gt;
        results=tuple(results),&lt;br /&gt;
        matched_count=matched_count,&lt;br /&gt;
        expression_count=len(expressions),&lt;br /&gt;
    )&lt;br /&gt;
    return analysis&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def evaluate_regex_milestone(&lt;br /&gt;
    command: ShellCommandEvent,&lt;br /&gt;
    expression: RegexMilestoneExpression,&lt;br /&gt;
) -&amp;gt; RegexMilestoneResult:&lt;br /&gt;
    text = getattr(command, expression.field)&lt;br /&gt;
    match = re.search(expression.pattern, text)&lt;br /&gt;
    match_text = None&lt;br /&gt;
&lt;br /&gt;
    if match is not None:&lt;br /&gt;
        match_text = match.group(0)&lt;br /&gt;
&lt;br /&gt;
    result = RegexMilestoneResult(&lt;br /&gt;
        expression_name=expression.name,&lt;br /&gt;
        field=expression.field,&lt;br /&gt;
        matched=match is not None,&lt;br /&gt;
        match_text=match_text,&lt;br /&gt;
    )&lt;br /&gt;
    return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then set up the bus:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.regex_milestones import RegexMilestoneProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Subscribe to the analysis topic before running the processor:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.shell.regex_milestone&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;regex-milestone-processor&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;regex-milestone-analysis&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create the shell command emitter and processor:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.shell.command&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;shell-reconstructor&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;shell-command&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
expressions = (&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;used-ls&amp;quot;,&lt;br /&gt;
        field=&amp;quot;input_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;\bls\b&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;saw-notes&amp;quot;,&lt;br /&gt;
        field=&amp;quot;output_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;notes\.txt&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;home-directory&amp;quot;,&lt;br /&gt;
        field=&amp;quot;cwd&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;^/home/student$&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = RegexMilestoneProcessor(bus, expressions)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit a shell command event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
command_emitter.emit(command)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the command:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis = processor.process_one()&lt;br /&gt;
analysis&lt;br /&gt;
analysis.results&lt;br /&gt;
analysis.matched_count&lt;br /&gt;
analysis.expression_count&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
RegexMilestoneAnalysisEvent(... matched_count=3, expression_count=3)&lt;br /&gt;
(RegexMilestoneResult(...), RegexMilestoneResult(...), RegexMilestoneResult(...))&lt;br /&gt;
3&lt;br /&gt;
3&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the derived analysis event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis_message = analysis_receiver.receive()&lt;br /&gt;
&lt;br /&gt;
analysis_message.msg_topic&lt;br /&gt;
analysis_message.msg_type&lt;br /&gt;
analysis_message.msg_producer&lt;br /&gt;
analysis_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;demo.shell.regex_milestone&#039;&lt;br /&gt;
&#039;regex-milestone-analysis&#039;&lt;br /&gt;
&#039;regex-milestone-processor&#039;&lt;br /&gt;
RegexMilestoneAnalysisEvent(... matched_count=3, expression_count=3)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
messages = history.messages()&lt;br /&gt;
&lt;br /&gt;
for message in messages:&lt;br /&gt;
    print(message.bus_sequence, message.msg_topic, message.msg_type)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
0 demo.shell.command shell-command&lt;br /&gt;
1 demo.shell.regex_milestone regex-milestone-analysis&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The regex milestone processor consumes one event and emits another. The derived analysis event is available to any later subscriber. History contains both the source command event and the derived analysis event.&lt;br /&gt;
&lt;br /&gt;
== Phase 7: Explore why shell reconstruction is separate ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Show that raw TTY observations are not the same thing as high-level shell command events.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Regex milestones should inspect high-level command events.&lt;br /&gt;
* TTY reads, TTY writes, and line-discipline lines are lower-level observations.&lt;br /&gt;
* A shell command event is reconstructed by correlating those observations.&lt;br /&gt;
* Keeping reconstruction separate prevents the milestone processor from becoming a large TTY parser.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Create a few low-level observations:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    LineDisciplineEvent,&lt;br /&gt;
    TTYReadEvent,&lt;br /&gt;
    TTYWriteEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_1 = TTYReadEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=0,&lt;br /&gt;
    timestamp_ns=1000,&lt;br /&gt;
    data=b&amp;quot;l&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_2 = TTYReadEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=1,&lt;br /&gt;
    timestamp_ns=1100,&lt;br /&gt;
    data=b&amp;quot;s&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
line = LineDisciplineEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=2,&lt;br /&gt;
    timestamp_ns=1200,&lt;br /&gt;
    line=&amp;quot;ls\n&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
write = TTYWriteEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=3,&lt;br /&gt;
    timestamp_ns=1600,&lt;br /&gt;
    data=b&amp;quot;notes.txt\n&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the raw pieces:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_1.data&lt;br /&gt;
read_2.data&lt;br /&gt;
line.line&lt;br /&gt;
write.data&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
b&#039;l&#039;&lt;br /&gt;
b&#039;s&#039;&lt;br /&gt;
&#039;ls\n&#039;&lt;br /&gt;
b&#039;notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Assemble command fields manually:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
input_text = line.line&lt;br /&gt;
output_text = write.data.decode(&amp;quot;utf-8&amp;quot;, errors=&amp;quot;replace&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
input_text&lt;br /&gt;
output_text&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;ls\n&#039;&lt;br /&gt;
&#039;notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The useful command event is derived from several lower-level observations. A later shell reconstructor can subscribe to line-discipline events, query recent reads/writes/timing observations, and emit &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; records.&lt;br /&gt;
&lt;br /&gt;
== Phase 8: Add keystroke timing as a smaller TTY-derived processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a simple stateful processor before attempting full shell reconstruction.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Timing requires comparing a TTY read with the previous read in the same session.&lt;br /&gt;
* A derived timing event makes the result explicit.&lt;br /&gt;
* Other processors can consume timing events instead of recalculating deltas.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration before the file edit&lt;br /&gt;
&lt;br /&gt;
Calculate timing manually from the two read events:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_events = [read_1, read_2]&lt;br /&gt;
previous_timestamp = None&lt;br /&gt;
timing_rows = []&lt;br /&gt;
&lt;br /&gt;
for event in read_events:&lt;br /&gt;
    delta = None&lt;br /&gt;
    if previous_timestamp is not None:&lt;br /&gt;
        delta = event.timestamp_ns - previous_timestamp&lt;br /&gt;
    timing_rows.append((event.source_index, previous_timestamp, delta))&lt;br /&gt;
    previous_timestamp = event.timestamp_ns&lt;br /&gt;
&lt;br /&gt;
timing_rows&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, None, None), (1, 1000, 100)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/keystroke_timing.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/keystroke_timing.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Keystroke timing processor for TTY read events.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    KeystrokeTimingEvent,&lt;br /&gt;
    TTYReadEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class KeystrokeTimingProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _last_timestamp_by_session: dict[str, int]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.tty.read&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;tty-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;tty-read&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.keystroke.timing&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._last_timestamp_by_session = {}&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; KeystrokeTimingEvent:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        event = message.payload&lt;br /&gt;
&lt;br /&gt;
        if not isinstance(event, TTYReadEvent):&lt;br /&gt;
            raise TypeError(f&amp;quot;expected TTYReadEvent, got {type(event).__name__}&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
        previous_timestamp = self._last_timestamp_by_session.get(&lt;br /&gt;
            event.session_id&lt;br /&gt;
        )&lt;br /&gt;
        delta = None&lt;br /&gt;
&lt;br /&gt;
        if previous_timestamp is not None:&lt;br /&gt;
            delta = event.timestamp_ns - previous_timestamp&lt;br /&gt;
&lt;br /&gt;
        self._last_timestamp_by_session[event.session_id] = event.timestamp_ns&lt;br /&gt;
&lt;br /&gt;
        text = event.data.decode(&amp;quot;utf-8&amp;quot;, errors=&amp;quot;replace&amp;quot;)&lt;br /&gt;
        timing = KeystrokeTimingEvent(&lt;br /&gt;
            session_id=event.session_id,&lt;br /&gt;
            source_index=event.source_index,&lt;br /&gt;
            timestamp_ns=event.timestamp_ns,&lt;br /&gt;
            previous_timestamp_ns=previous_timestamp,&lt;br /&gt;
            delta_ns=delta,&lt;br /&gt;
            text=text,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter.emit(timing)&lt;br /&gt;
        return timing&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
from demos.event_processors.events import TTYReadEvent&lt;br /&gt;
from demos.event_processors.keystroke_timing import KeystrokeTimingProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
timing_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.keystroke.timing&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.tty.read&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;tty-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;tty-read&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = KeystrokeTimingProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit reads and process them:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_emitter.emit(&lt;br /&gt;
    TTYReadEvent(&lt;br /&gt;
        session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
        source_index=0,&lt;br /&gt;
        timestamp_ns=1000,&lt;br /&gt;
        data=b&amp;quot;l&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_emitter.emit(&lt;br /&gt;
    TTYReadEvent(&lt;br /&gt;
        session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
        source_index=1,&lt;br /&gt;
        timestamp_ns=1100,&lt;br /&gt;
        data=b&amp;quot;s&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor.process_one()&lt;br /&gt;
processor.process_one()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeystrokeTimingEvent(... previous_timestamp_ns=None, delta_ns=None, text=&#039;l&#039;)&lt;br /&gt;
KeystrokeTimingEvent(... previous_timestamp_ns=1000, delta_ns=100, text=&#039;s&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive timing events:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
timing_receiver.receive().payload&lt;br /&gt;
timing_receiver.receive().payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeystrokeTimingEvent(... source_index=0 ...)&lt;br /&gt;
KeystrokeTimingEvent(... source_index=1 ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 9: Introduce graph events ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Switch to a small graph problem that can demonstrate safe feedback loops in an illustratable form.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* An edge is a source fact.&lt;br /&gt;
* A path is a derived fact.&lt;br /&gt;
* A direct edge implies a direct path.&lt;br /&gt;
* Longer paths can be derived later from known paths and edges.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_events.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/graph_events.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Graph event payloads for message bus demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from dataclasses import dataclass&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class EdgeDeclaredEvent:&lt;br /&gt;
    graph_id: str&lt;br /&gt;
    source: str&lt;br /&gt;
    target: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class PathKnownEvent:&lt;br /&gt;
    graph_id: str&lt;br /&gt;
    source: str&lt;br /&gt;
    target: str&lt;br /&gt;
    hop_count: int&lt;br /&gt;
&lt;br /&gt;
    def fact_key(self) -&amp;gt; tuple[str, str, str]:&lt;br /&gt;
        result = (self.graph_id, self.source, self.target)&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge = EdgeDeclaredEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;A&amp;quot;,&lt;br /&gt;
    target=&amp;quot;B&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path = PathKnownEvent(&lt;br /&gt;
    graph_id=edge.graph_id,&lt;br /&gt;
    source=edge.source,&lt;br /&gt;
    target=edge.target,&lt;br /&gt;
    hop_count=1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge&lt;br /&gt;
path&lt;br /&gt;
path.fact_key()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
EdgeDeclaredEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;)&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;, hop_count=1)&lt;br /&gt;
(&#039;demo-graph&#039;, &#039;A&#039;, &#039;B&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 10: Create a demo-only nonblocking receive helper ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Prepare for processor scheduling without editing &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;Receiver.receive()&amp;lt;/code&amp;gt; blocks until a message is available.&lt;br /&gt;
* A scheduler needs to try a processor and move on if no message is waiting.&lt;br /&gt;
* The current package does not expose a public nonblocking receiver method.&lt;br /&gt;
* This helper is local demonstration code. It can be replaced by a real public API later.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/demo_receive.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/demo_receive.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Demo-only helpers for receiver inspection and scheduling.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from queue import Empty&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.endpoints import Receiver&lt;br /&gt;
from mbus.message.records import ReceivedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class DemoReceiverError(RuntimeError):&lt;br /&gt;
    pass&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def try_receive(receiver: Receiver) -&amp;gt; ReceivedMessage | None:&lt;br /&gt;
    queue = getattr(receiver, &amp;quot;_queue&amp;quot;, None)&lt;br /&gt;
&lt;br /&gt;
    if queue is None:&lt;br /&gt;
        raise DemoReceiverError(&lt;br /&gt;
            &amp;quot;demo try_receive requires the current direct broker receiver&amp;quot;&lt;br /&gt;
        )&lt;br /&gt;
&lt;br /&gt;
    try:&lt;br /&gt;
        message = queue.get_nowait()&lt;br /&gt;
    except Empty:&lt;br /&gt;
        message = None&lt;br /&gt;
&lt;br /&gt;
    return message&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.demo_receive import try_receive&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
try_receive(receiver)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
None&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Now emit a message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;demo nonblocking receive&amp;quot;})&lt;br /&gt;
message = try_receive(receiver)&lt;br /&gt;
&lt;br /&gt;
message&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ReceivedMessage(...)&lt;br /&gt;
{&#039;text&#039;: &#039;demo nonblocking receive&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 11: Create the direct path processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Implement the graph rule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
edge(A, B) -&amp;gt; path(A, B)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The source emits edge events.&lt;br /&gt;
* The direct path processor emits derived path events.&lt;br /&gt;
* Other processors can subscribe to derived path events.&lt;br /&gt;
* The processor can be run by hand or by a scheduler.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_reachability.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/graph_reachability.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Graph reachability processors for message bus demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.demo_receive import try_receive&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class DirectPathProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _known_paths: set[tuple[str, str, str]]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._known_paths = set()&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; PathKnownEvent | None:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        result = self._process_payload(message.payload)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
    def process_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            self._process_payload(message.payload)&lt;br /&gt;
            work_count = 1&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def _process_payload(self, payload: object) -&amp;gt; PathKnownEvent | None:&lt;br /&gt;
        if not isinstance(payload, EdgeDeclaredEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected EdgeDeclaredEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        path = PathKnownEvent(&lt;br /&gt;
            graph_id=payload.graph_id,&lt;br /&gt;
            source=payload.source,&lt;br /&gt;
            target=payload.target,&lt;br /&gt;
            hop_count=1,&lt;br /&gt;
        )&lt;br /&gt;
&lt;br /&gt;
        if path.fact_key() not in self._known_paths:&lt;br /&gt;
            self._known_paths.add(path.fact_key())&lt;br /&gt;
            self._emitter.emit(path)&lt;br /&gt;
            result = path&lt;br /&gt;
        else:&lt;br /&gt;
            result = None&lt;br /&gt;
&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
from demos.event_processors.graph_events import EdgeDeclaredEvent&lt;br /&gt;
from demos.event_processors.graph_reachability import DirectPathProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
path_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = DirectPathProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Call the processor before any edge exists:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
0&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit one edge:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;A&amp;quot;,&lt;br /&gt;
        target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the edge:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the derived path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_message = path_receiver.receive()&lt;br /&gt;
path_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;, hop_count=1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 12: Explore the path extension rule by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Understand the feedback rule before implementing it.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* If &amp;lt;code&amp;gt;A&amp;lt;/code&amp;gt; reaches &amp;lt;code&amp;gt;B&amp;lt;/code&amp;gt;, and there is an edge from &amp;lt;code&amp;gt;B&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;C&amp;lt;/code&amp;gt;, then &amp;lt;code&amp;gt;A&amp;lt;/code&amp;gt; reaches &amp;lt;code&amp;gt;C&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Derived path facts can lead to more derived path facts.&lt;br /&gt;
* Duplicate prevention keeps the feedback loop from repeating the same fact forever.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_bc = EdgeDeclaredEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;B&amp;quot;,&lt;br /&gt;
    target=&amp;quot;C&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path_ab = PathKnownEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;A&amp;quot;,&lt;br /&gt;
    target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    hop_count=1,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Check whether the path and edge connect:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_ab.target&lt;br /&gt;
edge_bc.source&lt;br /&gt;
path_ab.target == edge_bc.source&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;B&#039;&lt;br /&gt;
&#039;B&#039;&lt;br /&gt;
True&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Derive a new path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_ac = PathKnownEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=path_ab.source,&lt;br /&gt;
    target=edge_bc.target,&lt;br /&gt;
    hop_count=path_ab.hop_count + 1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path_ac&lt;br /&gt;
path_ac.fact_key()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;C&#039;, hop_count=2)&lt;br /&gt;
(&#039;demo-graph&#039;, &#039;A&#039;, &#039;C&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Manually prevent duplicate path facts:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
known_paths = set()&lt;br /&gt;
known_paths.add(path_ab.fact_key())&lt;br /&gt;
&lt;br /&gt;
path_ac.fact_key() in known_paths&lt;br /&gt;
&lt;br /&gt;
if path_ac.fact_key() not in known_paths:&lt;br /&gt;
    known_paths.add(path_ac.fact_key())&lt;br /&gt;
&lt;br /&gt;
known_paths&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
False&lt;br /&gt;
{(&#039;demo-graph&#039;, &#039;A&#039;, &#039;B&#039;), (&#039;demo-graph&#039;, &#039;A&#039;, &#039;C&#039;)}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 13: Add the path extension processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Implement the feedback rule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
path(A, B) + edge(B, C) -&amp;gt; path(A, C)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The extension processor listens to edge events and path events.&lt;br /&gt;
* It emits new path events when a known path can be extended.&lt;br /&gt;
* It also listens to its own output because an extended path may be extendable again.&lt;br /&gt;
* This is a controlled feedback loop.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Append this class to:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_reachability.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
class PathExtensionProcessor:&lt;br /&gt;
    _edge_receiver: Receiver&lt;br /&gt;
    _direct_path_receiver: Receiver&lt;br /&gt;
    _extension_path_receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _edges: set[tuple[str, str, str]]&lt;br /&gt;
    _paths: dict[tuple[str, str, str], PathKnownEvent]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._edge_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._direct_path_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._extension_path_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._edges = set()&lt;br /&gt;
        self._paths = {}&lt;br /&gt;
&lt;br /&gt;
    def process_edge_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._edge_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_edge_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def process_direct_path_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._direct_path_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_path_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def process_extension_path_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._extension_path_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_path_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def _process_edge_payload(&lt;br /&gt;
        self,&lt;br /&gt;
        payload: object,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        if not isinstance(payload, EdgeDeclaredEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected EdgeDeclaredEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        edge = payload&lt;br /&gt;
        self._edges.add((edge.graph_id, edge.source, edge.target))&lt;br /&gt;
        paths = self._derive_from_new_edge(edge)&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _process_path_payload(&lt;br /&gt;
        self,&lt;br /&gt;
        payload: object,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        if not isinstance(payload, PathKnownEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected PathKnownEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        path = payload&lt;br /&gt;
        self._paths[path.fact_key()] = path&lt;br /&gt;
        paths = self._derive_from_new_path(path)&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _derive_from_new_edge(&lt;br /&gt;
        self,&lt;br /&gt;
        edge: EdgeDeclaredEvent,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        paths = []&lt;br /&gt;
&lt;br /&gt;
        for known_path in self._paths.values():&lt;br /&gt;
            if known_path.graph_id != edge.graph_id:&lt;br /&gt;
                continue&lt;br /&gt;
            if known_path.target != edge.source:&lt;br /&gt;
                continue&lt;br /&gt;
&lt;br /&gt;
            next_path = PathKnownEvent(&lt;br /&gt;
                graph_id=edge.graph_id,&lt;br /&gt;
                source=known_path.source,&lt;br /&gt;
                target=edge.target,&lt;br /&gt;
                hop_count=known_path.hop_count + 1,&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
            if self._emit_if_new(next_path):&lt;br /&gt;
                paths.append(next_path)&lt;br /&gt;
&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _derive_from_new_path(&lt;br /&gt;
        self,&lt;br /&gt;
        path: PathKnownEvent,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        paths = []&lt;br /&gt;
&lt;br /&gt;
        for graph_id, edge_source, edge_target in self._edges:&lt;br /&gt;
            if graph_id != path.graph_id:&lt;br /&gt;
                continue&lt;br /&gt;
            if edge_source != path.target:&lt;br /&gt;
                continue&lt;br /&gt;
&lt;br /&gt;
            next_path = PathKnownEvent(&lt;br /&gt;
                graph_id=path.graph_id,&lt;br /&gt;
                source=path.source,&lt;br /&gt;
                target=edge_target,&lt;br /&gt;
                hop_count=path.hop_count + 1,&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
            if self._emit_if_new(next_path):&lt;br /&gt;
                paths.append(next_path)&lt;br /&gt;
&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _emit_if_new(self, path: PathKnownEvent) -&amp;gt; bool:&lt;br /&gt;
        if path.fact_key() in self._paths:&lt;br /&gt;
            result = False&lt;br /&gt;
        else:&lt;br /&gt;
            self._paths[path.fact_key()] = path&lt;br /&gt;
            self._emitter.emit(path)&lt;br /&gt;
            result = True&lt;br /&gt;
&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_events import EdgeDeclaredEvent&lt;br /&gt;
from demos.event_processors.graph_reachability import (&lt;br /&gt;
    DirectPathProcessor,&lt;br /&gt;
    PathExtensionProcessor,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
extension_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
direct_processor = DirectPathProcessor(bus)&lt;br /&gt;
extension_processor = PathExtensionProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit two edges in fixed order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;A&amp;quot;,&lt;br /&gt;
        target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;B&amp;quot;,&lt;br /&gt;
        target=&amp;quot;C&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the direct paths:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
direct_processor.process_available()&lt;br /&gt;
direct_processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Let the extension processor learn the edges:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
extension_processor.process_edge_available()&lt;br /&gt;
extension_processor.process_edge_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Let the extension processor learn the direct paths:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
extension_processor.process_direct_path_available()&lt;br /&gt;
extension_processor.process_direct_path_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The second call may emit the extended &amp;lt;code&amp;gt;A -&amp;gt; C&amp;lt;/code&amp;gt; path after the processor has seen both direct paths and both edges.&lt;br /&gt;
&lt;br /&gt;
Receive the extended path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
message = extension_receiver.receive()&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;C&#039;, hop_count=2)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect captured graph path history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
path_messages = history.by_topic(&amp;quot;demo.graph.path&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
for message in path_messages:&lt;br /&gt;
    path = message.payload&lt;br /&gt;
    print(&lt;br /&gt;
        message.bus_sequence,&lt;br /&gt;
        message.msg_producer,&lt;br /&gt;
        path.source,&lt;br /&gt;
        path.target,&lt;br /&gt;
        path.hop_count,&lt;br /&gt;
    )&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
... direct-path A B 1&lt;br /&gt;
... direct-path B C 1&lt;br /&gt;
... path-extension A C 2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 14: Add a randomized processor scheduler ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Simulate concurrency effects without changing source input order.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The source will emit the same edge events in the same order every run.&lt;br /&gt;
* The scheduler varies which processor gets a chance to run first.&lt;br /&gt;
* This simulates uncertainty from processing time, scheduling priority, startup timing, or transmission delay.&lt;br /&gt;
* The graph example should converge to the same final path facts even when processor scheduling, and hence the order of intermediate derived processor events, differs.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/runners.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/runners.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Small processor runners for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from collections.abc import Callable&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
from random import Random&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class ProcessorStep:&lt;br /&gt;
    name: str&lt;br /&gt;
    run: Callable[[], int]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class RandomProcessorRunner:&lt;br /&gt;
    steps: tuple[ProcessorStep, ...]&lt;br /&gt;
    seed: int&lt;br /&gt;
    max_rounds: int = 30&lt;br /&gt;
    trace: list[tuple[int, str, int]] = field(default_factory=list)&lt;br /&gt;
&lt;br /&gt;
    def run_until_quiet(self) -&amp;gt; list[tuple[int, str, int]]:&lt;br /&gt;
        rng = Random(self.seed)&lt;br /&gt;
&lt;br /&gt;
        for round_index in range(self.max_rounds):&lt;br /&gt;
            work_count = self._run_round(rng, round_index)&lt;br /&gt;
&lt;br /&gt;
            if work_count == 0:&lt;br /&gt;
                break&lt;br /&gt;
&lt;br /&gt;
        result = list(self.trace)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
    def _run_round(self, rng: Random, round_index: int) -&amp;gt; int:&lt;br /&gt;
        steps = list(self.steps)&lt;br /&gt;
        rng.shuffle(steps)&lt;br /&gt;
        round_work_count = 0&lt;br /&gt;
&lt;br /&gt;
        for step in steps:&lt;br /&gt;
            step_work_count = step.run()&lt;br /&gt;
            self.trace.append((round_index, step.name, step_work_count))&lt;br /&gt;
            round_work_count += step_work_count&lt;br /&gt;
&lt;br /&gt;
        return round_work_count&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then inspect how a runner can shuffle placeholder steps:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.runners import (&lt;br /&gt;
    ProcessorStep,&lt;br /&gt;
    RandomProcessorRunner,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
calls = []&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def first_step() -&amp;gt; int:&lt;br /&gt;
    calls.append(&amp;quot;first&amp;quot;)&lt;br /&gt;
    return 0&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def second_step() -&amp;gt; int:&lt;br /&gt;
    calls.append(&amp;quot;second&amp;quot;)&lt;br /&gt;
    return 0&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
runner = RandomProcessorRunner(&lt;br /&gt;
    steps=(&lt;br /&gt;
        ProcessorStep(name=&amp;quot;first&amp;quot;, run=first_step),&lt;br /&gt;
        ProcessorStep(name=&amp;quot;second&amp;quot;, run=second_step),&lt;br /&gt;
    ),&lt;br /&gt;
    seed=1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
runner.run_until_quiet()&lt;br /&gt;
calls&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, &#039;...&#039;, 0), (0, &#039;...&#039;, 0)]&lt;br /&gt;
[&#039;...&#039;, &#039;...&#039;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 15: Run the graph example with randomized scheduling ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Run the same graph input under different processor schedules and compare the final path facts.&lt;br /&gt;
&lt;br /&gt;
; Interpreter setup&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_reachability import (&lt;br /&gt;
    DirectPathProcessor,&lt;br /&gt;
    PathExtensionProcessor,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.runners import (&lt;br /&gt;
    ProcessorStep,&lt;br /&gt;
    RandomProcessorRunner,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Define a helper function in the interpreter:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
def run_graph_demo(seed: int) -&amp;gt; tuple[&lt;br /&gt;
    list[tuple[int, str, int]],&lt;br /&gt;
    list[tuple[int, str, str, int]],&lt;br /&gt;
]:&lt;br /&gt;
    bus = DirectMessageBus()&lt;br /&gt;
    sink = AOStoreCaptureSink()&lt;br /&gt;
    bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
    edge_emitter = bus.register_emitter(&lt;br /&gt;
        msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
        msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
        default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    direct_processor = DirectPathProcessor(bus)&lt;br /&gt;
    extension_processor = PathExtensionProcessor(bus)&lt;br /&gt;
&lt;br /&gt;
    edges = (&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;A&amp;quot;,&lt;br /&gt;
            target=&amp;quot;B&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;B&amp;quot;,&lt;br /&gt;
            target=&amp;quot;C&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;C&amp;quot;,&lt;br /&gt;
            target=&amp;quot;D&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    for edge in edges:&lt;br /&gt;
        edge_emitter.emit(edge)&lt;br /&gt;
&lt;br /&gt;
    steps = (&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;direct path from edge&amp;quot;,&lt;br /&gt;
            run=direct_processor.process_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns edge&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_edge_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns direct path&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_direct_path_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns extension path&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_extension_path_available,&lt;br /&gt;
        ),&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    runner = RandomProcessorRunner(&lt;br /&gt;
        steps=steps,&lt;br /&gt;
        seed=seed,&lt;br /&gt;
    )&lt;br /&gt;
    trace = runner.run_until_quiet()&lt;br /&gt;
&lt;br /&gt;
    history = CaptureHistory(sink.all_records())&lt;br /&gt;
    path_facts = []&lt;br /&gt;
&lt;br /&gt;
    for message in history.by_topic(&amp;quot;demo.graph.path&amp;quot;):&lt;br /&gt;
        path = message.payload&lt;br /&gt;
        if isinstance(path, PathKnownEvent):&lt;br /&gt;
            fact = (&lt;br /&gt;
                message.bus_sequence,&lt;br /&gt;
                path.source,&lt;br /&gt;
                path.target,&lt;br /&gt;
                path.hop_count,&lt;br /&gt;
            )&lt;br /&gt;
            path_facts.append(fact)&lt;br /&gt;
&lt;br /&gt;
    result = (trace, path_facts)&lt;br /&gt;
    return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Run one schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_1, paths_1 = run_graph_demo(1)&lt;br /&gt;
&lt;br /&gt;
trace_1&lt;br /&gt;
paths_1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, &#039;...&#039;, ...), (0, &#039;...&#039;, ...), ...]&lt;br /&gt;
[(..., &#039;A&#039;, &#039;B&#039;, 1), (..., &#039;B&#039;, &#039;C&#039;, 1), (..., &#039;C&#039;, &#039;D&#039;, 1), ...]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Run another schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_2, paths_2 = run_graph_demo(2)&lt;br /&gt;
&lt;br /&gt;
trace_2&lt;br /&gt;
paths_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the schedules:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_1 == trace_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
False&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the final path facts without bus sequence numbers:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
final_paths_1 = {&lt;br /&gt;
    (source, target, hop_count)&lt;br /&gt;
    for _, source, target, hop_count in paths_1&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
final_paths_2 = {&lt;br /&gt;
    (source, target, hop_count)&lt;br /&gt;
    for _, source, target, hop_count in paths_2&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
final_paths_1&lt;br /&gt;
final_paths_2&lt;br /&gt;
final_paths_1 == final_paths_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
{(&#039;A&#039;, &#039;B&#039;, 1), (&#039;B&#039;, &#039;C&#039;, 1), (&#039;C&#039;, &#039;D&#039;, 1), (&#039;A&#039;, &#039;C&#039;, 2), (&#039;B&#039;, &#039;D&#039;, 2), (&#039;A&#039;, &#039;D&#039;, 3)}&lt;br /&gt;
{(&#039;A&#039;, &#039;B&#039;, 1), (&#039;B&#039;, &#039;C&#039;, 1), (&#039;C&#039;, &#039;D&#039;, 1), (&#039;A&#039;, &#039;C&#039;, 2), (&#039;B&#039;, &#039;D&#039;, 2), (&#039;A&#039;, &#039;D&#039;, 3)}&lt;br /&gt;
True&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for round_index, step_name, work_count in trace_1:&lt;br /&gt;
    print(round_index, step_name, work_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first captured path order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for bus_sequence, source, target, hop_count in paths_1:&lt;br /&gt;
    print(bus_sequence, source, target, hop_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the second schedule and path order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for round_index, step_name, work_count in trace_2:&lt;br /&gt;
    print(round_index, step_name, work_count)&lt;br /&gt;
&lt;br /&gt;
for bus_sequence, source, target, hop_count in paths_2:&lt;br /&gt;
    print(bus_sequence, source, target, hop_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The source input order did not change. The processor scheduling changed. The captured event order may differ. The final reachability facts should match.&lt;br /&gt;
&lt;br /&gt;
This models an important distributed-systems lesson: event history order and final derived state are related, but they are not the same thing.&lt;br /&gt;
&lt;br /&gt;
== Phase 16: Later refinement: graph index from history ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Introduce restart/recovery thinking only after the basic graph feedback example is familiar.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The first graph processors keep local working state.&lt;br /&gt;
* Local working state is enough for a short demonstration.&lt;br /&gt;
* Longer-lived processors need recovery behavior after restart.&lt;br /&gt;
* Since edge and path facts are captured as events, a graph index can later be rebuilt from history.&lt;br /&gt;
* This points toward projection-style services without changing the basic processor model.&lt;br /&gt;
&lt;br /&gt;
; Suggested later file&lt;br /&gt;
&lt;br /&gt;
Create later:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_index.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Suggested responsibilities:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
read captured history&lt;br /&gt;
collect EdgeDeclaredEvent payloads&lt;br /&gt;
collect PathKnownEvent payloads&lt;br /&gt;
answer whether a path fact exists&lt;br /&gt;
find edges starting at a node&lt;br /&gt;
find paths ending at a node&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This refinement belongs after the team has already run the graph reachability demo successfully.&lt;br /&gt;
&lt;br /&gt;
== Phase 17: Later refinement: shell command reconstruction ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Connect the TTY examples back to the shell command event shape used by milestones.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; is the high-level event consumed by the milestone processor.&lt;br /&gt;
* &amp;lt;code&amp;gt;TTYReadEvent&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;TTYWriteEvent&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;LineDisciplineEvent&amp;lt;/code&amp;gt; are lower-level observations.&lt;br /&gt;
* A reconstructor can subscribe to line-discipline events and query recent related observations.&lt;br /&gt;
* The reconstructor should emit shell command events; it should not perform regex analysis itself.&lt;br /&gt;
&lt;br /&gt;
; Suggested responsibilities&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
listen for completed line-discipline events&lt;br /&gt;
query history since the previous command boundary for the same session&lt;br /&gt;
collect matching reads, writes, and timing events&lt;br /&gt;
construct ShellCommandEvent&lt;br /&gt;
emit ShellCommandEvent on demo.shell.command&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This is an appropriate follow-up exercise after the team understands both milestone processing and history queries.&lt;br /&gt;
&lt;br /&gt;
= Related =&lt;br /&gt;
&lt;br /&gt;
== Top-level topic page ==&lt;br /&gt;
[[Message Bus Service]]&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=Message_Bus_Live_Coding_Exercise&amp;diff=938</id>
		<title>Message Bus Live Coding Exercise</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=Message_Bus_Live_Coding_Exercise&amp;diff=938"/>
		<updated>2026-06-16T04:56:50Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* Phase 3: Explore shell command data by hand */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Introduction =&lt;br /&gt;
&lt;br /&gt;
== Audience and scope ==&lt;br /&gt;
&lt;br /&gt;
This exercise is intended for EDURange developers learning how to write event processors with the message bus.&lt;br /&gt;
&lt;br /&gt;
The packaged &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; modules are treated as read-only for this exercise. All new code belongs under a demonstration directory such as:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
workspace/&lt;br /&gt;
    mbus/&lt;br /&gt;
    aostore/&lt;br /&gt;
    demos/&lt;br /&gt;
        __init__.py&lt;br /&gt;
        event_processors/&lt;br /&gt;
            __init__.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Pre-release copies of &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; are available on the Discord fileshare channel. Modules may be released under other package names; see also the EDURange GitHub.&lt;br /&gt;
&lt;br /&gt;
The exercise uses two problem families.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;1. Shell and TTY event processing&#039;&#039;&#039;&lt;br /&gt;
** Send and receive messages through the bus.&lt;br /&gt;
** Capture and inspect message history.&lt;br /&gt;
** Represent high-level shell command events.&lt;br /&gt;
** Recreate the regex-based milestone role in a simplified form.&lt;br /&gt;
** Mock up TTY-derived observations such as keystroke timing.&lt;br /&gt;
* &#039;&#039;&#039;2. Graph event processing&#039;&#039;&#039;&lt;br /&gt;
** Represent graph edge relationships as source events.&lt;br /&gt;
** Derive direct path facts from edge relation events.&lt;br /&gt;
** Add feedback by deriving longer paths from known paths.&lt;br /&gt;
** Simulate nondeterministic processor scheduling while keeping source input fixed.&lt;br /&gt;
&lt;br /&gt;
The shell examples draw from EDURange&#039;s institutional knowledge. The graph examples provide a common model problem family for meaningful use of feedback loops.&lt;br /&gt;
&lt;br /&gt;
== Running the examples ==&lt;br /&gt;
&lt;br /&gt;
Adjust paths to match the local checkout layout if necessary. Start an interpreter from the workspace root with project packages available.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
PYTHONPATH=&amp;quot;$PWD/mbus:$PWD/aostore:$PWD&amp;quot; python3.14&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
When module files are changed, restart the interpreter unless the group is deliberately practicing &amp;lt;code&amp;gt;importlib.reload()&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Create the demonstration package ==&lt;br /&gt;
&lt;br /&gt;
Before the first durable file edit, create the demonstration package directories:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir -p demos/event_processors&lt;br /&gt;
touch demos/__init__.py&lt;br /&gt;
touch demos/event_processors/__init__.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
== Important note about capture records ==&lt;br /&gt;
&lt;br /&gt;
The current demonstration works close to the low-level capture layer. As a result, it exposes &amp;lt;code&amp;gt;MessageSymbolRegistration&amp;lt;/code&amp;gt; records while building the temporary history helper.&lt;br /&gt;
&lt;br /&gt;
That is not the intended ordinary user experience. In a more complete release, higher-level capture and history infrastructure should handle symbol registration bookkeeping on behalf of processor authors. In this exercise, symbol registration records are visible because the demonstration history helper reconstructs readable messages from compact captured records directly.&lt;br /&gt;
&lt;br /&gt;
== Suggested group session flow ==&lt;br /&gt;
&lt;br /&gt;
; Session 1 - Bus and capture basics&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 1:&#039;&#039;&#039;&lt;br /&gt;
** Signal over the bus by hand.&lt;br /&gt;
** Inspect received messages.&lt;br /&gt;
** Inspect low-level captured records.&lt;br /&gt;
* &#039;&#039;&#039;Phase 2:&#039;&#039;&#039;&lt;br /&gt;
** Discuss why symbol registration appears in this temporary demo.&lt;br /&gt;
** Build the capture history helper.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1-3 hours (1-2 hours work, 0.5-1 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 2 - Shell commands and milestones&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 3:&#039;&#039;&#039;&lt;br /&gt;
** Represent a shell command as a dictionary.&lt;br /&gt;
* &#039;&#039;&#039;Phase 4:&#039;&#039;&#039;&lt;br /&gt;
** Replace it with &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Phase 5:&#039;&#039;&#039;&lt;br /&gt;
** Manually evaluate milestone expressions.&lt;br /&gt;
* &#039;&#039;&#039;Phase 6:&#039;&#039;&#039;&lt;br /&gt;
** Implement &amp;lt;code&amp;gt;RegexMilestoneProcessor&amp;lt;/code&amp;gt;.&lt;br /&gt;
** Inspect source and analysis events in history.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3 hours (1.5-2.5 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 3 - TTY-derived observations&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 7:&#039;&#039;&#039;&lt;br /&gt;
** Create raw TTY read, write, and line events.&lt;br /&gt;
** Show why milestones need reconstructed shell command events.&lt;br /&gt;
* &#039;&#039;&#039;Phase 8:&#039;&#039;&#039;&lt;br /&gt;
** Implement keystroke timing.&lt;br /&gt;
** Discuss shell command reconstruction as the next derived-event problem (to be continued in Session 6 onward).&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1-2.5 hours (1-2 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 4 - Graph-derived facts&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 9:&#039;&#039;&#039;&lt;br /&gt;
** Define graph edges and paths.&lt;br /&gt;
* &#039;&#039;&#039;Phase 10:&#039;&#039;&#039;&lt;br /&gt;
** Add the demo-only nonblocking receive helper.&lt;br /&gt;
* &#039;&#039;&#039;Phase 11:&#039;&#039;&#039;&lt;br /&gt;
** Implement direct path derivation.&lt;br /&gt;
* &#039;&#039;&#039;Phase 12:&#039;&#039;&#039;&lt;br /&gt;
** Explore the extension rule by hand.&lt;br /&gt;
* &#039;&#039;&#039;Phase 13:&#039;&#039;&#039;&lt;br /&gt;
** Add path extension feedback.&lt;br /&gt;
** Discuss duplicate prevention.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3.5 hours (1.5-3 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 5 - Nondeterministic processor scheduling&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 14:&#039;&#039;&#039;&lt;br /&gt;
** Add the randomized processor runner.&lt;br /&gt;
* &#039;&#039;&#039;Phase 15:&#039;&#039;&#039;&lt;br /&gt;
** Keep source events fixed.&lt;br /&gt;
** Vary processor scheduling by seed.&lt;br /&gt;
** Compare final path facts across runs.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3.5 hours (1.5-3 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 6 - Follow-up refinements&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 16:&#039;&#039;&#039;&lt;br /&gt;
** Rebuild a graph index from history.&lt;br /&gt;
* &#039;&#039;&#039;Phase 17:&#039;&#039;&#039;&lt;br /&gt;
** Implement shell command reconstruction.&lt;br /&gt;
** Replace local Python payload assumptions with explicit payload projection formats when the bus format layer is ready for the exercise.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-4.5 hours (1.5-4 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
= Event Processor Demonstration Plan =&lt;br /&gt;
&lt;br /&gt;
== Phase 1: Signal over the bus by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Send one message through the direct bus and inspect what the receiver sees.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* A bus is useful only when something emits and something receives.&lt;br /&gt;
* Public message setup uses readable strings: topic, producer, and message type.&lt;br /&gt;
* The direct broker delivers local Python payloads in this demonstration.&lt;br /&gt;
* Capture must be attached before delivery in the current prototype.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Import the bus and capture interfaces:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.capture.sink import CaptureSink&lt;br /&gt;
from mbus.message.records import CapturedMessage&lt;br /&gt;
from mbus.message.symbols import MessageSymbolRegistration&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create a temporary capture sink in the interpreter. This sink stores whatever the bus sends to the capture layer:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
CapturedRecord = MessageSymbolRegistration | CapturedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class ListCaptureSink(CaptureSink):&lt;br /&gt;
    records: list[CapturedRecord] = field(default_factory=list)&lt;br /&gt;
&lt;br /&gt;
    def capture_symbol_registration(&lt;br /&gt;
        self,&lt;br /&gt;
        registration: MessageSymbolRegistration,&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self.records.append(registration)&lt;br /&gt;
&lt;br /&gt;
    def capture(self, message: CapturedMessage) -&amp;gt; None:&lt;br /&gt;
        self.records.append(message)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create the bus and attach capture:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = ListCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the empty capture sink:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
sink&lt;br /&gt;
sink.records&lt;br /&gt;
len(sink.records)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ListCaptureSink(records=[])&lt;br /&gt;
[]&lt;br /&gt;
0&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create a receiver. This describes the messages the receiver wants to hear:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the receiver and confirm that capture is still empty:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
type(receiver)&lt;br /&gt;
vars(receiver)&lt;br /&gt;
sink.records&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;class &#039;mbus.broker.direct._BrokerReceiver&#039;&amp;gt;&lt;br /&gt;
{&#039;_queue&#039;: ...}&lt;br /&gt;
[]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create an emitter. Registering the emitter gives the bus enough information to bind readable names to compact internal IDs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the captured registration records:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
sink.records&lt;br /&gt;
[type(record).__name__ for record in sink.records]&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    print(record)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[&#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;]&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;demo.signals&#039; ...)&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;ping&#039; ...)&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;demo-source&#039; ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit one message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;hello bus&amp;quot;})&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect capture before receiving:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
len(sink.records)&lt;br /&gt;
[type(record).__name__ for record in sink.records]&lt;br /&gt;
sink.records[-1]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
4&lt;br /&gt;
[&#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;CapturedMessage&#039;]&lt;br /&gt;
CapturedMessage(... payload={&#039;text&#039;: &#039;hello bus&#039;} ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
message = receiver.receive()&lt;br /&gt;
&lt;br /&gt;
message&lt;br /&gt;
message.msg_topic&lt;br /&gt;
message.msg_producer&lt;br /&gt;
message.msg_type&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ReceivedMessage(...)&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&#039;demo-source&#039;&lt;br /&gt;
&#039;ping&#039;&lt;br /&gt;
{&#039;text&#039;: &#039;hello bus&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the received message with the captured record:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured = sink.records[-1]&lt;br /&gt;
&lt;br /&gt;
captured&lt;br /&gt;
captured.msg_topic_id&lt;br /&gt;
captured.msg_type_id&lt;br /&gt;
captured.msg_producer_id&lt;br /&gt;
captured.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
CapturedMessage(...)&lt;br /&gt;
TopicID(...)&lt;br /&gt;
MessageTypeID(...)&lt;br /&gt;
ProducerID(...)&lt;br /&gt;
{&#039;text&#039;: &#039;hello bus&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The receiver gets the readable message view. The capture sink sees compact IDs and local payload data. The readable names are still available through symbol registration records, but manually reconstructing them is awkward.&lt;br /&gt;
&lt;br /&gt;
That awkwardness motivates the next phase.&lt;br /&gt;
&lt;br /&gt;
== Phase 2: Build a temporary capture history helper ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a small demonstration helper that turns captured records back into readable history messages.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Capture records are useful, but raw captured messages use compact IDs.&lt;br /&gt;
* A history helper can rebuild readable message history from registration records.&lt;br /&gt;
* This is demonstration infrastructure, not the final persistence/query API.&lt;br /&gt;
* The helper writes captured records into an in-memory &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; sequential unit.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration before the file edit&lt;br /&gt;
&lt;br /&gt;
List only the captured messages:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured_messages = []&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    if isinstance(record, CapturedMessage):&lt;br /&gt;
        captured_messages.append(record)&lt;br /&gt;
&lt;br /&gt;
captured_messages&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[CapturedMessage(... payload={&#039;text&#039;: &#039;hello bus&#039;} ...)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first captured message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured_message = captured_messages[0]&lt;br /&gt;
&lt;br /&gt;
captured_message.msg_topic_id&lt;br /&gt;
captured_message.msg_type_id&lt;br /&gt;
captured_message.msg_producer_id&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
TopicID(...)&lt;br /&gt;
MessageTypeID(...)&lt;br /&gt;
ProducerID(...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Manually reconstruct a topic name:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
topic_names = {}&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    if isinstance(record, MessageSymbolRegistration):&lt;br /&gt;
        if record.symbol_kind.name == &amp;quot;TOPIC&amp;quot;:&lt;br /&gt;
            topic_names[record.symbol_id] = record.symbol&lt;br /&gt;
&lt;br /&gt;
topic_names&lt;br /&gt;
topic_names[captured_message.msg_topic_id]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
{TopicID(...): &#039;demo.signals&#039;}&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This works, but repeating it by hand would distract from writing event processors.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/capture_history.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/capture_history.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Capture and history helpers for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from collections.abc import Iterable&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
&lt;br /&gt;
from aostore.sequentialunit.listsequentialunit import ListSequentialUnit&lt;br /&gt;
from mbus.capture.sink import CaptureSink&lt;br /&gt;
from mbus.message.records import CapturedMessage&lt;br /&gt;
from mbus.message.symbols import (&lt;br /&gt;
    MessageSymbolKind,&lt;br /&gt;
    MessageSymbolRegistration,&lt;br /&gt;
    MessageTypeID,&lt;br /&gt;
    ProducerID,&lt;br /&gt;
    TopicID,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
CapturedRecord = MessageSymbolRegistration | CapturedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class AOStoreCaptureSink(CaptureSink):&lt;br /&gt;
    records: ListSequentialUnit[CapturedRecord] = field(&lt;br /&gt;
        default_factory=ListSequentialUnit&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    def capture_symbol_registration(&lt;br /&gt;
        self,&lt;br /&gt;
        registration: MessageSymbolRegistration,&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self.records.append(registration)&lt;br /&gt;
&lt;br /&gt;
    def capture(self, message: CapturedMessage) -&amp;gt; None:&lt;br /&gt;
        self.records.append(message)&lt;br /&gt;
&lt;br /&gt;
    def all_records(self) -&amp;gt; tuple[CapturedRecord, ...]:&lt;br /&gt;
        records = self.records.sequential_read(0, len(self.records))&lt;br /&gt;
        result = tuple(records)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class HistoryMessage:&lt;br /&gt;
    msg_topic: str&lt;br /&gt;
    msg_type: str&lt;br /&gt;
    msg_producer: str&lt;br /&gt;
    payload: object&lt;br /&gt;
    bus_sequence: int&lt;br /&gt;
    topic_sequence: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class CaptureHistory:&lt;br /&gt;
    _records: tuple[CapturedRecord, ...]&lt;br /&gt;
    _topics: dict[TopicID, str]&lt;br /&gt;
    _types: dict[MessageTypeID, str]&lt;br /&gt;
    _producers: dict[ProducerID, str]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, records: Iterable[CapturedRecord]) -&amp;gt; None:&lt;br /&gt;
        self._records = tuple(records)&lt;br /&gt;
        self._topics = {}&lt;br /&gt;
        self._types = {}&lt;br /&gt;
        self._producers = {}&lt;br /&gt;
        self._index_symbols()&lt;br /&gt;
&lt;br /&gt;
    def _index_symbols(self) -&amp;gt; None:&lt;br /&gt;
        for record in self._records:&lt;br /&gt;
            if isinstance(record, MessageSymbolRegistration):&lt;br /&gt;
                self._record_symbol(record)&lt;br /&gt;
&lt;br /&gt;
    def _record_symbol(self, record: MessageSymbolRegistration) -&amp;gt; None:&lt;br /&gt;
        if record.symbol_kind is MessageSymbolKind.TOPIC:&lt;br /&gt;
            self._topics[record.symbol_id] = record.symbol&lt;br /&gt;
        elif record.symbol_kind is MessageSymbolKind.MSG_TYPE:&lt;br /&gt;
            self._types[record.symbol_id] = record.symbol&lt;br /&gt;
        elif record.symbol_kind is MessageSymbolKind.PRODUCER:&lt;br /&gt;
            self._producers[record.symbol_id] = record.symbol&lt;br /&gt;
&lt;br /&gt;
    def messages(self) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for record in self._records:&lt;br /&gt;
            if isinstance(record, CapturedMessage):&lt;br /&gt;
                message = self._decode_message(record)&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def by_topic(self, msg_topic: str) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for message in self.messages():&lt;br /&gt;
            if message.msg_topic == msg_topic:&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def since(self, bus_sequence: int) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for message in self.messages():&lt;br /&gt;
            if message.bus_sequence &amp;gt;= bus_sequence:&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def _decode_message(self, message: CapturedMessage) -&amp;gt; HistoryMessage:&lt;br /&gt;
        history_message = HistoryMessage(&lt;br /&gt;
            msg_topic=self._topics[message.msg_topic_id],&lt;br /&gt;
            msg_type=self._types[message.msg_type_id],&lt;br /&gt;
            msg_producer=self._producers[message.msg_producer_id],&lt;br /&gt;
            payload=message.payload,&lt;br /&gt;
            bus_sequence=message.bus_sequence,&lt;br /&gt;
            topic_sequence=message.topic_sequence,&lt;br /&gt;
        )&lt;br /&gt;
        return history_message&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;history helper&amp;quot;})&lt;br /&gt;
message = receiver.receive()&lt;br /&gt;
&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
history.messages()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[HistoryMessage(msg_topic=&#039;demo.signals&#039;, msg_type=&#039;ping&#039;, msg_producer=&#039;demo-source&#039;, payload={&#039;text&#039;: &#039;history helper&#039;}, ...)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the decoded history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history_message = history.messages()[0]&lt;br /&gt;
&lt;br /&gt;
history_message.msg_topic&lt;br /&gt;
history_message.msg_type&lt;br /&gt;
history_message.msg_producer&lt;br /&gt;
history_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&#039;ping&#039;&lt;br /&gt;
&#039;demo-source&#039;&lt;br /&gt;
{&#039;text&#039;: &#039;history helper&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 3: Explore shell command data by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Identify the high-level event shape needed by a milestone processor.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The old milestone strategy operated on command-level records.&lt;br /&gt;
* A command-level record includes context such as host and working directory.&lt;br /&gt;
* It also includes shell input and shell output.&lt;br /&gt;
* Raw TTY events do not directly have this shape; reconstruction comes later.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Represent a shell command as a dictionary:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = {&lt;br /&gt;
    &amp;quot;session_id&amp;quot;: &amp;quot;session-1&amp;quot;,&lt;br /&gt;
    &amp;quot;command_index&amp;quot;: 0,&lt;br /&gt;
    &amp;quot;host&amp;quot;: &amp;quot;alpha&amp;quot;,&lt;br /&gt;
    &amp;quot;cwd&amp;quot;: &amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    &amp;quot;input_text&amp;quot;: &amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    &amp;quot;output_text&amp;quot;: &amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    &amp;quot;started_at_ns&amp;quot;: 1000,&lt;br /&gt;
    &amp;quot;ended_at_ns&amp;quot;: 2000,&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the fields used by milestone checks:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command[&amp;quot;host&amp;quot;]&lt;br /&gt;
command[&amp;quot;cwd&amp;quot;]&lt;br /&gt;
command[&amp;quot;input_text&amp;quot;]&lt;br /&gt;
command[&amp;quot;output_text&amp;quot;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;alpha&#039;&lt;br /&gt;
&#039;/home/student&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Try an accidental wrong key:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command[&amp;quot;working_directory&amp;quot;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeyError: &#039;working_directory&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The dictionary contains the information, but it does not document the shape of the event. A typo is discovered only when the field is used. A named event class makes the processor code easier to read and easier to explain.&lt;br /&gt;
&lt;br /&gt;
== Phase 4: Persist the event record classes ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create reusable payload classes for the TTY, shell command, and milestone examples.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/events.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/events.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Payload records for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from dataclasses import dataclass&lt;br /&gt;
from typing import Literal&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class TTYReadEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    data: bytes&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class TTYWriteEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    data: bytes&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class LineDisciplineEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    line: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class KeystrokeTimingEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    previous_timestamp_ns: int | None&lt;br /&gt;
    delta_ns: int | None&lt;br /&gt;
    text: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class ShellCommandEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    command_index: int&lt;br /&gt;
    host: str&lt;br /&gt;
    cwd: str&lt;br /&gt;
    input_text: str&lt;br /&gt;
    output_text: str&lt;br /&gt;
    started_at_ns: int&lt;br /&gt;
    ended_at_ns: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
RegexMilestoneField = Literal[&lt;br /&gt;
    &amp;quot;host&amp;quot;,&lt;br /&gt;
    &amp;quot;cwd&amp;quot;,&lt;br /&gt;
    &amp;quot;input_text&amp;quot;,&lt;br /&gt;
    &amp;quot;output_text&amp;quot;,&lt;br /&gt;
]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneExpression:&lt;br /&gt;
    name: str&lt;br /&gt;
    field: RegexMilestoneField&lt;br /&gt;
    pattern: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneResult:&lt;br /&gt;
    expression_name: str&lt;br /&gt;
    field: RegexMilestoneField&lt;br /&gt;
    matched: bool&lt;br /&gt;
    match_text: str | None = None&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneAnalysisEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    command_index: int&lt;br /&gt;
    results: tuple[RegexMilestoneResult, ...]&lt;br /&gt;
    matched_count: int&lt;br /&gt;
    expression_count: int&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.events import ShellCommandEvent&lt;br /&gt;
&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
command&lt;br /&gt;
command.host&lt;br /&gt;
command.cwd&lt;br /&gt;
command.input_text&lt;br /&gt;
command.output_text&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ShellCommandEvent(session_id=&#039;session-1&#039;, command_index=0, ...)&lt;br /&gt;
&#039;alpha&#039;&lt;br /&gt;
&#039;/home/student&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The event shape is now explicit. Other code can depend on a named &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; instead of assuming that a loose dictionary has the expected fields.&lt;br /&gt;
&lt;br /&gt;
== Phase 5: Evaluate milestones manually ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Recreate the core milestone check in a small, transparent form.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* A milestone expression names one check against one shell command field.&lt;br /&gt;
* The field may be command input, command output, working directory, or host context.&lt;br /&gt;
* The result records whether the expression matched and, when available, the matched text.&lt;br /&gt;
* Manual evaluation is useful for learning the shape of the problem before writing a processor.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Import regular expressions and the payload classes:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create one command event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create three milestone expressions:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
expressions = (&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;used-ls&amp;quot;,&lt;br /&gt;
        field=&amp;quot;input_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;\bls\b&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;saw-notes&amp;quot;,&lt;br /&gt;
        field=&amp;quot;output_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;notes\.txt&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;home-directory&amp;quot;,&lt;br /&gt;
        field=&amp;quot;cwd&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;^/home/student$&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Evaluate one expression:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
expression = expressions[0]&lt;br /&gt;
text = getattr(command, expression.field)&lt;br /&gt;
match = re.search(expression.pattern, text)&lt;br /&gt;
&lt;br /&gt;
expression.name&lt;br /&gt;
expression.field&lt;br /&gt;
text&lt;br /&gt;
match.group(0)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;used-ls&#039;&lt;br /&gt;
&#039;input_text&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;ls&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Evaluate all expressions:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
manual_results = []&lt;br /&gt;
&lt;br /&gt;
for expression in expressions:&lt;br /&gt;
    text = getattr(command, expression.field)&lt;br /&gt;
    match = re.search(expression.pattern, text)&lt;br /&gt;
    manual_results.append((expression.name, match is not None))&lt;br /&gt;
&lt;br /&gt;
manual_results&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(&#039;used-ls&#039;, True), (&#039;saw-notes&#039;, True), (&#039;home-directory&#039;, True)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
Manual evaluation is simple for one command, but the loop is already event processor logic. The next phase preserves the loop in a reusable processor.&lt;br /&gt;
&lt;br /&gt;
== Phase 6: Persist the RegexMilestone processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a processor that receives shell command events and emits regex milestone analysis events.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/regex_milestones.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/regex_milestones.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Regex milestone processor for shell command events.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneAnalysisEvent,&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    RegexMilestoneResult,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class RegexMilestoneProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _expressions: tuple[RegexMilestoneExpression, ...]&lt;br /&gt;
&lt;br /&gt;
    def __init__(&lt;br /&gt;
        self,&lt;br /&gt;
        bus: DirectMessageBus,&lt;br /&gt;
        expressions: tuple[RegexMilestoneExpression, ...],&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.shell.command&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;shell-reconstructor&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;shell-command&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.shell.regex_milestone&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;regex-milestone-processor&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;regex-milestone-analysis&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._expressions = expressions&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; RegexMilestoneAnalysisEvent:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        command = message.payload&lt;br /&gt;
&lt;br /&gt;
        if not isinstance(command, ShellCommandEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected ShellCommandEvent, got {type(command).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        analysis = analyze_regex_milestones(command, self._expressions)&lt;br /&gt;
        self._emitter.emit(analysis)&lt;br /&gt;
        return analysis&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def analyze_regex_milestones(&lt;br /&gt;
    command: ShellCommandEvent,&lt;br /&gt;
    expressions: tuple[RegexMilestoneExpression, ...],&lt;br /&gt;
) -&amp;gt; RegexMilestoneAnalysisEvent:&lt;br /&gt;
    results = []&lt;br /&gt;
    for expression in expressions:&lt;br /&gt;
        result = evaluate_regex_milestone(command, expression)&lt;br /&gt;
        results.append(result)&lt;br /&gt;
&lt;br /&gt;
    matched_count = sum(result.matched for result in results)&lt;br /&gt;
    analysis = RegexMilestoneAnalysisEvent(&lt;br /&gt;
        session_id=command.session_id,&lt;br /&gt;
        command_index=command.command_index,&lt;br /&gt;
        results=tuple(results),&lt;br /&gt;
        matched_count=matched_count,&lt;br /&gt;
        expression_count=len(expressions),&lt;br /&gt;
    )&lt;br /&gt;
    return analysis&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def evaluate_regex_milestone(&lt;br /&gt;
    command: ShellCommandEvent,&lt;br /&gt;
    expression: RegexMilestoneExpression,&lt;br /&gt;
) -&amp;gt; RegexMilestoneResult:&lt;br /&gt;
    text = getattr(command, expression.field)&lt;br /&gt;
    match = re.search(expression.pattern, text)&lt;br /&gt;
    match_text = None&lt;br /&gt;
&lt;br /&gt;
    if match is not None:&lt;br /&gt;
        match_text = match.group(0)&lt;br /&gt;
&lt;br /&gt;
    result = RegexMilestoneResult(&lt;br /&gt;
        expression_name=expression.name,&lt;br /&gt;
        field=expression.field,&lt;br /&gt;
        matched=match is not None,&lt;br /&gt;
        match_text=match_text,&lt;br /&gt;
    )&lt;br /&gt;
    return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then set up the bus:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.regex_milestones import RegexMilestoneProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Subscribe to the analysis topic before running the processor:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.shell.regex_milestone&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;regex-milestone-processor&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;regex-milestone-analysis&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create the shell command emitter and processor:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.shell.command&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;shell-reconstructor&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;shell-command&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
expressions = (&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;used-ls&amp;quot;,&lt;br /&gt;
        field=&amp;quot;input_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;\bls\b&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;saw-notes&amp;quot;,&lt;br /&gt;
        field=&amp;quot;output_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;notes\.txt&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;home-directory&amp;quot;,&lt;br /&gt;
        field=&amp;quot;cwd&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;^/home/student$&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = RegexMilestoneProcessor(bus, expressions)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit a shell command event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
command_emitter.emit(command)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the command:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis = processor.process_one()&lt;br /&gt;
analysis&lt;br /&gt;
analysis.results&lt;br /&gt;
analysis.matched_count&lt;br /&gt;
analysis.expression_count&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
RegexMilestoneAnalysisEvent(... matched_count=3, expression_count=3)&lt;br /&gt;
(RegexMilestoneResult(...), RegexMilestoneResult(...), RegexMilestoneResult(...))&lt;br /&gt;
3&lt;br /&gt;
3&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the derived analysis event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis_message = analysis_receiver.receive()&lt;br /&gt;
&lt;br /&gt;
analysis_message.msg_topic&lt;br /&gt;
analysis_message.msg_type&lt;br /&gt;
analysis_message.msg_producer&lt;br /&gt;
analysis_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;demo.shell.regex_milestone&#039;&lt;br /&gt;
&#039;regex-milestone-analysis&#039;&lt;br /&gt;
&#039;regex-milestone-processor&#039;&lt;br /&gt;
RegexMilestoneAnalysisEvent(... matched_count=3, expression_count=3)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
messages = history.messages()&lt;br /&gt;
&lt;br /&gt;
for message in messages:&lt;br /&gt;
    print(message.bus_sequence, message.msg_topic, message.msg_type)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
0 demo.shell.command shell-command&lt;br /&gt;
1 demo.shell.regex_milestone regex-milestone-analysis&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The regex milestone processor consumes one event and emits another. The derived analysis event is available to any later subscriber. History contains both the source command event and the derived analysis event.&lt;br /&gt;
&lt;br /&gt;
== Phase 7: Explore why shell reconstruction is separate ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Show that raw TTY observations are not the same thing as high-level shell command events.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Regex milestones should inspect high-level command events.&lt;br /&gt;
* TTY reads, TTY writes, and line-discipline lines are lower-level observations.&lt;br /&gt;
* A shell command event is reconstructed by correlating those observations.&lt;br /&gt;
* Keeping reconstruction separate prevents the milestone processor from becoming a large TTY parser.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Create a few low-level observations:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    LineDisciplineEvent,&lt;br /&gt;
    TTYReadEvent,&lt;br /&gt;
    TTYWriteEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_1 = TTYReadEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=0,&lt;br /&gt;
    timestamp_ns=1000,&lt;br /&gt;
    data=b&amp;quot;l&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_2 = TTYReadEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=1,&lt;br /&gt;
    timestamp_ns=1100,&lt;br /&gt;
    data=b&amp;quot;s&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
line = LineDisciplineEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=2,&lt;br /&gt;
    timestamp_ns=1200,&lt;br /&gt;
    line=&amp;quot;ls\n&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
write = TTYWriteEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=3,&lt;br /&gt;
    timestamp_ns=1600,&lt;br /&gt;
    data=b&amp;quot;notes.txt\n&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the raw pieces:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_1.data&lt;br /&gt;
read_2.data&lt;br /&gt;
line.line&lt;br /&gt;
write.data&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
b&#039;l&#039;&lt;br /&gt;
b&#039;s&#039;&lt;br /&gt;
&#039;ls\n&#039;&lt;br /&gt;
b&#039;notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Assemble command fields manually:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
input_text = line.line&lt;br /&gt;
output_text = write.data.decode(&amp;quot;utf-8&amp;quot;, errors=&amp;quot;replace&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
input_text&lt;br /&gt;
output_text&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;ls\n&#039;&lt;br /&gt;
&#039;notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The useful command event is derived from several lower-level observations. A later shell reconstructor can subscribe to line-discipline events, query recent reads/writes/timing observations, and emit &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; records.&lt;br /&gt;
&lt;br /&gt;
== Phase 8: Add keystroke timing as a smaller TTY-derived processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a simple stateful processor before attempting full shell reconstruction.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Timing requires comparing a TTY read with the previous read in the same session.&lt;br /&gt;
* A derived timing event makes the result explicit.&lt;br /&gt;
* Other processors can consume timing events instead of recalculating deltas.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration before the file edit&lt;br /&gt;
&lt;br /&gt;
Calculate timing manually from the two read events:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_events = [read_1, read_2]&lt;br /&gt;
previous_timestamp = None&lt;br /&gt;
timing_rows = []&lt;br /&gt;
&lt;br /&gt;
for event in read_events:&lt;br /&gt;
    delta = None&lt;br /&gt;
    if previous_timestamp is not None:&lt;br /&gt;
        delta = event.timestamp_ns - previous_timestamp&lt;br /&gt;
    timing_rows.append((event.source_index, previous_timestamp, delta))&lt;br /&gt;
    previous_timestamp = event.timestamp_ns&lt;br /&gt;
&lt;br /&gt;
timing_rows&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, None, None), (1, 1000, 100)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/keystroke_timing.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/keystroke_timing.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Keystroke timing processor for TTY read events.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    KeystrokeTimingEvent,&lt;br /&gt;
    TTYReadEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class KeystrokeTimingProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _last_timestamp_by_session: dict[str, int]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.tty.read&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;tty-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;tty-read&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.keystroke.timing&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._last_timestamp_by_session = {}&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; KeystrokeTimingEvent:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        event = message.payload&lt;br /&gt;
&lt;br /&gt;
        if not isinstance(event, TTYReadEvent):&lt;br /&gt;
            raise TypeError(f&amp;quot;expected TTYReadEvent, got {type(event).__name__}&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
        previous_timestamp = self._last_timestamp_by_session.get(&lt;br /&gt;
            event.session_id&lt;br /&gt;
        )&lt;br /&gt;
        delta = None&lt;br /&gt;
&lt;br /&gt;
        if previous_timestamp is not None:&lt;br /&gt;
            delta = event.timestamp_ns - previous_timestamp&lt;br /&gt;
&lt;br /&gt;
        self._last_timestamp_by_session[event.session_id] = event.timestamp_ns&lt;br /&gt;
&lt;br /&gt;
        text = event.data.decode(&amp;quot;utf-8&amp;quot;, errors=&amp;quot;replace&amp;quot;)&lt;br /&gt;
        timing = KeystrokeTimingEvent(&lt;br /&gt;
            session_id=event.session_id,&lt;br /&gt;
            source_index=event.source_index,&lt;br /&gt;
            timestamp_ns=event.timestamp_ns,&lt;br /&gt;
            previous_timestamp_ns=previous_timestamp,&lt;br /&gt;
            delta_ns=delta,&lt;br /&gt;
            text=text,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter.emit(timing)&lt;br /&gt;
        return timing&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
from demos.event_processors.events import TTYReadEvent&lt;br /&gt;
from demos.event_processors.keystroke_timing import KeystrokeTimingProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
timing_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.keystroke.timing&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.tty.read&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;tty-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;tty-read&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = KeystrokeTimingProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit reads and process them:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_emitter.emit(&lt;br /&gt;
    TTYReadEvent(&lt;br /&gt;
        session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
        source_index=0,&lt;br /&gt;
        timestamp_ns=1000,&lt;br /&gt;
        data=b&amp;quot;l&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_emitter.emit(&lt;br /&gt;
    TTYReadEvent(&lt;br /&gt;
        session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
        source_index=1,&lt;br /&gt;
        timestamp_ns=1100,&lt;br /&gt;
        data=b&amp;quot;s&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor.process_one()&lt;br /&gt;
processor.process_one()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeystrokeTimingEvent(... previous_timestamp_ns=None, delta_ns=None, text=&#039;l&#039;)&lt;br /&gt;
KeystrokeTimingEvent(... previous_timestamp_ns=1000, delta_ns=100, text=&#039;s&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive timing events:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
timing_receiver.receive().payload&lt;br /&gt;
timing_receiver.receive().payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeystrokeTimingEvent(... source_index=0 ...)&lt;br /&gt;
KeystrokeTimingEvent(... source_index=1 ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 9: Introduce graph events ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Switch to a small graph problem that can demonstrate feedback loops safely.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* An edge is a source fact.&lt;br /&gt;
* A path is a derived fact.&lt;br /&gt;
* A direct edge implies a direct path.&lt;br /&gt;
* Longer paths can be derived later from known paths and edges.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_events.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/graph_events.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Graph event payloads for message bus demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from dataclasses import dataclass&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class EdgeDeclaredEvent:&lt;br /&gt;
    graph_id: str&lt;br /&gt;
    source: str&lt;br /&gt;
    target: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class PathKnownEvent:&lt;br /&gt;
    graph_id: str&lt;br /&gt;
    source: str&lt;br /&gt;
    target: str&lt;br /&gt;
    hop_count: int&lt;br /&gt;
&lt;br /&gt;
    def fact_key(self) -&amp;gt; tuple[str, str, str]:&lt;br /&gt;
        result = (self.graph_id, self.source, self.target)&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge = EdgeDeclaredEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;A&amp;quot;,&lt;br /&gt;
    target=&amp;quot;B&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path = PathKnownEvent(&lt;br /&gt;
    graph_id=edge.graph_id,&lt;br /&gt;
    source=edge.source,&lt;br /&gt;
    target=edge.target,&lt;br /&gt;
    hop_count=1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge&lt;br /&gt;
path&lt;br /&gt;
path.fact_key()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
EdgeDeclaredEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;)&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;, hop_count=1)&lt;br /&gt;
(&#039;demo-graph&#039;, &#039;A&#039;, &#039;B&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 10: Create a demo-only nonblocking receive helper ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Prepare for processor scheduling without editing &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;Receiver.receive()&amp;lt;/code&amp;gt; blocks until a message is available.&lt;br /&gt;
* A scheduler needs to try a processor and move on if no message is waiting.&lt;br /&gt;
* The current package does not expose a public nonblocking receiver method.&lt;br /&gt;
* This helper is local demonstration code. It can be replaced by a real public API later.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/demo_receive.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/demo_receive.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Demo-only helpers for receiver inspection and scheduling.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from queue import Empty&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.endpoints import Receiver&lt;br /&gt;
from mbus.message.records import ReceivedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class DemoReceiverError(RuntimeError):&lt;br /&gt;
    pass&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def try_receive(receiver: Receiver) -&amp;gt; ReceivedMessage | None:&lt;br /&gt;
    queue = getattr(receiver, &amp;quot;_queue&amp;quot;, None)&lt;br /&gt;
&lt;br /&gt;
    if queue is None:&lt;br /&gt;
        raise DemoReceiverError(&lt;br /&gt;
            &amp;quot;demo try_receive requires the current direct broker receiver&amp;quot;&lt;br /&gt;
        )&lt;br /&gt;
&lt;br /&gt;
    try:&lt;br /&gt;
        message = queue.get_nowait()&lt;br /&gt;
    except Empty:&lt;br /&gt;
        message = None&lt;br /&gt;
&lt;br /&gt;
    return message&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.demo_receive import try_receive&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
try_receive(receiver)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
None&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Now emit a message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;demo nonblocking receive&amp;quot;})&lt;br /&gt;
message = try_receive(receiver)&lt;br /&gt;
&lt;br /&gt;
message&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ReceivedMessage(...)&lt;br /&gt;
{&#039;text&#039;: &#039;demo nonblocking receive&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 11: Create the direct path processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Implement the graph rule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
edge(A, B) -&amp;gt; path(A, B)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The source emits edge events.&lt;br /&gt;
* The direct path processor emits derived path events.&lt;br /&gt;
* Other processors can subscribe to derived path events.&lt;br /&gt;
* The processor can be run by hand or by a scheduler.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_reachability.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/graph_reachability.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Graph reachability processors for message bus demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.demo_receive import try_receive&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class DirectPathProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _known_paths: set[tuple[str, str, str]]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._known_paths = set()&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; PathKnownEvent | None:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        result = self._process_payload(message.payload)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
    def process_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            self._process_payload(message.payload)&lt;br /&gt;
            work_count = 1&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def _process_payload(self, payload: object) -&amp;gt; PathKnownEvent | None:&lt;br /&gt;
        if not isinstance(payload, EdgeDeclaredEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected EdgeDeclaredEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        path = PathKnownEvent(&lt;br /&gt;
            graph_id=payload.graph_id,&lt;br /&gt;
            source=payload.source,&lt;br /&gt;
            target=payload.target,&lt;br /&gt;
            hop_count=1,&lt;br /&gt;
        )&lt;br /&gt;
&lt;br /&gt;
        if path.fact_key() not in self._known_paths:&lt;br /&gt;
            self._known_paths.add(path.fact_key())&lt;br /&gt;
            self._emitter.emit(path)&lt;br /&gt;
            result = path&lt;br /&gt;
        else:&lt;br /&gt;
            result = None&lt;br /&gt;
&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
from demos.event_processors.graph_events import EdgeDeclaredEvent&lt;br /&gt;
from demos.event_processors.graph_reachability import DirectPathProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
path_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = DirectPathProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Call the processor before any edge exists:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
0&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit one edge:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;A&amp;quot;,&lt;br /&gt;
        target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the edge:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the derived path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_message = path_receiver.receive()&lt;br /&gt;
path_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;, hop_count=1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 12: Explore the path extension rule by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Understand the feedback rule before implementing it.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* If &amp;lt;code&amp;gt;A&amp;lt;/code&amp;gt; reaches &amp;lt;code&amp;gt;B&amp;lt;/code&amp;gt;, and there is an edge from &amp;lt;code&amp;gt;B&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;C&amp;lt;/code&amp;gt;, then &amp;lt;code&amp;gt;A&amp;lt;/code&amp;gt; reaches &amp;lt;code&amp;gt;C&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Derived path facts can lead to more derived path facts.&lt;br /&gt;
* Duplicate prevention keeps the feedback loop from repeating the same fact forever.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_bc = EdgeDeclaredEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;B&amp;quot;,&lt;br /&gt;
    target=&amp;quot;C&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path_ab = PathKnownEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;A&amp;quot;,&lt;br /&gt;
    target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    hop_count=1,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Check whether the path and edge connect:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_ab.target&lt;br /&gt;
edge_bc.source&lt;br /&gt;
path_ab.target == edge_bc.source&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;B&#039;&lt;br /&gt;
&#039;B&#039;&lt;br /&gt;
True&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Derive a new path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_ac = PathKnownEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=path_ab.source,&lt;br /&gt;
    target=edge_bc.target,&lt;br /&gt;
    hop_count=path_ab.hop_count + 1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path_ac&lt;br /&gt;
path_ac.fact_key()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;C&#039;, hop_count=2)&lt;br /&gt;
(&#039;demo-graph&#039;, &#039;A&#039;, &#039;C&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Manually prevent duplicate path facts:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
known_paths = set()&lt;br /&gt;
known_paths.add(path_ab.fact_key())&lt;br /&gt;
&lt;br /&gt;
path_ac.fact_key() in known_paths&lt;br /&gt;
&lt;br /&gt;
if path_ac.fact_key() not in known_paths:&lt;br /&gt;
    known_paths.add(path_ac.fact_key())&lt;br /&gt;
&lt;br /&gt;
known_paths&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
False&lt;br /&gt;
{(&#039;demo-graph&#039;, &#039;A&#039;, &#039;B&#039;), (&#039;demo-graph&#039;, &#039;A&#039;, &#039;C&#039;)}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 13: Add the path extension processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Implement the feedback rule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
path(A, B) + edge(B, C) -&amp;gt; path(A, C)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The extension processor listens to edge events and path events.&lt;br /&gt;
* It emits new path events when a known path can be extended.&lt;br /&gt;
* It also listens to its own output because an extended path may be extendable again.&lt;br /&gt;
* This is a controlled feedback loop.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Append this class to:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_reachability.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
class PathExtensionProcessor:&lt;br /&gt;
    _edge_receiver: Receiver&lt;br /&gt;
    _direct_path_receiver: Receiver&lt;br /&gt;
    _extension_path_receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _edges: set[tuple[str, str, str]]&lt;br /&gt;
    _paths: dict[tuple[str, str, str], PathKnownEvent]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._edge_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._direct_path_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._extension_path_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._edges = set()&lt;br /&gt;
        self._paths = {}&lt;br /&gt;
&lt;br /&gt;
    def process_edge_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._edge_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_edge_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def process_direct_path_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._direct_path_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_path_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def process_extension_path_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._extension_path_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_path_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def _process_edge_payload(&lt;br /&gt;
        self,&lt;br /&gt;
        payload: object,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        if not isinstance(payload, EdgeDeclaredEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected EdgeDeclaredEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        edge = payload&lt;br /&gt;
        self._edges.add((edge.graph_id, edge.source, edge.target))&lt;br /&gt;
        paths = self._derive_from_new_edge(edge)&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _process_path_payload(&lt;br /&gt;
        self,&lt;br /&gt;
        payload: object,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        if not isinstance(payload, PathKnownEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected PathKnownEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        path = payload&lt;br /&gt;
        self._paths[path.fact_key()] = path&lt;br /&gt;
        paths = self._derive_from_new_path(path)&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _derive_from_new_edge(&lt;br /&gt;
        self,&lt;br /&gt;
        edge: EdgeDeclaredEvent,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        paths = []&lt;br /&gt;
&lt;br /&gt;
        for known_path in self._paths.values():&lt;br /&gt;
            if known_path.graph_id != edge.graph_id:&lt;br /&gt;
                continue&lt;br /&gt;
            if known_path.target != edge.source:&lt;br /&gt;
                continue&lt;br /&gt;
&lt;br /&gt;
            next_path = PathKnownEvent(&lt;br /&gt;
                graph_id=edge.graph_id,&lt;br /&gt;
                source=known_path.source,&lt;br /&gt;
                target=edge.target,&lt;br /&gt;
                hop_count=known_path.hop_count + 1,&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
            if self._emit_if_new(next_path):&lt;br /&gt;
                paths.append(next_path)&lt;br /&gt;
&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _derive_from_new_path(&lt;br /&gt;
        self,&lt;br /&gt;
        path: PathKnownEvent,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        paths = []&lt;br /&gt;
&lt;br /&gt;
        for graph_id, edge_source, edge_target in self._edges:&lt;br /&gt;
            if graph_id != path.graph_id:&lt;br /&gt;
                continue&lt;br /&gt;
            if edge_source != path.target:&lt;br /&gt;
                continue&lt;br /&gt;
&lt;br /&gt;
            next_path = PathKnownEvent(&lt;br /&gt;
                graph_id=path.graph_id,&lt;br /&gt;
                source=path.source,&lt;br /&gt;
                target=edge_target,&lt;br /&gt;
                hop_count=path.hop_count + 1,&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
            if self._emit_if_new(next_path):&lt;br /&gt;
                paths.append(next_path)&lt;br /&gt;
&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _emit_if_new(self, path: PathKnownEvent) -&amp;gt; bool:&lt;br /&gt;
        if path.fact_key() in self._paths:&lt;br /&gt;
            result = False&lt;br /&gt;
        else:&lt;br /&gt;
            self._paths[path.fact_key()] = path&lt;br /&gt;
            self._emitter.emit(path)&lt;br /&gt;
            result = True&lt;br /&gt;
&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_events import EdgeDeclaredEvent&lt;br /&gt;
from demos.event_processors.graph_reachability import (&lt;br /&gt;
    DirectPathProcessor,&lt;br /&gt;
    PathExtensionProcessor,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
extension_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
direct_processor = DirectPathProcessor(bus)&lt;br /&gt;
extension_processor = PathExtensionProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit two edges in fixed order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;A&amp;quot;,&lt;br /&gt;
        target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;B&amp;quot;,&lt;br /&gt;
        target=&amp;quot;C&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the direct paths:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
direct_processor.process_available()&lt;br /&gt;
direct_processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Let the extension processor learn the edges:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
extension_processor.process_edge_available()&lt;br /&gt;
extension_processor.process_edge_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Let the extension processor learn the direct paths:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
extension_processor.process_direct_path_available()&lt;br /&gt;
extension_processor.process_direct_path_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The second call may emit the extended &amp;lt;code&amp;gt;A -&amp;gt; C&amp;lt;/code&amp;gt; path after the processor has seen both direct paths and both edges.&lt;br /&gt;
&lt;br /&gt;
Receive the extended path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
message = extension_receiver.receive()&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;C&#039;, hop_count=2)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect captured graph path history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
path_messages = history.by_topic(&amp;quot;demo.graph.path&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
for message in path_messages:&lt;br /&gt;
    path = message.payload&lt;br /&gt;
    print(&lt;br /&gt;
        message.bus_sequence,&lt;br /&gt;
        message.msg_producer,&lt;br /&gt;
        path.source,&lt;br /&gt;
        path.target,&lt;br /&gt;
        path.hop_count,&lt;br /&gt;
    )&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
... direct-path A B 1&lt;br /&gt;
... direct-path B C 1&lt;br /&gt;
... path-extension A C 2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 14: Add a randomized processor scheduler ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Simulate concurrency effects without changing source input order.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The source will emit the same edge events in the same order every run.&lt;br /&gt;
* The scheduler varies which processor gets a chance to run first.&lt;br /&gt;
* This simulates uncertainty from processing time, scheduling priority, startup timing, or transmission delay.&lt;br /&gt;
* The graph example should converge to the same final path facts even when processor scheduling, and hence the order of intermediate derived processor events, differs.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/runners.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/runners.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Small processor runners for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from collections.abc import Callable&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
from random import Random&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class ProcessorStep:&lt;br /&gt;
    name: str&lt;br /&gt;
    run: Callable[[], int]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class RandomProcessorRunner:&lt;br /&gt;
    steps: tuple[ProcessorStep, ...]&lt;br /&gt;
    seed: int&lt;br /&gt;
    max_rounds: int = 30&lt;br /&gt;
    trace: list[tuple[int, str, int]] = field(default_factory=list)&lt;br /&gt;
&lt;br /&gt;
    def run_until_quiet(self) -&amp;gt; list[tuple[int, str, int]]:&lt;br /&gt;
        rng = Random(self.seed)&lt;br /&gt;
&lt;br /&gt;
        for round_index in range(self.max_rounds):&lt;br /&gt;
            work_count = self._run_round(rng, round_index)&lt;br /&gt;
&lt;br /&gt;
            if work_count == 0:&lt;br /&gt;
                break&lt;br /&gt;
&lt;br /&gt;
        result = list(self.trace)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
    def _run_round(self, rng: Random, round_index: int) -&amp;gt; int:&lt;br /&gt;
        steps = list(self.steps)&lt;br /&gt;
        rng.shuffle(steps)&lt;br /&gt;
        round_work_count = 0&lt;br /&gt;
&lt;br /&gt;
        for step in steps:&lt;br /&gt;
            step_work_count = step.run()&lt;br /&gt;
            self.trace.append((round_index, step.name, step_work_count))&lt;br /&gt;
            round_work_count += step_work_count&lt;br /&gt;
&lt;br /&gt;
        return round_work_count&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then inspect how a runner can shuffle placeholder steps:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.runners import (&lt;br /&gt;
    ProcessorStep,&lt;br /&gt;
    RandomProcessorRunner,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
calls = []&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def first_step() -&amp;gt; int:&lt;br /&gt;
    calls.append(&amp;quot;first&amp;quot;)&lt;br /&gt;
    return 0&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def second_step() -&amp;gt; int:&lt;br /&gt;
    calls.append(&amp;quot;second&amp;quot;)&lt;br /&gt;
    return 0&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
runner = RandomProcessorRunner(&lt;br /&gt;
    steps=(&lt;br /&gt;
        ProcessorStep(name=&amp;quot;first&amp;quot;, run=first_step),&lt;br /&gt;
        ProcessorStep(name=&amp;quot;second&amp;quot;, run=second_step),&lt;br /&gt;
    ),&lt;br /&gt;
    seed=1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
runner.run_until_quiet()&lt;br /&gt;
calls&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, &#039;...&#039;, 0), (0, &#039;...&#039;, 0)]&lt;br /&gt;
[&#039;...&#039;, &#039;...&#039;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 15: Run the graph example with randomized scheduling ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Run the same graph input under different processor schedules and compare the final path facts.&lt;br /&gt;
&lt;br /&gt;
; Interpreter setup&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_reachability import (&lt;br /&gt;
    DirectPathProcessor,&lt;br /&gt;
    PathExtensionProcessor,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.runners import (&lt;br /&gt;
    ProcessorStep,&lt;br /&gt;
    RandomProcessorRunner,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Define a helper function in the interpreter:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
def run_graph_demo(seed: int) -&amp;gt; tuple[&lt;br /&gt;
    list[tuple[int, str, int]],&lt;br /&gt;
    list[tuple[int, str, str, int]],&lt;br /&gt;
]:&lt;br /&gt;
    bus = DirectMessageBus()&lt;br /&gt;
    sink = AOStoreCaptureSink()&lt;br /&gt;
    bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
    edge_emitter = bus.register_emitter(&lt;br /&gt;
        msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
        msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
        default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    direct_processor = DirectPathProcessor(bus)&lt;br /&gt;
    extension_processor = PathExtensionProcessor(bus)&lt;br /&gt;
&lt;br /&gt;
    edges = (&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;A&amp;quot;,&lt;br /&gt;
            target=&amp;quot;B&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;B&amp;quot;,&lt;br /&gt;
            target=&amp;quot;C&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;C&amp;quot;,&lt;br /&gt;
            target=&amp;quot;D&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    for edge in edges:&lt;br /&gt;
        edge_emitter.emit(edge)&lt;br /&gt;
&lt;br /&gt;
    steps = (&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;direct path from edge&amp;quot;,&lt;br /&gt;
            run=direct_processor.process_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns edge&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_edge_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns direct path&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_direct_path_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns extension path&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_extension_path_available,&lt;br /&gt;
        ),&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    runner = RandomProcessorRunner(&lt;br /&gt;
        steps=steps,&lt;br /&gt;
        seed=seed,&lt;br /&gt;
    )&lt;br /&gt;
    trace = runner.run_until_quiet()&lt;br /&gt;
&lt;br /&gt;
    history = CaptureHistory(sink.all_records())&lt;br /&gt;
    path_facts = []&lt;br /&gt;
&lt;br /&gt;
    for message in history.by_topic(&amp;quot;demo.graph.path&amp;quot;):&lt;br /&gt;
        path = message.payload&lt;br /&gt;
        if isinstance(path, PathKnownEvent):&lt;br /&gt;
            fact = (&lt;br /&gt;
                message.bus_sequence,&lt;br /&gt;
                path.source,&lt;br /&gt;
                path.target,&lt;br /&gt;
                path.hop_count,&lt;br /&gt;
            )&lt;br /&gt;
            path_facts.append(fact)&lt;br /&gt;
&lt;br /&gt;
    result = (trace, path_facts)&lt;br /&gt;
    return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Run one schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_1, paths_1 = run_graph_demo(1)&lt;br /&gt;
&lt;br /&gt;
trace_1&lt;br /&gt;
paths_1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, &#039;...&#039;, ...), (0, &#039;...&#039;, ...), ...]&lt;br /&gt;
[(..., &#039;A&#039;, &#039;B&#039;, 1), (..., &#039;B&#039;, &#039;C&#039;, 1), (..., &#039;C&#039;, &#039;D&#039;, 1), ...]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Run another schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_2, paths_2 = run_graph_demo(2)&lt;br /&gt;
&lt;br /&gt;
trace_2&lt;br /&gt;
paths_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the schedules:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_1 == trace_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
False&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the final path facts without bus sequence numbers:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
final_paths_1 = {&lt;br /&gt;
    (source, target, hop_count)&lt;br /&gt;
    for _, source, target, hop_count in paths_1&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
final_paths_2 = {&lt;br /&gt;
    (source, target, hop_count)&lt;br /&gt;
    for _, source, target, hop_count in paths_2&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
final_paths_1&lt;br /&gt;
final_paths_2&lt;br /&gt;
final_paths_1 == final_paths_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
{(&#039;A&#039;, &#039;B&#039;, 1), (&#039;B&#039;, &#039;C&#039;, 1), (&#039;C&#039;, &#039;D&#039;, 1), (&#039;A&#039;, &#039;C&#039;, 2), (&#039;B&#039;, &#039;D&#039;, 2), (&#039;A&#039;, &#039;D&#039;, 3)}&lt;br /&gt;
{(&#039;A&#039;, &#039;B&#039;, 1), (&#039;B&#039;, &#039;C&#039;, 1), (&#039;C&#039;, &#039;D&#039;, 1), (&#039;A&#039;, &#039;C&#039;, 2), (&#039;B&#039;, &#039;D&#039;, 2), (&#039;A&#039;, &#039;D&#039;, 3)}&lt;br /&gt;
True&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for round_index, step_name, work_count in trace_1:&lt;br /&gt;
    print(round_index, step_name, work_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first captured path order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for bus_sequence, source, target, hop_count in paths_1:&lt;br /&gt;
    print(bus_sequence, source, target, hop_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the second schedule and path order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for round_index, step_name, work_count in trace_2:&lt;br /&gt;
    print(round_index, step_name, work_count)&lt;br /&gt;
&lt;br /&gt;
for bus_sequence, source, target, hop_count in paths_2:&lt;br /&gt;
    print(bus_sequence, source, target, hop_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The source input order did not change. The processor scheduling changed. The captured event order may differ. The final reachability facts should match.&lt;br /&gt;
&lt;br /&gt;
This models an important distributed-systems lesson: event history order and final derived state are related, but they are not the same thing.&lt;br /&gt;
&lt;br /&gt;
== Phase 16: Later refinement: graph index from history ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Introduce restart/recovery thinking only after the basic graph feedback example is familiar.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The first graph processors keep local working state.&lt;br /&gt;
* Local working state is enough for a short demonstration.&lt;br /&gt;
* Longer-lived processors need recovery behavior after restart.&lt;br /&gt;
* Since edge and path facts are captured as events, a graph index can later be rebuilt from history.&lt;br /&gt;
* This points toward projection-style services without changing the basic processor model.&lt;br /&gt;
&lt;br /&gt;
; Suggested later file&lt;br /&gt;
&lt;br /&gt;
Create later:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_index.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Suggested responsibilities:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
read captured history&lt;br /&gt;
collect EdgeDeclaredEvent payloads&lt;br /&gt;
collect PathKnownEvent payloads&lt;br /&gt;
answer whether a path fact exists&lt;br /&gt;
find edges starting at a node&lt;br /&gt;
find paths ending at a node&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This refinement belongs after the team has already run the graph reachability demo successfully.&lt;br /&gt;
&lt;br /&gt;
== Phase 17: Later refinement: shell command reconstruction ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Connect the TTY examples back to the shell command event shape used by milestones.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; is the high-level event consumed by the milestone processor.&lt;br /&gt;
* &amp;lt;code&amp;gt;TTYReadEvent&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;TTYWriteEvent&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;LineDisciplineEvent&amp;lt;/code&amp;gt; are lower-level observations.&lt;br /&gt;
* A reconstructor can subscribe to line-discipline events and query recent related observations.&lt;br /&gt;
* The reconstructor should emit shell command events; it should not perform regex analysis itself.&lt;br /&gt;
&lt;br /&gt;
; Suggested responsibilities&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
listen for completed line-discipline events&lt;br /&gt;
query history since the previous command boundary for the same session&lt;br /&gt;
collect matching reads, writes, and timing events&lt;br /&gt;
construct ShellCommandEvent&lt;br /&gt;
emit ShellCommandEvent on demo.shell.command&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This is an appropriate follow-up exercise after the team understands both milestone processing and history queries.&lt;br /&gt;
&lt;br /&gt;
= Related =&lt;br /&gt;
&lt;br /&gt;
== Top-level topic page ==&lt;br /&gt;
[[Message Bus Service]]&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=Message_Bus_Live_Coding_Exercise&amp;diff=937</id>
		<title>Message Bus Live Coding Exercise</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=Message_Bus_Live_Coding_Exercise&amp;diff=937"/>
		<updated>2026-06-16T04:30:40Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* Create the demonstration package */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Introduction =&lt;br /&gt;
&lt;br /&gt;
== Audience and scope ==&lt;br /&gt;
&lt;br /&gt;
This exercise is intended for EDURange developers learning how to write event processors with the message bus.&lt;br /&gt;
&lt;br /&gt;
The packaged &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; modules are treated as read-only for this exercise. All new code belongs under a demonstration directory such as:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
workspace/&lt;br /&gt;
    mbus/&lt;br /&gt;
    aostore/&lt;br /&gt;
    demos/&lt;br /&gt;
        __init__.py&lt;br /&gt;
        event_processors/&lt;br /&gt;
            __init__.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Pre-release copies of &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; are available on the Discord fileshare channel. Modules may be released under other package names; see also the EDURange GitHub.&lt;br /&gt;
&lt;br /&gt;
The exercise uses two problem families.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;1. Shell and TTY event processing&#039;&#039;&#039;&lt;br /&gt;
** Send and receive messages through the bus.&lt;br /&gt;
** Capture and inspect message history.&lt;br /&gt;
** Represent high-level shell command events.&lt;br /&gt;
** Recreate the regex-based milestone role in a simplified form.&lt;br /&gt;
** Mock up TTY-derived observations such as keystroke timing.&lt;br /&gt;
* &#039;&#039;&#039;2. Graph event processing&#039;&#039;&#039;&lt;br /&gt;
** Represent graph edge relationships as source events.&lt;br /&gt;
** Derive direct path facts from edge relation events.&lt;br /&gt;
** Add feedback by deriving longer paths from known paths.&lt;br /&gt;
** Simulate nondeterministic processor scheduling while keeping source input fixed.&lt;br /&gt;
&lt;br /&gt;
The shell examples draw from EDURange&#039;s institutional knowledge. The graph examples provide a common model problem family for meaningful use of feedback loops.&lt;br /&gt;
&lt;br /&gt;
== Running the examples ==&lt;br /&gt;
&lt;br /&gt;
Adjust paths to match the local checkout layout if necessary. Start an interpreter from the workspace root with project packages available.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
PYTHONPATH=&amp;quot;$PWD/mbus:$PWD/aostore:$PWD&amp;quot; python3.14&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
When module files are changed, restart the interpreter unless the group is deliberately practicing &amp;lt;code&amp;gt;importlib.reload()&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Create the demonstration package ==&lt;br /&gt;
&lt;br /&gt;
Before the first durable file edit, create the demonstration package directories:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir -p demos/event_processors&lt;br /&gt;
touch demos/__init__.py&lt;br /&gt;
touch demos/event_processors/__init__.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
== Important note about capture records ==&lt;br /&gt;
&lt;br /&gt;
The current demonstration works close to the low-level capture layer. As a result, it exposes &amp;lt;code&amp;gt;MessageSymbolRegistration&amp;lt;/code&amp;gt; records while building the temporary history helper.&lt;br /&gt;
&lt;br /&gt;
That is not the intended ordinary user experience. In a more complete release, higher-level capture and history infrastructure should handle symbol registration bookkeeping on behalf of processor authors. In this exercise, symbol registration records are visible because the demonstration history helper reconstructs readable messages from compact captured records directly.&lt;br /&gt;
&lt;br /&gt;
== Suggested group session flow ==&lt;br /&gt;
&lt;br /&gt;
; Session 1 - Bus and capture basics&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 1:&#039;&#039;&#039;&lt;br /&gt;
** Signal over the bus by hand.&lt;br /&gt;
** Inspect received messages.&lt;br /&gt;
** Inspect low-level captured records.&lt;br /&gt;
* &#039;&#039;&#039;Phase 2:&#039;&#039;&#039;&lt;br /&gt;
** Discuss why symbol registration appears in this temporary demo.&lt;br /&gt;
** Build the capture history helper.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1-3 hours (1-2 hours work, 0.5-1 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 2 - Shell command and regex milestones&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 3:&#039;&#039;&#039;&lt;br /&gt;
** Represent a shell command as a dictionary.&lt;br /&gt;
* &#039;&#039;&#039;Phase 4:&#039;&#039;&#039;&lt;br /&gt;
** Replace it with &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Phase 5:&#039;&#039;&#039;&lt;br /&gt;
** Manually evaluate regex milestone expressions.&lt;br /&gt;
* &#039;&#039;&#039;Phase 6:&#039;&#039;&#039;&lt;br /&gt;
** Implement &amp;lt;code&amp;gt;RegexMilestoneProcessor&amp;lt;/code&amp;gt;.&lt;br /&gt;
** Inspect source and analysis events in history.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3 hours (1.5-2.5 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 3 - TTY-derived observations&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 7:&#039;&#039;&#039;&lt;br /&gt;
** Create raw TTY read, write, and line events.&lt;br /&gt;
** Show why regex milestones need reconstructed shell command events.&lt;br /&gt;
* &#039;&#039;&#039;Phase 8:&#039;&#039;&#039;&lt;br /&gt;
** Implement keystroke timing.&lt;br /&gt;
** Discuss shell command reconstruction as the next derived-event problem (to be continued in Session 6 onward).&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1-2.5 hours (1-2 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 4 - Graph-derived facts&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 9:&#039;&#039;&#039;&lt;br /&gt;
** Define graph edges and paths.&lt;br /&gt;
* &#039;&#039;&#039;Phase 10:&#039;&#039;&#039;&lt;br /&gt;
** Add the demo-only nonblocking receive helper.&lt;br /&gt;
* &#039;&#039;&#039;Phase 11:&#039;&#039;&#039;&lt;br /&gt;
** Implement direct path derivation.&lt;br /&gt;
* &#039;&#039;&#039;Phase 12:&#039;&#039;&#039;&lt;br /&gt;
** Explore the extension rule by hand.&lt;br /&gt;
* &#039;&#039;&#039;Phase 13:&#039;&#039;&#039;&lt;br /&gt;
** Add path extension feedback.&lt;br /&gt;
** Discuss duplicate prevention.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3.5 hours (1.5-3 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 5 - Nondeterministic processor scheduling&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 14:&#039;&#039;&#039;&lt;br /&gt;
** Add the randomized processor runner.&lt;br /&gt;
* &#039;&#039;&#039;Phase 15:&#039;&#039;&#039;&lt;br /&gt;
** Keep source events fixed.&lt;br /&gt;
** Vary processor scheduling by seed.&lt;br /&gt;
** Compare final path facts across runs.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3.5 hours (1.5-3 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 6 - Follow-up refinements&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 16:&#039;&#039;&#039;&lt;br /&gt;
** Rebuild a graph index from history.&lt;br /&gt;
* &#039;&#039;&#039;Phase 17:&#039;&#039;&#039;&lt;br /&gt;
** Implement shell command reconstruction.&lt;br /&gt;
** Replace local Python payload assumptions with explicit payload projection formats when the bus format layer is ready for the exercise.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-4.5 hours (1.5-4 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
= Event Processor Demonstration Plan =&lt;br /&gt;
&lt;br /&gt;
== Phase 1: Signal over the bus by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Send one message through the direct bus and inspect what the receiver sees.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* A bus is useful only when something emits and something receives.&lt;br /&gt;
* Public message setup uses readable strings: topic, producer, and message type.&lt;br /&gt;
* The direct broker delivers local Python payloads in this demonstration.&lt;br /&gt;
* Capture must be attached before delivery in the current prototype.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Import the bus and capture interfaces:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.capture.sink import CaptureSink&lt;br /&gt;
from mbus.message.records import CapturedMessage&lt;br /&gt;
from mbus.message.symbols import MessageSymbolRegistration&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create a temporary capture sink in the interpreter. This sink stores whatever the bus sends to the capture layer:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
CapturedRecord = MessageSymbolRegistration | CapturedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class ListCaptureSink(CaptureSink):&lt;br /&gt;
    records: list[CapturedRecord] = field(default_factory=list)&lt;br /&gt;
&lt;br /&gt;
    def capture_symbol_registration(&lt;br /&gt;
        self,&lt;br /&gt;
        registration: MessageSymbolRegistration,&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self.records.append(registration)&lt;br /&gt;
&lt;br /&gt;
    def capture(self, message: CapturedMessage) -&amp;gt; None:&lt;br /&gt;
        self.records.append(message)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create the bus and attach capture:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = ListCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the empty capture sink:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
sink&lt;br /&gt;
sink.records&lt;br /&gt;
len(sink.records)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ListCaptureSink(records=[])&lt;br /&gt;
[]&lt;br /&gt;
0&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create a receiver. This describes the messages the receiver wants to hear:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the receiver and confirm that capture is still empty:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
type(receiver)&lt;br /&gt;
vars(receiver)&lt;br /&gt;
sink.records&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;class &#039;mbus.broker.direct._BrokerReceiver&#039;&amp;gt;&lt;br /&gt;
{&#039;_queue&#039;: ...}&lt;br /&gt;
[]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create an emitter. Registering the emitter gives the bus enough information to bind readable names to compact internal IDs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the captured registration records:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
sink.records&lt;br /&gt;
[type(record).__name__ for record in sink.records]&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    print(record)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[&#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;]&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;demo.signals&#039; ...)&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;ping&#039; ...)&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;demo-source&#039; ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit one message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;hello bus&amp;quot;})&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect capture before receiving:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
len(sink.records)&lt;br /&gt;
[type(record).__name__ for record in sink.records]&lt;br /&gt;
sink.records[-1]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
4&lt;br /&gt;
[&#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;CapturedMessage&#039;]&lt;br /&gt;
CapturedMessage(... payload={&#039;text&#039;: &#039;hello bus&#039;} ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
message = receiver.receive()&lt;br /&gt;
&lt;br /&gt;
message&lt;br /&gt;
message.msg_topic&lt;br /&gt;
message.msg_producer&lt;br /&gt;
message.msg_type&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ReceivedMessage(...)&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&#039;demo-source&#039;&lt;br /&gt;
&#039;ping&#039;&lt;br /&gt;
{&#039;text&#039;: &#039;hello bus&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the received message with the captured record:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured = sink.records[-1]&lt;br /&gt;
&lt;br /&gt;
captured&lt;br /&gt;
captured.msg_topic_id&lt;br /&gt;
captured.msg_type_id&lt;br /&gt;
captured.msg_producer_id&lt;br /&gt;
captured.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
CapturedMessage(...)&lt;br /&gt;
TopicID(...)&lt;br /&gt;
MessageTypeID(...)&lt;br /&gt;
ProducerID(...)&lt;br /&gt;
{&#039;text&#039;: &#039;hello bus&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The receiver gets the readable message view. The capture sink sees compact IDs and local payload data. The readable names are still available through symbol registration records, but manually reconstructing them is awkward.&lt;br /&gt;
&lt;br /&gt;
That awkwardness motivates the next phase.&lt;br /&gt;
&lt;br /&gt;
== Phase 2: Build a temporary capture history helper ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a small demonstration helper that turns captured records back into readable history messages.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Capture records are useful, but raw captured messages use compact IDs.&lt;br /&gt;
* A history helper can rebuild readable message history from registration records.&lt;br /&gt;
* This is demonstration infrastructure, not the final persistence/query API.&lt;br /&gt;
* The helper writes captured records into an in-memory &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; sequential unit.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration before the file edit&lt;br /&gt;
&lt;br /&gt;
List only the captured messages:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured_messages = []&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    if isinstance(record, CapturedMessage):&lt;br /&gt;
        captured_messages.append(record)&lt;br /&gt;
&lt;br /&gt;
captured_messages&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[CapturedMessage(... payload={&#039;text&#039;: &#039;hello bus&#039;} ...)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first captured message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured_message = captured_messages[0]&lt;br /&gt;
&lt;br /&gt;
captured_message.msg_topic_id&lt;br /&gt;
captured_message.msg_type_id&lt;br /&gt;
captured_message.msg_producer_id&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
TopicID(...)&lt;br /&gt;
MessageTypeID(...)&lt;br /&gt;
ProducerID(...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Manually reconstruct a topic name:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
topic_names = {}&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    if isinstance(record, MessageSymbolRegistration):&lt;br /&gt;
        if record.symbol_kind.name == &amp;quot;TOPIC&amp;quot;:&lt;br /&gt;
            topic_names[record.symbol_id] = record.symbol&lt;br /&gt;
&lt;br /&gt;
topic_names&lt;br /&gt;
topic_names[captured_message.msg_topic_id]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
{TopicID(...): &#039;demo.signals&#039;}&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This works, but repeating it by hand would distract from writing event processors.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/capture_history.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/capture_history.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Capture and history helpers for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from collections.abc import Iterable&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
&lt;br /&gt;
from aostore.sequentialunit.listsequentialunit import ListSequentialUnit&lt;br /&gt;
from mbus.capture.sink import CaptureSink&lt;br /&gt;
from mbus.message.records import CapturedMessage&lt;br /&gt;
from mbus.message.symbols import (&lt;br /&gt;
    MessageSymbolKind,&lt;br /&gt;
    MessageSymbolRegistration,&lt;br /&gt;
    MessageTypeID,&lt;br /&gt;
    ProducerID,&lt;br /&gt;
    TopicID,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
CapturedRecord = MessageSymbolRegistration | CapturedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class AOStoreCaptureSink(CaptureSink):&lt;br /&gt;
    records: ListSequentialUnit[CapturedRecord] = field(&lt;br /&gt;
        default_factory=ListSequentialUnit&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    def capture_symbol_registration(&lt;br /&gt;
        self,&lt;br /&gt;
        registration: MessageSymbolRegistration,&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self.records.append(registration)&lt;br /&gt;
&lt;br /&gt;
    def capture(self, message: CapturedMessage) -&amp;gt; None:&lt;br /&gt;
        self.records.append(message)&lt;br /&gt;
&lt;br /&gt;
    def all_records(self) -&amp;gt; tuple[CapturedRecord, ...]:&lt;br /&gt;
        records = self.records.sequential_read(0, len(self.records))&lt;br /&gt;
        result = tuple(records)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class HistoryMessage:&lt;br /&gt;
    msg_topic: str&lt;br /&gt;
    msg_type: str&lt;br /&gt;
    msg_producer: str&lt;br /&gt;
    payload: object&lt;br /&gt;
    bus_sequence: int&lt;br /&gt;
    topic_sequence: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class CaptureHistory:&lt;br /&gt;
    _records: tuple[CapturedRecord, ...]&lt;br /&gt;
    _topics: dict[TopicID, str]&lt;br /&gt;
    _types: dict[MessageTypeID, str]&lt;br /&gt;
    _producers: dict[ProducerID, str]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, records: Iterable[CapturedRecord]) -&amp;gt; None:&lt;br /&gt;
        self._records = tuple(records)&lt;br /&gt;
        self._topics = {}&lt;br /&gt;
        self._types = {}&lt;br /&gt;
        self._producers = {}&lt;br /&gt;
        self._index_symbols()&lt;br /&gt;
&lt;br /&gt;
    def _index_symbols(self) -&amp;gt; None:&lt;br /&gt;
        for record in self._records:&lt;br /&gt;
            if isinstance(record, MessageSymbolRegistration):&lt;br /&gt;
                self._record_symbol(record)&lt;br /&gt;
&lt;br /&gt;
    def _record_symbol(self, record: MessageSymbolRegistration) -&amp;gt; None:&lt;br /&gt;
        if record.symbol_kind is MessageSymbolKind.TOPIC:&lt;br /&gt;
            self._topics[record.symbol_id] = record.symbol&lt;br /&gt;
        elif record.symbol_kind is MessageSymbolKind.MSG_TYPE:&lt;br /&gt;
            self._types[record.symbol_id] = record.symbol&lt;br /&gt;
        elif record.symbol_kind is MessageSymbolKind.PRODUCER:&lt;br /&gt;
            self._producers[record.symbol_id] = record.symbol&lt;br /&gt;
&lt;br /&gt;
    def messages(self) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for record in self._records:&lt;br /&gt;
            if isinstance(record, CapturedMessage):&lt;br /&gt;
                message = self._decode_message(record)&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def by_topic(self, msg_topic: str) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for message in self.messages():&lt;br /&gt;
            if message.msg_topic == msg_topic:&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def since(self, bus_sequence: int) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for message in self.messages():&lt;br /&gt;
            if message.bus_sequence &amp;gt;= bus_sequence:&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def _decode_message(self, message: CapturedMessage) -&amp;gt; HistoryMessage:&lt;br /&gt;
        history_message = HistoryMessage(&lt;br /&gt;
            msg_topic=self._topics[message.msg_topic_id],&lt;br /&gt;
            msg_type=self._types[message.msg_type_id],&lt;br /&gt;
            msg_producer=self._producers[message.msg_producer_id],&lt;br /&gt;
            payload=message.payload,&lt;br /&gt;
            bus_sequence=message.bus_sequence,&lt;br /&gt;
            topic_sequence=message.topic_sequence,&lt;br /&gt;
        )&lt;br /&gt;
        return history_message&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;history helper&amp;quot;})&lt;br /&gt;
message = receiver.receive()&lt;br /&gt;
&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
history.messages()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[HistoryMessage(msg_topic=&#039;demo.signals&#039;, msg_type=&#039;ping&#039;, msg_producer=&#039;demo-source&#039;, payload={&#039;text&#039;: &#039;history helper&#039;}, ...)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the decoded history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history_message = history.messages()[0]&lt;br /&gt;
&lt;br /&gt;
history_message.msg_topic&lt;br /&gt;
history_message.msg_type&lt;br /&gt;
history_message.msg_producer&lt;br /&gt;
history_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&#039;ping&#039;&lt;br /&gt;
&#039;demo-source&#039;&lt;br /&gt;
{&#039;text&#039;: &#039;history helper&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 3: Explore shell command data by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Identify the high-level event shape needed by a regex milestone processor.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The old milestone strategy operated on command-level records.&lt;br /&gt;
* A command-level record includes context such as host and working directory.&lt;br /&gt;
* It also includes shell input and shell output.&lt;br /&gt;
* Raw TTY events do not directly have this shape; reconstruction comes later.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Represent a shell command as a dictionary:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = {&lt;br /&gt;
    &amp;quot;session_id&amp;quot;: &amp;quot;session-1&amp;quot;,&lt;br /&gt;
    &amp;quot;command_index&amp;quot;: 0,&lt;br /&gt;
    &amp;quot;host&amp;quot;: &amp;quot;alpha&amp;quot;,&lt;br /&gt;
    &amp;quot;cwd&amp;quot;: &amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    &amp;quot;input_text&amp;quot;: &amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    &amp;quot;output_text&amp;quot;: &amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    &amp;quot;started_at_ns&amp;quot;: 1000,&lt;br /&gt;
    &amp;quot;ended_at_ns&amp;quot;: 2000,&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the fields used by regex milestone checks:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command[&amp;quot;host&amp;quot;]&lt;br /&gt;
command[&amp;quot;cwd&amp;quot;]&lt;br /&gt;
command[&amp;quot;input_text&amp;quot;]&lt;br /&gt;
command[&amp;quot;output_text&amp;quot;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;alpha&#039;&lt;br /&gt;
&#039;/home/student&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Try an accidental wrong key:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command[&amp;quot;working_directory&amp;quot;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeyError: &#039;working_directory&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The dictionary contains the information, but it does not document the shape of the event. A typo is discovered only when the field is used. A named event class makes the processor code easier to read and easier to explain.&lt;br /&gt;
&lt;br /&gt;
== Phase 4: Persist the event record classes ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create reusable payload classes for the TTY, shell command, and regex milestone examples.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/events.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/events.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Payload records for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from dataclasses import dataclass&lt;br /&gt;
from typing import Literal&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class TTYReadEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    data: bytes&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class TTYWriteEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    data: bytes&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class LineDisciplineEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    line: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class KeystrokeTimingEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    previous_timestamp_ns: int | None&lt;br /&gt;
    delta_ns: int | None&lt;br /&gt;
    text: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class ShellCommandEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    command_index: int&lt;br /&gt;
    host: str&lt;br /&gt;
    cwd: str&lt;br /&gt;
    input_text: str&lt;br /&gt;
    output_text: str&lt;br /&gt;
    started_at_ns: int&lt;br /&gt;
    ended_at_ns: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
RegexMilestoneField = Literal[&lt;br /&gt;
    &amp;quot;host&amp;quot;,&lt;br /&gt;
    &amp;quot;cwd&amp;quot;,&lt;br /&gt;
    &amp;quot;input_text&amp;quot;,&lt;br /&gt;
    &amp;quot;output_text&amp;quot;,&lt;br /&gt;
]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneExpression:&lt;br /&gt;
    name: str&lt;br /&gt;
    field: RegexMilestoneField&lt;br /&gt;
    pattern: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneResult:&lt;br /&gt;
    expression_name: str&lt;br /&gt;
    field: RegexMilestoneField&lt;br /&gt;
    matched: bool&lt;br /&gt;
    match_text: str | None = None&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneAnalysisEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    command_index: int&lt;br /&gt;
    results: tuple[RegexMilestoneResult, ...]&lt;br /&gt;
    matched_count: int&lt;br /&gt;
    expression_count: int&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.events import ShellCommandEvent&lt;br /&gt;
&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
command&lt;br /&gt;
command.host&lt;br /&gt;
command.cwd&lt;br /&gt;
command.input_text&lt;br /&gt;
command.output_text&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ShellCommandEvent(session_id=&#039;session-1&#039;, command_index=0, ...)&lt;br /&gt;
&#039;alpha&#039;&lt;br /&gt;
&#039;/home/student&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The event shape is now explicit. Other code can depend on a named &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; instead of assuming that a loose dictionary has the expected fields.&lt;br /&gt;
&lt;br /&gt;
== Phase 5: Evaluate regex milestones manually ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Recreate the core milestone check in a small, transparent form.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* A regex milestone expression names one check against one shell command field.&lt;br /&gt;
* The field may be command input, command output, working directory, or host context.&lt;br /&gt;
* The result records whether the expression matched and, when available, the matched text.&lt;br /&gt;
* Manual evaluation is useful for learning the shape of the problem before writing a processor.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Import regular expressions and the payload classes:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create one command event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create three milestone expressions:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
expressions = (&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;used-ls&amp;quot;,&lt;br /&gt;
        field=&amp;quot;input_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;\bls\b&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;saw-notes&amp;quot;,&lt;br /&gt;
        field=&amp;quot;output_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;notes\.txt&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;home-directory&amp;quot;,&lt;br /&gt;
        field=&amp;quot;cwd&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;^/home/student$&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Evaluate one expression:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
expression = expressions[0]&lt;br /&gt;
text = getattr(command, expression.field)&lt;br /&gt;
match = re.search(expression.pattern, text)&lt;br /&gt;
&lt;br /&gt;
expression.name&lt;br /&gt;
expression.field&lt;br /&gt;
text&lt;br /&gt;
match.group(0)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;used-ls&#039;&lt;br /&gt;
&#039;input_text&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;ls&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Evaluate all expressions:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
manual_results = []&lt;br /&gt;
&lt;br /&gt;
for expression in expressions:&lt;br /&gt;
    text = getattr(command, expression.field)&lt;br /&gt;
    match = re.search(expression.pattern, text)&lt;br /&gt;
    manual_results.append((expression.name, match is not None))&lt;br /&gt;
&lt;br /&gt;
manual_results&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(&#039;used-ls&#039;, True), (&#039;saw-notes&#039;, True), (&#039;home-directory&#039;, True)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
Manual evaluation is simple for one command, but the loop is already event processor logic. The next phase preserves the loop in a reusable processor.&lt;br /&gt;
&lt;br /&gt;
== Phase 6: Persist the RegexMilestone processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a processor that receives shell command events and emits regex milestone analysis events.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/regex_milestones.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/regex_milestones.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Regex milestone processor for shell command events.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneAnalysisEvent,&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    RegexMilestoneResult,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class RegexMilestoneProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _expressions: tuple[RegexMilestoneExpression, ...]&lt;br /&gt;
&lt;br /&gt;
    def __init__(&lt;br /&gt;
        self,&lt;br /&gt;
        bus: DirectMessageBus,&lt;br /&gt;
        expressions: tuple[RegexMilestoneExpression, ...],&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.shell.command&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;shell-reconstructor&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;shell-command&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.shell.regex_milestone&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;regex-milestone-processor&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;regex-milestone-analysis&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._expressions = expressions&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; RegexMilestoneAnalysisEvent:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        command = message.payload&lt;br /&gt;
&lt;br /&gt;
        if not isinstance(command, ShellCommandEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected ShellCommandEvent, got {type(command).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        analysis = analyze_regex_milestones(command, self._expressions)&lt;br /&gt;
        self._emitter.emit(analysis)&lt;br /&gt;
        return analysis&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def analyze_regex_milestones(&lt;br /&gt;
    command: ShellCommandEvent,&lt;br /&gt;
    expressions: tuple[RegexMilestoneExpression, ...],&lt;br /&gt;
) -&amp;gt; RegexMilestoneAnalysisEvent:&lt;br /&gt;
    results = []&lt;br /&gt;
    for expression in expressions:&lt;br /&gt;
        result = evaluate_regex_milestone(command, expression)&lt;br /&gt;
        results.append(result)&lt;br /&gt;
&lt;br /&gt;
    matched_count = sum(result.matched for result in results)&lt;br /&gt;
    analysis = RegexMilestoneAnalysisEvent(&lt;br /&gt;
        session_id=command.session_id,&lt;br /&gt;
        command_index=command.command_index,&lt;br /&gt;
        results=tuple(results),&lt;br /&gt;
        matched_count=matched_count,&lt;br /&gt;
        expression_count=len(expressions),&lt;br /&gt;
    )&lt;br /&gt;
    return analysis&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def evaluate_regex_milestone(&lt;br /&gt;
    command: ShellCommandEvent,&lt;br /&gt;
    expression: RegexMilestoneExpression,&lt;br /&gt;
) -&amp;gt; RegexMilestoneResult:&lt;br /&gt;
    text = getattr(command, expression.field)&lt;br /&gt;
    match = re.search(expression.pattern, text)&lt;br /&gt;
    match_text = None&lt;br /&gt;
&lt;br /&gt;
    if match is not None:&lt;br /&gt;
        match_text = match.group(0)&lt;br /&gt;
&lt;br /&gt;
    result = RegexMilestoneResult(&lt;br /&gt;
        expression_name=expression.name,&lt;br /&gt;
        field=expression.field,&lt;br /&gt;
        matched=match is not None,&lt;br /&gt;
        match_text=match_text,&lt;br /&gt;
    )&lt;br /&gt;
    return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then set up the bus:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.regex_milestones import RegexMilestoneProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Subscribe to the analysis topic before running the processor:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.shell.regex_milestone&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;regex-milestone-processor&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;regex-milestone-analysis&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create the shell command emitter and processor:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.shell.command&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;shell-reconstructor&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;shell-command&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
expressions = (&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;used-ls&amp;quot;,&lt;br /&gt;
        field=&amp;quot;input_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;\bls\b&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;saw-notes&amp;quot;,&lt;br /&gt;
        field=&amp;quot;output_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;notes\.txt&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;home-directory&amp;quot;,&lt;br /&gt;
        field=&amp;quot;cwd&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;^/home/student$&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = RegexMilestoneProcessor(bus, expressions)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit a shell command event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
command_emitter.emit(command)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the command:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis = processor.process_one()&lt;br /&gt;
analysis&lt;br /&gt;
analysis.results&lt;br /&gt;
analysis.matched_count&lt;br /&gt;
analysis.expression_count&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
RegexMilestoneAnalysisEvent(... matched_count=3, expression_count=3)&lt;br /&gt;
(RegexMilestoneResult(...), RegexMilestoneResult(...), RegexMilestoneResult(...))&lt;br /&gt;
3&lt;br /&gt;
3&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the derived analysis event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis_message = analysis_receiver.receive()&lt;br /&gt;
&lt;br /&gt;
analysis_message.msg_topic&lt;br /&gt;
analysis_message.msg_type&lt;br /&gt;
analysis_message.msg_producer&lt;br /&gt;
analysis_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;demo.shell.regex_milestone&#039;&lt;br /&gt;
&#039;regex-milestone-analysis&#039;&lt;br /&gt;
&#039;regex-milestone-processor&#039;&lt;br /&gt;
RegexMilestoneAnalysisEvent(... matched_count=3, expression_count=3)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
messages = history.messages()&lt;br /&gt;
&lt;br /&gt;
for message in messages:&lt;br /&gt;
    print(message.bus_sequence, message.msg_topic, message.msg_type)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
0 demo.shell.command shell-command&lt;br /&gt;
1 demo.shell.regex_milestone regex-milestone-analysis&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The regex milestone processor consumes one event and emits another. The derived analysis event is available to any later subscriber. History contains both the source command event and the derived analysis event.&lt;br /&gt;
&lt;br /&gt;
== Phase 7: Explore why shell reconstruction is separate ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Show that raw TTY observations are not the same thing as high-level shell command events.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Regex milestones should inspect high-level command events.&lt;br /&gt;
* TTY reads, TTY writes, and line-discipline lines are lower-level observations.&lt;br /&gt;
* A shell command event is reconstructed by correlating those observations.&lt;br /&gt;
* Keeping reconstruction separate prevents the regex milestone processor from becoming a large TTY parser.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Create a few low-level observations:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    LineDisciplineEvent,&lt;br /&gt;
    TTYReadEvent,&lt;br /&gt;
    TTYWriteEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_1 = TTYReadEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=0,&lt;br /&gt;
    timestamp_ns=1000,&lt;br /&gt;
    data=b&amp;quot;l&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_2 = TTYReadEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=1,&lt;br /&gt;
    timestamp_ns=1100,&lt;br /&gt;
    data=b&amp;quot;s&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
line = LineDisciplineEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=2,&lt;br /&gt;
    timestamp_ns=1200,&lt;br /&gt;
    line=&amp;quot;ls\n&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
write = TTYWriteEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=3,&lt;br /&gt;
    timestamp_ns=1600,&lt;br /&gt;
    data=b&amp;quot;notes.txt\n&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the raw pieces:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_1.data&lt;br /&gt;
read_2.data&lt;br /&gt;
line.line&lt;br /&gt;
write.data&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
b&#039;l&#039;&lt;br /&gt;
b&#039;s&#039;&lt;br /&gt;
&#039;ls\n&#039;&lt;br /&gt;
b&#039;notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Assemble command fields manually:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
input_text = line.line&lt;br /&gt;
output_text = write.data.decode(&amp;quot;utf-8&amp;quot;, errors=&amp;quot;replace&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
input_text&lt;br /&gt;
output_text&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;ls\n&#039;&lt;br /&gt;
&#039;notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The useful command event is derived from several lower-level observations. A later shell reconstructor can subscribe to line-discipline events, query recent reads/writes/timing observations, and emit &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; records.&lt;br /&gt;
&lt;br /&gt;
== Phase 8: Add keystroke timing as a smaller TTY-derived processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a simple stateful processor before attempting full shell reconstruction.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Timing requires comparing a TTY read with the previous read in the same session.&lt;br /&gt;
* A derived timing event makes the result explicit.&lt;br /&gt;
* Other processors can consume timing events instead of recalculating deltas.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration before the file edit&lt;br /&gt;
&lt;br /&gt;
Calculate timing manually from the two read events:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_events = [read_1, read_2]&lt;br /&gt;
previous_timestamp = None&lt;br /&gt;
timing_rows = []&lt;br /&gt;
&lt;br /&gt;
for event in read_events:&lt;br /&gt;
    delta = None&lt;br /&gt;
    if previous_timestamp is not None:&lt;br /&gt;
        delta = event.timestamp_ns - previous_timestamp&lt;br /&gt;
    timing_rows.append((event.source_index, previous_timestamp, delta))&lt;br /&gt;
    previous_timestamp = event.timestamp_ns&lt;br /&gt;
&lt;br /&gt;
timing_rows&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, None, None), (1, 1000, 100)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/keystroke_timing.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/keystroke_timing.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Keystroke timing processor for TTY read events.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    KeystrokeTimingEvent,&lt;br /&gt;
    TTYReadEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class KeystrokeTimingProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _last_timestamp_by_session: dict[str, int]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.tty.read&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;tty-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;tty-read&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.keystroke.timing&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._last_timestamp_by_session = {}&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; KeystrokeTimingEvent:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        event = message.payload&lt;br /&gt;
&lt;br /&gt;
        if not isinstance(event, TTYReadEvent):&lt;br /&gt;
            raise TypeError(f&amp;quot;expected TTYReadEvent, got {type(event).__name__}&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
        previous_timestamp = self._last_timestamp_by_session.get(&lt;br /&gt;
            event.session_id&lt;br /&gt;
        )&lt;br /&gt;
        delta = None&lt;br /&gt;
&lt;br /&gt;
        if previous_timestamp is not None:&lt;br /&gt;
            delta = event.timestamp_ns - previous_timestamp&lt;br /&gt;
&lt;br /&gt;
        self._last_timestamp_by_session[event.session_id] = event.timestamp_ns&lt;br /&gt;
&lt;br /&gt;
        text = event.data.decode(&amp;quot;utf-8&amp;quot;, errors=&amp;quot;replace&amp;quot;)&lt;br /&gt;
        timing = KeystrokeTimingEvent(&lt;br /&gt;
            session_id=event.session_id,&lt;br /&gt;
            source_index=event.source_index,&lt;br /&gt;
            timestamp_ns=event.timestamp_ns,&lt;br /&gt;
            previous_timestamp_ns=previous_timestamp,&lt;br /&gt;
            delta_ns=delta,&lt;br /&gt;
            text=text,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter.emit(timing)&lt;br /&gt;
        return timing&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
from demos.event_processors.events import TTYReadEvent&lt;br /&gt;
from demos.event_processors.keystroke_timing import KeystrokeTimingProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
timing_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.keystroke.timing&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.tty.read&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;tty-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;tty-read&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = KeystrokeTimingProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit reads and process them:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_emitter.emit(&lt;br /&gt;
    TTYReadEvent(&lt;br /&gt;
        session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
        source_index=0,&lt;br /&gt;
        timestamp_ns=1000,&lt;br /&gt;
        data=b&amp;quot;l&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_emitter.emit(&lt;br /&gt;
    TTYReadEvent(&lt;br /&gt;
        session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
        source_index=1,&lt;br /&gt;
        timestamp_ns=1100,&lt;br /&gt;
        data=b&amp;quot;s&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor.process_one()&lt;br /&gt;
processor.process_one()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeystrokeTimingEvent(... previous_timestamp_ns=None, delta_ns=None, text=&#039;l&#039;)&lt;br /&gt;
KeystrokeTimingEvent(... previous_timestamp_ns=1000, delta_ns=100, text=&#039;s&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive timing events:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
timing_receiver.receive().payload&lt;br /&gt;
timing_receiver.receive().payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeystrokeTimingEvent(... source_index=0 ...)&lt;br /&gt;
KeystrokeTimingEvent(... source_index=1 ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 9: Introduce graph events ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Switch to a small graph problem that can demonstrate feedback loops safely.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* An edge is a source fact.&lt;br /&gt;
* A path is a derived fact.&lt;br /&gt;
* A direct edge implies a direct path.&lt;br /&gt;
* Longer paths can be derived later from known paths and edges.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_events.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/graph_events.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Graph event payloads for message bus demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from dataclasses import dataclass&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class EdgeDeclaredEvent:&lt;br /&gt;
    graph_id: str&lt;br /&gt;
    source: str&lt;br /&gt;
    target: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class PathKnownEvent:&lt;br /&gt;
    graph_id: str&lt;br /&gt;
    source: str&lt;br /&gt;
    target: str&lt;br /&gt;
    hop_count: int&lt;br /&gt;
&lt;br /&gt;
    def fact_key(self) -&amp;gt; tuple[str, str, str]:&lt;br /&gt;
        result = (self.graph_id, self.source, self.target)&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge = EdgeDeclaredEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;A&amp;quot;,&lt;br /&gt;
    target=&amp;quot;B&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path = PathKnownEvent(&lt;br /&gt;
    graph_id=edge.graph_id,&lt;br /&gt;
    source=edge.source,&lt;br /&gt;
    target=edge.target,&lt;br /&gt;
    hop_count=1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge&lt;br /&gt;
path&lt;br /&gt;
path.fact_key()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
EdgeDeclaredEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;)&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;, hop_count=1)&lt;br /&gt;
(&#039;demo-graph&#039;, &#039;A&#039;, &#039;B&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 10: Create a demo-only nonblocking receive helper ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Prepare for processor scheduling without editing &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;Receiver.receive()&amp;lt;/code&amp;gt; blocks until a message is available.&lt;br /&gt;
* A scheduler needs to try a processor and move on if no message is waiting.&lt;br /&gt;
* The current package does not expose a public nonblocking receiver method.&lt;br /&gt;
* This helper is local demonstration code. It can be replaced by a real public API later.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/demo_receive.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/demo_receive.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Demo-only helpers for receiver inspection and scheduling.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from queue import Empty&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.endpoints import Receiver&lt;br /&gt;
from mbus.message.records import ReceivedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class DemoReceiverError(RuntimeError):&lt;br /&gt;
    pass&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def try_receive(receiver: Receiver) -&amp;gt; ReceivedMessage | None:&lt;br /&gt;
    queue = getattr(receiver, &amp;quot;_queue&amp;quot;, None)&lt;br /&gt;
&lt;br /&gt;
    if queue is None:&lt;br /&gt;
        raise DemoReceiverError(&lt;br /&gt;
            &amp;quot;demo try_receive requires the current direct broker receiver&amp;quot;&lt;br /&gt;
        )&lt;br /&gt;
&lt;br /&gt;
    try:&lt;br /&gt;
        message = queue.get_nowait()&lt;br /&gt;
    except Empty:&lt;br /&gt;
        message = None&lt;br /&gt;
&lt;br /&gt;
    return message&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.demo_receive import try_receive&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
try_receive(receiver)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
None&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Now emit a message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;demo nonblocking receive&amp;quot;})&lt;br /&gt;
message = try_receive(receiver)&lt;br /&gt;
&lt;br /&gt;
message&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ReceivedMessage(...)&lt;br /&gt;
{&#039;text&#039;: &#039;demo nonblocking receive&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 11: Create the direct path processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Implement the graph rule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
edge(A, B) -&amp;gt; path(A, B)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The source emits edge events.&lt;br /&gt;
* The direct path processor emits derived path events.&lt;br /&gt;
* Other processors can subscribe to derived path events.&lt;br /&gt;
* The processor can be run by hand or by a scheduler.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_reachability.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/graph_reachability.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Graph reachability processors for message bus demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.demo_receive import try_receive&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class DirectPathProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _known_paths: set[tuple[str, str, str]]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._known_paths = set()&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; PathKnownEvent | None:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        result = self._process_payload(message.payload)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
    def process_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            self._process_payload(message.payload)&lt;br /&gt;
            work_count = 1&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def _process_payload(self, payload: object) -&amp;gt; PathKnownEvent | None:&lt;br /&gt;
        if not isinstance(payload, EdgeDeclaredEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected EdgeDeclaredEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        path = PathKnownEvent(&lt;br /&gt;
            graph_id=payload.graph_id,&lt;br /&gt;
            source=payload.source,&lt;br /&gt;
            target=payload.target,&lt;br /&gt;
            hop_count=1,&lt;br /&gt;
        )&lt;br /&gt;
&lt;br /&gt;
        if path.fact_key() not in self._known_paths:&lt;br /&gt;
            self._known_paths.add(path.fact_key())&lt;br /&gt;
            self._emitter.emit(path)&lt;br /&gt;
            result = path&lt;br /&gt;
        else:&lt;br /&gt;
            result = None&lt;br /&gt;
&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
from demos.event_processors.graph_events import EdgeDeclaredEvent&lt;br /&gt;
from demos.event_processors.graph_reachability import DirectPathProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
path_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = DirectPathProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Call the processor before any edge exists:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
0&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit one edge:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;A&amp;quot;,&lt;br /&gt;
        target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the edge:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the derived path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_message = path_receiver.receive()&lt;br /&gt;
path_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;, hop_count=1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 12: Explore the path extension rule by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Understand the feedback rule before implementing it.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* If &amp;lt;code&amp;gt;A&amp;lt;/code&amp;gt; reaches &amp;lt;code&amp;gt;B&amp;lt;/code&amp;gt;, and there is an edge from &amp;lt;code&amp;gt;B&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;C&amp;lt;/code&amp;gt;, then &amp;lt;code&amp;gt;A&amp;lt;/code&amp;gt; reaches &amp;lt;code&amp;gt;C&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Derived path facts can lead to more derived path facts.&lt;br /&gt;
* Duplicate prevention keeps the feedback loop from repeating the same fact forever.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_bc = EdgeDeclaredEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;B&amp;quot;,&lt;br /&gt;
    target=&amp;quot;C&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path_ab = PathKnownEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;A&amp;quot;,&lt;br /&gt;
    target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    hop_count=1,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Check whether the path and edge connect:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_ab.target&lt;br /&gt;
edge_bc.source&lt;br /&gt;
path_ab.target == edge_bc.source&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;B&#039;&lt;br /&gt;
&#039;B&#039;&lt;br /&gt;
True&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Derive a new path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_ac = PathKnownEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=path_ab.source,&lt;br /&gt;
    target=edge_bc.target,&lt;br /&gt;
    hop_count=path_ab.hop_count + 1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path_ac&lt;br /&gt;
path_ac.fact_key()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;C&#039;, hop_count=2)&lt;br /&gt;
(&#039;demo-graph&#039;, &#039;A&#039;, &#039;C&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Manually prevent duplicate path facts:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
known_paths = set()&lt;br /&gt;
known_paths.add(path_ab.fact_key())&lt;br /&gt;
&lt;br /&gt;
path_ac.fact_key() in known_paths&lt;br /&gt;
&lt;br /&gt;
if path_ac.fact_key() not in known_paths:&lt;br /&gt;
    known_paths.add(path_ac.fact_key())&lt;br /&gt;
&lt;br /&gt;
known_paths&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
False&lt;br /&gt;
{(&#039;demo-graph&#039;, &#039;A&#039;, &#039;B&#039;), (&#039;demo-graph&#039;, &#039;A&#039;, &#039;C&#039;)}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 13: Add the path extension processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Implement the feedback rule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
path(A, B) + edge(B, C) -&amp;gt; path(A, C)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The extension processor listens to edge events and path events.&lt;br /&gt;
* It emits new path events when a known path can be extended.&lt;br /&gt;
* It also listens to its own output because an extended path may be extendable again.&lt;br /&gt;
* This is a controlled feedback loop.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Append this class to:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_reachability.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
class PathExtensionProcessor:&lt;br /&gt;
    _edge_receiver: Receiver&lt;br /&gt;
    _direct_path_receiver: Receiver&lt;br /&gt;
    _extension_path_receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _edges: set[tuple[str, str, str]]&lt;br /&gt;
    _paths: dict[tuple[str, str, str], PathKnownEvent]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._edge_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._direct_path_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._extension_path_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._edges = set()&lt;br /&gt;
        self._paths = {}&lt;br /&gt;
&lt;br /&gt;
    def process_edge_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._edge_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_edge_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def process_direct_path_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._direct_path_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_path_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def process_extension_path_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._extension_path_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_path_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def _process_edge_payload(&lt;br /&gt;
        self,&lt;br /&gt;
        payload: object,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        if not isinstance(payload, EdgeDeclaredEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected EdgeDeclaredEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        edge = payload&lt;br /&gt;
        self._edges.add((edge.graph_id, edge.source, edge.target))&lt;br /&gt;
        paths = self._derive_from_new_edge(edge)&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _process_path_payload(&lt;br /&gt;
        self,&lt;br /&gt;
        payload: object,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        if not isinstance(payload, PathKnownEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected PathKnownEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        path = payload&lt;br /&gt;
        self._paths[path.fact_key()] = path&lt;br /&gt;
        paths = self._derive_from_new_path(path)&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _derive_from_new_edge(&lt;br /&gt;
        self,&lt;br /&gt;
        edge: EdgeDeclaredEvent,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        paths = []&lt;br /&gt;
&lt;br /&gt;
        for known_path in self._paths.values():&lt;br /&gt;
            if known_path.graph_id != edge.graph_id:&lt;br /&gt;
                continue&lt;br /&gt;
            if known_path.target != edge.source:&lt;br /&gt;
                continue&lt;br /&gt;
&lt;br /&gt;
            next_path = PathKnownEvent(&lt;br /&gt;
                graph_id=edge.graph_id,&lt;br /&gt;
                source=known_path.source,&lt;br /&gt;
                target=edge.target,&lt;br /&gt;
                hop_count=known_path.hop_count + 1,&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
            if self._emit_if_new(next_path):&lt;br /&gt;
                paths.append(next_path)&lt;br /&gt;
&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _derive_from_new_path(&lt;br /&gt;
        self,&lt;br /&gt;
        path: PathKnownEvent,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        paths = []&lt;br /&gt;
&lt;br /&gt;
        for graph_id, edge_source, edge_target in self._edges:&lt;br /&gt;
            if graph_id != path.graph_id:&lt;br /&gt;
                continue&lt;br /&gt;
            if edge_source != path.target:&lt;br /&gt;
                continue&lt;br /&gt;
&lt;br /&gt;
            next_path = PathKnownEvent(&lt;br /&gt;
                graph_id=path.graph_id,&lt;br /&gt;
                source=path.source,&lt;br /&gt;
                target=edge_target,&lt;br /&gt;
                hop_count=path.hop_count + 1,&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
            if self._emit_if_new(next_path):&lt;br /&gt;
                paths.append(next_path)&lt;br /&gt;
&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _emit_if_new(self, path: PathKnownEvent) -&amp;gt; bool:&lt;br /&gt;
        if path.fact_key() in self._paths:&lt;br /&gt;
            result = False&lt;br /&gt;
        else:&lt;br /&gt;
            self._paths[path.fact_key()] = path&lt;br /&gt;
            self._emitter.emit(path)&lt;br /&gt;
            result = True&lt;br /&gt;
&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_events import EdgeDeclaredEvent&lt;br /&gt;
from demos.event_processors.graph_reachability import (&lt;br /&gt;
    DirectPathProcessor,&lt;br /&gt;
    PathExtensionProcessor,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
extension_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
direct_processor = DirectPathProcessor(bus)&lt;br /&gt;
extension_processor = PathExtensionProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit two edges in fixed order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;A&amp;quot;,&lt;br /&gt;
        target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;B&amp;quot;,&lt;br /&gt;
        target=&amp;quot;C&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the direct paths:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
direct_processor.process_available()&lt;br /&gt;
direct_processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Let the extension processor learn the edges:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
extension_processor.process_edge_available()&lt;br /&gt;
extension_processor.process_edge_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Let the extension processor learn the direct paths:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
extension_processor.process_direct_path_available()&lt;br /&gt;
extension_processor.process_direct_path_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The second call may emit the extended &amp;lt;code&amp;gt;A -&amp;gt; C&amp;lt;/code&amp;gt; path after the processor has seen both direct paths and both edges.&lt;br /&gt;
&lt;br /&gt;
Receive the extended path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
message = extension_receiver.receive()&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;C&#039;, hop_count=2)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect captured graph path history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
path_messages = history.by_topic(&amp;quot;demo.graph.path&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
for message in path_messages:&lt;br /&gt;
    path = message.payload&lt;br /&gt;
    print(&lt;br /&gt;
        message.bus_sequence,&lt;br /&gt;
        message.msg_producer,&lt;br /&gt;
        path.source,&lt;br /&gt;
        path.target,&lt;br /&gt;
        path.hop_count,&lt;br /&gt;
    )&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
... direct-path A B 1&lt;br /&gt;
... direct-path B C 1&lt;br /&gt;
... path-extension A C 2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 14: Add a randomized processor scheduler ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Simulate concurrency effects without changing source input order.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The source will emit the same edge events in the same order every run.&lt;br /&gt;
* The scheduler varies which processor gets a chance to run first.&lt;br /&gt;
* This simulates uncertainty from processing time, scheduling priority, startup timing, or transmission delay.&lt;br /&gt;
* The graph example should converge to the same final path facts even when processor scheduling, and hence the order of intermediate derived processor events, differs.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/runners.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/runners.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Small processor runners for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from collections.abc import Callable&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
from random import Random&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class ProcessorStep:&lt;br /&gt;
    name: str&lt;br /&gt;
    run: Callable[[], int]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class RandomProcessorRunner:&lt;br /&gt;
    steps: tuple[ProcessorStep, ...]&lt;br /&gt;
    seed: int&lt;br /&gt;
    max_rounds: int = 30&lt;br /&gt;
    trace: list[tuple[int, str, int]] = field(default_factory=list)&lt;br /&gt;
&lt;br /&gt;
    def run_until_quiet(self) -&amp;gt; list[tuple[int, str, int]]:&lt;br /&gt;
        rng = Random(self.seed)&lt;br /&gt;
&lt;br /&gt;
        for round_index in range(self.max_rounds):&lt;br /&gt;
            work_count = self._run_round(rng, round_index)&lt;br /&gt;
&lt;br /&gt;
            if work_count == 0:&lt;br /&gt;
                break&lt;br /&gt;
&lt;br /&gt;
        result = list(self.trace)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
    def _run_round(self, rng: Random, round_index: int) -&amp;gt; int:&lt;br /&gt;
        steps = list(self.steps)&lt;br /&gt;
        rng.shuffle(steps)&lt;br /&gt;
        round_work_count = 0&lt;br /&gt;
&lt;br /&gt;
        for step in steps:&lt;br /&gt;
            step_work_count = step.run()&lt;br /&gt;
            self.trace.append((round_index, step.name, step_work_count))&lt;br /&gt;
            round_work_count += step_work_count&lt;br /&gt;
&lt;br /&gt;
        return round_work_count&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then inspect how a runner can shuffle placeholder steps:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.runners import (&lt;br /&gt;
    ProcessorStep,&lt;br /&gt;
    RandomProcessorRunner,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
calls = []&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def first_step() -&amp;gt; int:&lt;br /&gt;
    calls.append(&amp;quot;first&amp;quot;)&lt;br /&gt;
    return 0&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def second_step() -&amp;gt; int:&lt;br /&gt;
    calls.append(&amp;quot;second&amp;quot;)&lt;br /&gt;
    return 0&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
runner = RandomProcessorRunner(&lt;br /&gt;
    steps=(&lt;br /&gt;
        ProcessorStep(name=&amp;quot;first&amp;quot;, run=first_step),&lt;br /&gt;
        ProcessorStep(name=&amp;quot;second&amp;quot;, run=second_step),&lt;br /&gt;
    ),&lt;br /&gt;
    seed=1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
runner.run_until_quiet()&lt;br /&gt;
calls&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, &#039;...&#039;, 0), (0, &#039;...&#039;, 0)]&lt;br /&gt;
[&#039;...&#039;, &#039;...&#039;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 15: Run the graph example with randomized scheduling ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Run the same graph input under different processor schedules and compare the final path facts.&lt;br /&gt;
&lt;br /&gt;
; Interpreter setup&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_reachability import (&lt;br /&gt;
    DirectPathProcessor,&lt;br /&gt;
    PathExtensionProcessor,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.runners import (&lt;br /&gt;
    ProcessorStep,&lt;br /&gt;
    RandomProcessorRunner,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Define a helper function in the interpreter:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
def run_graph_demo(seed: int) -&amp;gt; tuple[&lt;br /&gt;
    list[tuple[int, str, int]],&lt;br /&gt;
    list[tuple[int, str, str, int]],&lt;br /&gt;
]:&lt;br /&gt;
    bus = DirectMessageBus()&lt;br /&gt;
    sink = AOStoreCaptureSink()&lt;br /&gt;
    bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
    edge_emitter = bus.register_emitter(&lt;br /&gt;
        msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
        msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
        default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    direct_processor = DirectPathProcessor(bus)&lt;br /&gt;
    extension_processor = PathExtensionProcessor(bus)&lt;br /&gt;
&lt;br /&gt;
    edges = (&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;A&amp;quot;,&lt;br /&gt;
            target=&amp;quot;B&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;B&amp;quot;,&lt;br /&gt;
            target=&amp;quot;C&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;C&amp;quot;,&lt;br /&gt;
            target=&amp;quot;D&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    for edge in edges:&lt;br /&gt;
        edge_emitter.emit(edge)&lt;br /&gt;
&lt;br /&gt;
    steps = (&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;direct path from edge&amp;quot;,&lt;br /&gt;
            run=direct_processor.process_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns edge&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_edge_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns direct path&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_direct_path_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns extension path&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_extension_path_available,&lt;br /&gt;
        ),&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    runner = RandomProcessorRunner(&lt;br /&gt;
        steps=steps,&lt;br /&gt;
        seed=seed,&lt;br /&gt;
    )&lt;br /&gt;
    trace = runner.run_until_quiet()&lt;br /&gt;
&lt;br /&gt;
    history = CaptureHistory(sink.all_records())&lt;br /&gt;
    path_facts = []&lt;br /&gt;
&lt;br /&gt;
    for message in history.by_topic(&amp;quot;demo.graph.path&amp;quot;):&lt;br /&gt;
        path = message.payload&lt;br /&gt;
        if isinstance(path, PathKnownEvent):&lt;br /&gt;
            fact = (&lt;br /&gt;
                message.bus_sequence,&lt;br /&gt;
                path.source,&lt;br /&gt;
                path.target,&lt;br /&gt;
                path.hop_count,&lt;br /&gt;
            )&lt;br /&gt;
            path_facts.append(fact)&lt;br /&gt;
&lt;br /&gt;
    result = (trace, path_facts)&lt;br /&gt;
    return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Run one schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_1, paths_1 = run_graph_demo(1)&lt;br /&gt;
&lt;br /&gt;
trace_1&lt;br /&gt;
paths_1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, &#039;...&#039;, ...), (0, &#039;...&#039;, ...), ...]&lt;br /&gt;
[(..., &#039;A&#039;, &#039;B&#039;, 1), (..., &#039;B&#039;, &#039;C&#039;, 1), (..., &#039;C&#039;, &#039;D&#039;, 1), ...]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Run another schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_2, paths_2 = run_graph_demo(2)&lt;br /&gt;
&lt;br /&gt;
trace_2&lt;br /&gt;
paths_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the schedules:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_1 == trace_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
False&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the final path facts without bus sequence numbers:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
final_paths_1 = {&lt;br /&gt;
    (source, target, hop_count)&lt;br /&gt;
    for _, source, target, hop_count in paths_1&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
final_paths_2 = {&lt;br /&gt;
    (source, target, hop_count)&lt;br /&gt;
    for _, source, target, hop_count in paths_2&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
final_paths_1&lt;br /&gt;
final_paths_2&lt;br /&gt;
final_paths_1 == final_paths_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
{(&#039;A&#039;, &#039;B&#039;, 1), (&#039;B&#039;, &#039;C&#039;, 1), (&#039;C&#039;, &#039;D&#039;, 1), (&#039;A&#039;, &#039;C&#039;, 2), (&#039;B&#039;, &#039;D&#039;, 2), (&#039;A&#039;, &#039;D&#039;, 3)}&lt;br /&gt;
{(&#039;A&#039;, &#039;B&#039;, 1), (&#039;B&#039;, &#039;C&#039;, 1), (&#039;C&#039;, &#039;D&#039;, 1), (&#039;A&#039;, &#039;C&#039;, 2), (&#039;B&#039;, &#039;D&#039;, 2), (&#039;A&#039;, &#039;D&#039;, 3)}&lt;br /&gt;
True&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for round_index, step_name, work_count in trace_1:&lt;br /&gt;
    print(round_index, step_name, work_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first captured path order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for bus_sequence, source, target, hop_count in paths_1:&lt;br /&gt;
    print(bus_sequence, source, target, hop_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the second schedule and path order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for round_index, step_name, work_count in trace_2:&lt;br /&gt;
    print(round_index, step_name, work_count)&lt;br /&gt;
&lt;br /&gt;
for bus_sequence, source, target, hop_count in paths_2:&lt;br /&gt;
    print(bus_sequence, source, target, hop_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The source input order did not change. The processor scheduling changed. The captured event order may differ. The final reachability facts should match.&lt;br /&gt;
&lt;br /&gt;
This models an important distributed-systems lesson: event history order and final derived state are related, but they are not the same thing.&lt;br /&gt;
&lt;br /&gt;
== Phase 16: Later refinement: graph index from history ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Introduce restart/recovery thinking only after the basic graph feedback example is familiar.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The first graph processors keep local working state.&lt;br /&gt;
* Local working state is enough for a short demonstration.&lt;br /&gt;
* Longer-lived processors need recovery behavior after restart.&lt;br /&gt;
* Since edge and path facts are captured as events, a graph index can later be rebuilt from history.&lt;br /&gt;
* This points toward projection-style services without changing the basic processor model.&lt;br /&gt;
&lt;br /&gt;
; Suggested later file&lt;br /&gt;
&lt;br /&gt;
Create later:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_index.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Suggested responsibilities:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
read captured history&lt;br /&gt;
collect EdgeDeclaredEvent payloads&lt;br /&gt;
collect PathKnownEvent payloads&lt;br /&gt;
answer whether a path fact exists&lt;br /&gt;
find edges starting at a node&lt;br /&gt;
find paths ending at a node&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This refinement belongs after the team has already run the graph reachability demo successfully.&lt;br /&gt;
&lt;br /&gt;
== Phase 17: Later refinement: shell command reconstruction ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Connect the TTY examples back to the shell command event shape used by regex milestones.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; is the high-level event consumed by the regex milestone processor.&lt;br /&gt;
* &amp;lt;code&amp;gt;TTYReadEvent&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;TTYWriteEvent&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;LineDisciplineEvent&amp;lt;/code&amp;gt; are lower-level observations.&lt;br /&gt;
* A reconstructor can subscribe to line-discipline events and query recent related observations.&lt;br /&gt;
* The reconstructor should emit shell command events; it should not perform regex analysis itself.&lt;br /&gt;
&lt;br /&gt;
; Suggested responsibilities&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
listen for completed line-discipline events&lt;br /&gt;
query history since the previous command boundary for the same session&lt;br /&gt;
collect matching reads, writes, and timing events&lt;br /&gt;
construct ShellCommandEvent&lt;br /&gt;
emit ShellCommandEvent on demo.shell.command&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This is an appropriate follow-up exercise after the team understands both regex milestone processing and history queries.&lt;br /&gt;
&lt;br /&gt;
= Related =&lt;br /&gt;
&lt;br /&gt;
== Top-level topic page ==&lt;br /&gt;
[[Message Bus Service]]&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=Message_Bus_Live_Coding_Exercise&amp;diff=936</id>
		<title>Message Bus Live Coding Exercise</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=Message_Bus_Live_Coding_Exercise&amp;diff=936"/>
		<updated>2026-06-16T04:30:06Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* Audience and scope */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Introduction =&lt;br /&gt;
&lt;br /&gt;
== Audience and scope ==&lt;br /&gt;
&lt;br /&gt;
This exercise is intended for EDURange developers learning how to write event processors with the message bus.&lt;br /&gt;
&lt;br /&gt;
The packaged &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; modules are treated as read-only for this exercise. All new code belongs under a demonstration directory such as:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
workspace/&lt;br /&gt;
    mbus/&lt;br /&gt;
    aostore/&lt;br /&gt;
    demos/&lt;br /&gt;
        __init__.py&lt;br /&gt;
        event_processors/&lt;br /&gt;
            __init__.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Pre-release copies of &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; are available on the Discord fileshare channel. Modules may be released under other package names; see also the EDURange GitHub.&lt;br /&gt;
&lt;br /&gt;
The exercise uses two problem families.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;1. Shell and TTY event processing&#039;&#039;&#039;&lt;br /&gt;
** Send and receive messages through the bus.&lt;br /&gt;
** Capture and inspect message history.&lt;br /&gt;
** Represent high-level shell command events.&lt;br /&gt;
** Recreate the regex-based milestone role in a simplified form.&lt;br /&gt;
** Mock up TTY-derived observations such as keystroke timing.&lt;br /&gt;
* &#039;&#039;&#039;2. Graph event processing&#039;&#039;&#039;&lt;br /&gt;
** Represent graph edge relationships as source events.&lt;br /&gt;
** Derive direct path facts from edge relation events.&lt;br /&gt;
** Add feedback by deriving longer paths from known paths.&lt;br /&gt;
** Simulate nondeterministic processor scheduling while keeping source input fixed.&lt;br /&gt;
&lt;br /&gt;
The shell examples draw from EDURange&#039;s institutional knowledge. The graph examples provide a common model problem family for meaningful use of feedback loops.&lt;br /&gt;
&lt;br /&gt;
== Running the examples ==&lt;br /&gt;
&lt;br /&gt;
Adjust paths to match the local checkout layout if necessary. Start an interpreter from the workspace root with project packages available.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
PYTHONPATH=&amp;quot;$PWD/mbus:$PWD/aostore:$PWD&amp;quot; python3.14&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
When module files are changed, restart the interpreter unless the group is deliberately practicing &amp;lt;code&amp;gt;importlib.reload()&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Create the demonstration package ==&lt;br /&gt;
&lt;br /&gt;
Before the first durable file edit, create the demonstration package directories:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir -p demos/event_processors&lt;br /&gt;
touch demos/__init__.py&lt;br /&gt;
touch demos/event_processors/__init__.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
== Important note about capture records ==&lt;br /&gt;
&lt;br /&gt;
The current demonstration works close to the low-level capture layer. As a result, it exposes &amp;lt;code&amp;gt;MessageSymbolRegistration&amp;lt;/code&amp;gt; records while building the temporary history helper.&lt;br /&gt;
&lt;br /&gt;
That is not the intended ordinary user experience. In a more complete release, higher-level capture and history infrastructure should handle symbol registration bookkeeping on behalf of processor authors. In this exercise, symbol registration records are visible because the demonstration history helper reconstructs readable messages from compact captured records directly.&lt;br /&gt;
&lt;br /&gt;
== Suggested group session flow ==&lt;br /&gt;
&lt;br /&gt;
; Session 1 - Bus and capture basics&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 1:&#039;&#039;&#039;&lt;br /&gt;
** Signal over the bus by hand.&lt;br /&gt;
** Inspect received messages.&lt;br /&gt;
** Inspect low-level captured records.&lt;br /&gt;
* &#039;&#039;&#039;Phase 2:&#039;&#039;&#039;&lt;br /&gt;
** Discuss why symbol registration appears in this temporary demo.&lt;br /&gt;
** Build the capture history helper.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1-3 hours (1-2 hours work, 0.5-1 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 2 - Shell command and regex milestones&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 3:&#039;&#039;&#039;&lt;br /&gt;
** Represent a shell command as a dictionary.&lt;br /&gt;
* &#039;&#039;&#039;Phase 4:&#039;&#039;&#039;&lt;br /&gt;
** Replace it with &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Phase 5:&#039;&#039;&#039;&lt;br /&gt;
** Manually evaluate regex milestone expressions.&lt;br /&gt;
* &#039;&#039;&#039;Phase 6:&#039;&#039;&#039;&lt;br /&gt;
** Implement &amp;lt;code&amp;gt;RegexMilestoneProcessor&amp;lt;/code&amp;gt;.&lt;br /&gt;
** Inspect source and analysis events in history.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3 hours (1.5-2.5 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 3 - TTY-derived observations&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 7:&#039;&#039;&#039;&lt;br /&gt;
** Create raw TTY read, write, and line events.&lt;br /&gt;
** Show why regex milestones need reconstructed shell command events.&lt;br /&gt;
* &#039;&#039;&#039;Phase 8:&#039;&#039;&#039;&lt;br /&gt;
** Implement keystroke timing.&lt;br /&gt;
** Discuss shell command reconstruction as the next derived-event problem (to be continued in Session 6 onward).&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1-2.5 hours (1-2 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 4 - Graph-derived facts&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 9:&#039;&#039;&#039;&lt;br /&gt;
** Define graph edges and paths.&lt;br /&gt;
* &#039;&#039;&#039;Phase 10:&#039;&#039;&#039;&lt;br /&gt;
** Add the demo-only nonblocking receive helper.&lt;br /&gt;
* &#039;&#039;&#039;Phase 11:&#039;&#039;&#039;&lt;br /&gt;
** Implement direct path derivation.&lt;br /&gt;
* &#039;&#039;&#039;Phase 12:&#039;&#039;&#039;&lt;br /&gt;
** Explore the extension rule by hand.&lt;br /&gt;
* &#039;&#039;&#039;Phase 13:&#039;&#039;&#039;&lt;br /&gt;
** Add path extension feedback.&lt;br /&gt;
** Discuss duplicate prevention.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3.5 hours (1.5-3 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 5 - Nondeterministic processor scheduling&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 14:&#039;&#039;&#039;&lt;br /&gt;
** Add the randomized processor runner.&lt;br /&gt;
* &#039;&#039;&#039;Phase 15:&#039;&#039;&#039;&lt;br /&gt;
** Keep source events fixed.&lt;br /&gt;
** Vary processor scheduling by seed.&lt;br /&gt;
** Compare final path facts across runs.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3.5 hours (1.5-3 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 6 - Follow-up refinements&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 16:&#039;&#039;&#039;&lt;br /&gt;
** Rebuild a graph index from history.&lt;br /&gt;
* &#039;&#039;&#039;Phase 17:&#039;&#039;&#039;&lt;br /&gt;
** Implement shell command reconstruction.&lt;br /&gt;
** Replace local Python payload assumptions with explicit payload projection formats when the bus format layer is ready for the exercise.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-4.5 hours (1.5-4 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
= Event Processor Demonstration Plan =&lt;br /&gt;
&lt;br /&gt;
== Phase 1: Signal over the bus by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Send one message through the direct bus and inspect what the receiver sees.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* A bus is useful only when something emits and something receives.&lt;br /&gt;
* Public message setup uses readable strings: topic, producer, and message type.&lt;br /&gt;
* The direct broker delivers local Python payloads in this demonstration.&lt;br /&gt;
* Capture must be attached before delivery in the current prototype.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Import the bus and capture interfaces:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.capture.sink import CaptureSink&lt;br /&gt;
from mbus.message.records import CapturedMessage&lt;br /&gt;
from mbus.message.symbols import MessageSymbolRegistration&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create a temporary capture sink in the interpreter. This sink stores whatever the bus sends to the capture layer:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
CapturedRecord = MessageSymbolRegistration | CapturedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class ListCaptureSink(CaptureSink):&lt;br /&gt;
    records: list[CapturedRecord] = field(default_factory=list)&lt;br /&gt;
&lt;br /&gt;
    def capture_symbol_registration(&lt;br /&gt;
        self,&lt;br /&gt;
        registration: MessageSymbolRegistration,&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self.records.append(registration)&lt;br /&gt;
&lt;br /&gt;
    def capture(self, message: CapturedMessage) -&amp;gt; None:&lt;br /&gt;
        self.records.append(message)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create the bus and attach capture:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = ListCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the empty capture sink:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
sink&lt;br /&gt;
sink.records&lt;br /&gt;
len(sink.records)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ListCaptureSink(records=[])&lt;br /&gt;
[]&lt;br /&gt;
0&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create a receiver. This describes the messages the receiver wants to hear:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the receiver and confirm that capture is still empty:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
type(receiver)&lt;br /&gt;
vars(receiver)&lt;br /&gt;
sink.records&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;class &#039;mbus.broker.direct._BrokerReceiver&#039;&amp;gt;&lt;br /&gt;
{&#039;_queue&#039;: ...}&lt;br /&gt;
[]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create an emitter. Registering the emitter gives the bus enough information to bind readable names to compact internal IDs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the captured registration records:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
sink.records&lt;br /&gt;
[type(record).__name__ for record in sink.records]&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    print(record)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[&#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;]&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;demo.signals&#039; ...)&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;ping&#039; ...)&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;demo-source&#039; ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit one message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;hello bus&amp;quot;})&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect capture before receiving:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
len(sink.records)&lt;br /&gt;
[type(record).__name__ for record in sink.records]&lt;br /&gt;
sink.records[-1]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
4&lt;br /&gt;
[&#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;CapturedMessage&#039;]&lt;br /&gt;
CapturedMessage(... payload={&#039;text&#039;: &#039;hello bus&#039;} ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
message = receiver.receive()&lt;br /&gt;
&lt;br /&gt;
message&lt;br /&gt;
message.msg_topic&lt;br /&gt;
message.msg_producer&lt;br /&gt;
message.msg_type&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ReceivedMessage(...)&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&#039;demo-source&#039;&lt;br /&gt;
&#039;ping&#039;&lt;br /&gt;
{&#039;text&#039;: &#039;hello bus&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the received message with the captured record:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured = sink.records[-1]&lt;br /&gt;
&lt;br /&gt;
captured&lt;br /&gt;
captured.msg_topic_id&lt;br /&gt;
captured.msg_type_id&lt;br /&gt;
captured.msg_producer_id&lt;br /&gt;
captured.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
CapturedMessage(...)&lt;br /&gt;
TopicID(...)&lt;br /&gt;
MessageTypeID(...)&lt;br /&gt;
ProducerID(...)&lt;br /&gt;
{&#039;text&#039;: &#039;hello bus&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The receiver gets the readable message view. The capture sink sees compact IDs and local payload data. The readable names are still available through symbol registration records, but manually reconstructing them is awkward.&lt;br /&gt;
&lt;br /&gt;
That awkwardness motivates the next phase.&lt;br /&gt;
&lt;br /&gt;
== Phase 2: Build a temporary capture history helper ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a small demonstration helper that turns captured records back into readable history messages.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Capture records are useful, but raw captured messages use compact IDs.&lt;br /&gt;
* A history helper can rebuild readable message history from registration records.&lt;br /&gt;
* This is demonstration infrastructure, not the final persistence/query API.&lt;br /&gt;
* The helper writes captured records into an in-memory &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; sequential unit.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration before the file edit&lt;br /&gt;
&lt;br /&gt;
List only the captured messages:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured_messages = []&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    if isinstance(record, CapturedMessage):&lt;br /&gt;
        captured_messages.append(record)&lt;br /&gt;
&lt;br /&gt;
captured_messages&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[CapturedMessage(... payload={&#039;text&#039;: &#039;hello bus&#039;} ...)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first captured message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured_message = captured_messages[0]&lt;br /&gt;
&lt;br /&gt;
captured_message.msg_topic_id&lt;br /&gt;
captured_message.msg_type_id&lt;br /&gt;
captured_message.msg_producer_id&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
TopicID(...)&lt;br /&gt;
MessageTypeID(...)&lt;br /&gt;
ProducerID(...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Manually reconstruct a topic name:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
topic_names = {}&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    if isinstance(record, MessageSymbolRegistration):&lt;br /&gt;
        if record.symbol_kind.name == &amp;quot;TOPIC&amp;quot;:&lt;br /&gt;
            topic_names[record.symbol_id] = record.symbol&lt;br /&gt;
&lt;br /&gt;
topic_names&lt;br /&gt;
topic_names[captured_message.msg_topic_id]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
{TopicID(...): &#039;demo.signals&#039;}&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This works, but repeating it by hand would distract from writing event processors.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/capture_history.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/capture_history.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Capture and history helpers for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from collections.abc import Iterable&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
&lt;br /&gt;
from aostore.sequentialunit.listsequentialunit import ListSequentialUnit&lt;br /&gt;
from mbus.capture.sink import CaptureSink&lt;br /&gt;
from mbus.message.records import CapturedMessage&lt;br /&gt;
from mbus.message.symbols import (&lt;br /&gt;
    MessageSymbolKind,&lt;br /&gt;
    MessageSymbolRegistration,&lt;br /&gt;
    MessageTypeID,&lt;br /&gt;
    ProducerID,&lt;br /&gt;
    TopicID,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
CapturedRecord = MessageSymbolRegistration | CapturedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class AOStoreCaptureSink(CaptureSink):&lt;br /&gt;
    records: ListSequentialUnit[CapturedRecord] = field(&lt;br /&gt;
        default_factory=ListSequentialUnit&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    def capture_symbol_registration(&lt;br /&gt;
        self,&lt;br /&gt;
        registration: MessageSymbolRegistration,&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self.records.append(registration)&lt;br /&gt;
&lt;br /&gt;
    def capture(self, message: CapturedMessage) -&amp;gt; None:&lt;br /&gt;
        self.records.append(message)&lt;br /&gt;
&lt;br /&gt;
    def all_records(self) -&amp;gt; tuple[CapturedRecord, ...]:&lt;br /&gt;
        records = self.records.sequential_read(0, len(self.records))&lt;br /&gt;
        result = tuple(records)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class HistoryMessage:&lt;br /&gt;
    msg_topic: str&lt;br /&gt;
    msg_type: str&lt;br /&gt;
    msg_producer: str&lt;br /&gt;
    payload: object&lt;br /&gt;
    bus_sequence: int&lt;br /&gt;
    topic_sequence: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class CaptureHistory:&lt;br /&gt;
    _records: tuple[CapturedRecord, ...]&lt;br /&gt;
    _topics: dict[TopicID, str]&lt;br /&gt;
    _types: dict[MessageTypeID, str]&lt;br /&gt;
    _producers: dict[ProducerID, str]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, records: Iterable[CapturedRecord]) -&amp;gt; None:&lt;br /&gt;
        self._records = tuple(records)&lt;br /&gt;
        self._topics = {}&lt;br /&gt;
        self._types = {}&lt;br /&gt;
        self._producers = {}&lt;br /&gt;
        self._index_symbols()&lt;br /&gt;
&lt;br /&gt;
    def _index_symbols(self) -&amp;gt; None:&lt;br /&gt;
        for record in self._records:&lt;br /&gt;
            if isinstance(record, MessageSymbolRegistration):&lt;br /&gt;
                self._record_symbol(record)&lt;br /&gt;
&lt;br /&gt;
    def _record_symbol(self, record: MessageSymbolRegistration) -&amp;gt; None:&lt;br /&gt;
        if record.symbol_kind is MessageSymbolKind.TOPIC:&lt;br /&gt;
            self._topics[record.symbol_id] = record.symbol&lt;br /&gt;
        elif record.symbol_kind is MessageSymbolKind.MSG_TYPE:&lt;br /&gt;
            self._types[record.symbol_id] = record.symbol&lt;br /&gt;
        elif record.symbol_kind is MessageSymbolKind.PRODUCER:&lt;br /&gt;
            self._producers[record.symbol_id] = record.symbol&lt;br /&gt;
&lt;br /&gt;
    def messages(self) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for record in self._records:&lt;br /&gt;
            if isinstance(record, CapturedMessage):&lt;br /&gt;
                message = self._decode_message(record)&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def by_topic(self, msg_topic: str) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for message in self.messages():&lt;br /&gt;
            if message.msg_topic == msg_topic:&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def since(self, bus_sequence: int) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for message in self.messages():&lt;br /&gt;
            if message.bus_sequence &amp;gt;= bus_sequence:&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def _decode_message(self, message: CapturedMessage) -&amp;gt; HistoryMessage:&lt;br /&gt;
        history_message = HistoryMessage(&lt;br /&gt;
            msg_topic=self._topics[message.msg_topic_id],&lt;br /&gt;
            msg_type=self._types[message.msg_type_id],&lt;br /&gt;
            msg_producer=self._producers[message.msg_producer_id],&lt;br /&gt;
            payload=message.payload,&lt;br /&gt;
            bus_sequence=message.bus_sequence,&lt;br /&gt;
            topic_sequence=message.topic_sequence,&lt;br /&gt;
        )&lt;br /&gt;
        return history_message&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;history helper&amp;quot;})&lt;br /&gt;
message = receiver.receive()&lt;br /&gt;
&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
history.messages()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[HistoryMessage(msg_topic=&#039;demo.signals&#039;, msg_type=&#039;ping&#039;, msg_producer=&#039;demo-source&#039;, payload={&#039;text&#039;: &#039;history helper&#039;}, ...)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the decoded history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history_message = history.messages()[0]&lt;br /&gt;
&lt;br /&gt;
history_message.msg_topic&lt;br /&gt;
history_message.msg_type&lt;br /&gt;
history_message.msg_producer&lt;br /&gt;
history_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&#039;ping&#039;&lt;br /&gt;
&#039;demo-source&#039;&lt;br /&gt;
{&#039;text&#039;: &#039;history helper&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 3: Explore shell command data by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Identify the high-level event shape needed by a regex milestone processor.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The old milestone strategy operated on command-level records.&lt;br /&gt;
* A command-level record includes context such as host and working directory.&lt;br /&gt;
* It also includes shell input and shell output.&lt;br /&gt;
* Raw TTY events do not directly have this shape; reconstruction comes later.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Represent a shell command as a dictionary:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = {&lt;br /&gt;
    &amp;quot;session_id&amp;quot;: &amp;quot;session-1&amp;quot;,&lt;br /&gt;
    &amp;quot;command_index&amp;quot;: 0,&lt;br /&gt;
    &amp;quot;host&amp;quot;: &amp;quot;alpha&amp;quot;,&lt;br /&gt;
    &amp;quot;cwd&amp;quot;: &amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    &amp;quot;input_text&amp;quot;: &amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    &amp;quot;output_text&amp;quot;: &amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    &amp;quot;started_at_ns&amp;quot;: 1000,&lt;br /&gt;
    &amp;quot;ended_at_ns&amp;quot;: 2000,&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the fields used by regex milestone checks:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command[&amp;quot;host&amp;quot;]&lt;br /&gt;
command[&amp;quot;cwd&amp;quot;]&lt;br /&gt;
command[&amp;quot;input_text&amp;quot;]&lt;br /&gt;
command[&amp;quot;output_text&amp;quot;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;alpha&#039;&lt;br /&gt;
&#039;/home/student&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Try an accidental wrong key:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command[&amp;quot;working_directory&amp;quot;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeyError: &#039;working_directory&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The dictionary contains the information, but it does not document the shape of the event. A typo is discovered only when the field is used. A named event class makes the processor code easier to read and easier to explain.&lt;br /&gt;
&lt;br /&gt;
== Phase 4: Persist the event record classes ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create reusable payload classes for the TTY, shell command, and regex milestone examples.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/events.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/events.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Payload records for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from dataclasses import dataclass&lt;br /&gt;
from typing import Literal&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class TTYReadEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    data: bytes&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class TTYWriteEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    data: bytes&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class LineDisciplineEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    line: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class KeystrokeTimingEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    previous_timestamp_ns: int | None&lt;br /&gt;
    delta_ns: int | None&lt;br /&gt;
    text: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class ShellCommandEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    command_index: int&lt;br /&gt;
    host: str&lt;br /&gt;
    cwd: str&lt;br /&gt;
    input_text: str&lt;br /&gt;
    output_text: str&lt;br /&gt;
    started_at_ns: int&lt;br /&gt;
    ended_at_ns: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
RegexMilestoneField = Literal[&lt;br /&gt;
    &amp;quot;host&amp;quot;,&lt;br /&gt;
    &amp;quot;cwd&amp;quot;,&lt;br /&gt;
    &amp;quot;input_text&amp;quot;,&lt;br /&gt;
    &amp;quot;output_text&amp;quot;,&lt;br /&gt;
]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneExpression:&lt;br /&gt;
    name: str&lt;br /&gt;
    field: RegexMilestoneField&lt;br /&gt;
    pattern: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneResult:&lt;br /&gt;
    expression_name: str&lt;br /&gt;
    field: RegexMilestoneField&lt;br /&gt;
    matched: bool&lt;br /&gt;
    match_text: str | None = None&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneAnalysisEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    command_index: int&lt;br /&gt;
    results: tuple[RegexMilestoneResult, ...]&lt;br /&gt;
    matched_count: int&lt;br /&gt;
    expression_count: int&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.events import ShellCommandEvent&lt;br /&gt;
&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
command&lt;br /&gt;
command.host&lt;br /&gt;
command.cwd&lt;br /&gt;
command.input_text&lt;br /&gt;
command.output_text&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ShellCommandEvent(session_id=&#039;session-1&#039;, command_index=0, ...)&lt;br /&gt;
&#039;alpha&#039;&lt;br /&gt;
&#039;/home/student&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The event shape is now explicit. Other code can depend on a named &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; instead of assuming that a loose dictionary has the expected fields.&lt;br /&gt;
&lt;br /&gt;
== Phase 5: Evaluate regex milestones manually ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Recreate the core milestone check in a small, transparent form.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* A regex milestone expression names one check against one shell command field.&lt;br /&gt;
* The field may be command input, command output, working directory, or host context.&lt;br /&gt;
* The result records whether the expression matched and, when available, the matched text.&lt;br /&gt;
* Manual evaluation is useful for learning the shape of the problem before writing a processor.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Import regular expressions and the payload classes:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create one command event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create three milestone expressions:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
expressions = (&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;used-ls&amp;quot;,&lt;br /&gt;
        field=&amp;quot;input_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;\bls\b&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;saw-notes&amp;quot;,&lt;br /&gt;
        field=&amp;quot;output_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;notes\.txt&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;home-directory&amp;quot;,&lt;br /&gt;
        field=&amp;quot;cwd&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;^/home/student$&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Evaluate one expression:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
expression = expressions[0]&lt;br /&gt;
text = getattr(command, expression.field)&lt;br /&gt;
match = re.search(expression.pattern, text)&lt;br /&gt;
&lt;br /&gt;
expression.name&lt;br /&gt;
expression.field&lt;br /&gt;
text&lt;br /&gt;
match.group(0)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;used-ls&#039;&lt;br /&gt;
&#039;input_text&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;ls&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Evaluate all expressions:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
manual_results = []&lt;br /&gt;
&lt;br /&gt;
for expression in expressions:&lt;br /&gt;
    text = getattr(command, expression.field)&lt;br /&gt;
    match = re.search(expression.pattern, text)&lt;br /&gt;
    manual_results.append((expression.name, match is not None))&lt;br /&gt;
&lt;br /&gt;
manual_results&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(&#039;used-ls&#039;, True), (&#039;saw-notes&#039;, True), (&#039;home-directory&#039;, True)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
Manual evaluation is simple for one command, but the loop is already event processor logic. The next phase preserves the loop in a reusable processor.&lt;br /&gt;
&lt;br /&gt;
== Phase 6: Persist the RegexMilestone processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a processor that receives shell command events and emits regex milestone analysis events.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/regex_milestones.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/regex_milestones.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Regex milestone processor for shell command events.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneAnalysisEvent,&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    RegexMilestoneResult,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class RegexMilestoneProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _expressions: tuple[RegexMilestoneExpression, ...]&lt;br /&gt;
&lt;br /&gt;
    def __init__(&lt;br /&gt;
        self,&lt;br /&gt;
        bus: DirectMessageBus,&lt;br /&gt;
        expressions: tuple[RegexMilestoneExpression, ...],&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.shell.command&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;shell-reconstructor&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;shell-command&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.shell.regex_milestone&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;regex-milestone-processor&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;regex-milestone-analysis&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._expressions = expressions&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; RegexMilestoneAnalysisEvent:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        command = message.payload&lt;br /&gt;
&lt;br /&gt;
        if not isinstance(command, ShellCommandEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected ShellCommandEvent, got {type(command).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        analysis = analyze_regex_milestones(command, self._expressions)&lt;br /&gt;
        self._emitter.emit(analysis)&lt;br /&gt;
        return analysis&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def analyze_regex_milestones(&lt;br /&gt;
    command: ShellCommandEvent,&lt;br /&gt;
    expressions: tuple[RegexMilestoneExpression, ...],&lt;br /&gt;
) -&amp;gt; RegexMilestoneAnalysisEvent:&lt;br /&gt;
    results = []&lt;br /&gt;
    for expression in expressions:&lt;br /&gt;
        result = evaluate_regex_milestone(command, expression)&lt;br /&gt;
        results.append(result)&lt;br /&gt;
&lt;br /&gt;
    matched_count = sum(result.matched for result in results)&lt;br /&gt;
    analysis = RegexMilestoneAnalysisEvent(&lt;br /&gt;
        session_id=command.session_id,&lt;br /&gt;
        command_index=command.command_index,&lt;br /&gt;
        results=tuple(results),&lt;br /&gt;
        matched_count=matched_count,&lt;br /&gt;
        expression_count=len(expressions),&lt;br /&gt;
    )&lt;br /&gt;
    return analysis&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def evaluate_regex_milestone(&lt;br /&gt;
    command: ShellCommandEvent,&lt;br /&gt;
    expression: RegexMilestoneExpression,&lt;br /&gt;
) -&amp;gt; RegexMilestoneResult:&lt;br /&gt;
    text = getattr(command, expression.field)&lt;br /&gt;
    match = re.search(expression.pattern, text)&lt;br /&gt;
    match_text = None&lt;br /&gt;
&lt;br /&gt;
    if match is not None:&lt;br /&gt;
        match_text = match.group(0)&lt;br /&gt;
&lt;br /&gt;
    result = RegexMilestoneResult(&lt;br /&gt;
        expression_name=expression.name,&lt;br /&gt;
        field=expression.field,&lt;br /&gt;
        matched=match is not None,&lt;br /&gt;
        match_text=match_text,&lt;br /&gt;
    )&lt;br /&gt;
    return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then set up the bus:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.regex_milestones import RegexMilestoneProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Subscribe to the analysis topic before running the processor:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.shell.regex_milestone&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;regex-milestone-processor&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;regex-milestone-analysis&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create the shell command emitter and processor:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.shell.command&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;shell-reconstructor&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;shell-command&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
expressions = (&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;used-ls&amp;quot;,&lt;br /&gt;
        field=&amp;quot;input_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;\bls\b&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;saw-notes&amp;quot;,&lt;br /&gt;
        field=&amp;quot;output_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;notes\.txt&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;home-directory&amp;quot;,&lt;br /&gt;
        field=&amp;quot;cwd&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;^/home/student$&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = RegexMilestoneProcessor(bus, expressions)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit a shell command event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
command_emitter.emit(command)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the command:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis = processor.process_one()&lt;br /&gt;
analysis&lt;br /&gt;
analysis.results&lt;br /&gt;
analysis.matched_count&lt;br /&gt;
analysis.expression_count&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
RegexMilestoneAnalysisEvent(... matched_count=3, expression_count=3)&lt;br /&gt;
(RegexMilestoneResult(...), RegexMilestoneResult(...), RegexMilestoneResult(...))&lt;br /&gt;
3&lt;br /&gt;
3&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the derived analysis event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis_message = analysis_receiver.receive()&lt;br /&gt;
&lt;br /&gt;
analysis_message.msg_topic&lt;br /&gt;
analysis_message.msg_type&lt;br /&gt;
analysis_message.msg_producer&lt;br /&gt;
analysis_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;demo.shell.regex_milestone&#039;&lt;br /&gt;
&#039;regex-milestone-analysis&#039;&lt;br /&gt;
&#039;regex-milestone-processor&#039;&lt;br /&gt;
RegexMilestoneAnalysisEvent(... matched_count=3, expression_count=3)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
messages = history.messages()&lt;br /&gt;
&lt;br /&gt;
for message in messages:&lt;br /&gt;
    print(message.bus_sequence, message.msg_topic, message.msg_type)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
0 demo.shell.command shell-command&lt;br /&gt;
1 demo.shell.regex_milestone regex-milestone-analysis&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The regex milestone processor consumes one event and emits another. The derived analysis event is available to any later subscriber. History contains both the source command event and the derived analysis event.&lt;br /&gt;
&lt;br /&gt;
== Phase 7: Explore why shell reconstruction is separate ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Show that raw TTY observations are not the same thing as high-level shell command events.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Regex milestones should inspect high-level command events.&lt;br /&gt;
* TTY reads, TTY writes, and line-discipline lines are lower-level observations.&lt;br /&gt;
* A shell command event is reconstructed by correlating those observations.&lt;br /&gt;
* Keeping reconstruction separate prevents the regex milestone processor from becoming a large TTY parser.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Create a few low-level observations:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    LineDisciplineEvent,&lt;br /&gt;
    TTYReadEvent,&lt;br /&gt;
    TTYWriteEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_1 = TTYReadEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=0,&lt;br /&gt;
    timestamp_ns=1000,&lt;br /&gt;
    data=b&amp;quot;l&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_2 = TTYReadEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=1,&lt;br /&gt;
    timestamp_ns=1100,&lt;br /&gt;
    data=b&amp;quot;s&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
line = LineDisciplineEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=2,&lt;br /&gt;
    timestamp_ns=1200,&lt;br /&gt;
    line=&amp;quot;ls\n&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
write = TTYWriteEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=3,&lt;br /&gt;
    timestamp_ns=1600,&lt;br /&gt;
    data=b&amp;quot;notes.txt\n&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the raw pieces:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_1.data&lt;br /&gt;
read_2.data&lt;br /&gt;
line.line&lt;br /&gt;
write.data&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
b&#039;l&#039;&lt;br /&gt;
b&#039;s&#039;&lt;br /&gt;
&#039;ls\n&#039;&lt;br /&gt;
b&#039;notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Assemble command fields manually:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
input_text = line.line&lt;br /&gt;
output_text = write.data.decode(&amp;quot;utf-8&amp;quot;, errors=&amp;quot;replace&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
input_text&lt;br /&gt;
output_text&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;ls\n&#039;&lt;br /&gt;
&#039;notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The useful command event is derived from several lower-level observations. A later shell reconstructor can subscribe to line-discipline events, query recent reads/writes/timing observations, and emit &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; records.&lt;br /&gt;
&lt;br /&gt;
== Phase 8: Add keystroke timing as a smaller TTY-derived processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a simple stateful processor before attempting full shell reconstruction.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Timing requires comparing a TTY read with the previous read in the same session.&lt;br /&gt;
* A derived timing event makes the result explicit.&lt;br /&gt;
* Other processors can consume timing events instead of recalculating deltas.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration before the file edit&lt;br /&gt;
&lt;br /&gt;
Calculate timing manually from the two read events:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_events = [read_1, read_2]&lt;br /&gt;
previous_timestamp = None&lt;br /&gt;
timing_rows = []&lt;br /&gt;
&lt;br /&gt;
for event in read_events:&lt;br /&gt;
    delta = None&lt;br /&gt;
    if previous_timestamp is not None:&lt;br /&gt;
        delta = event.timestamp_ns - previous_timestamp&lt;br /&gt;
    timing_rows.append((event.source_index, previous_timestamp, delta))&lt;br /&gt;
    previous_timestamp = event.timestamp_ns&lt;br /&gt;
&lt;br /&gt;
timing_rows&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, None, None), (1, 1000, 100)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/keystroke_timing.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/keystroke_timing.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Keystroke timing processor for TTY read events.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    KeystrokeTimingEvent,&lt;br /&gt;
    TTYReadEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class KeystrokeTimingProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _last_timestamp_by_session: dict[str, int]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.tty.read&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;tty-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;tty-read&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.keystroke.timing&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._last_timestamp_by_session = {}&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; KeystrokeTimingEvent:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        event = message.payload&lt;br /&gt;
&lt;br /&gt;
        if not isinstance(event, TTYReadEvent):&lt;br /&gt;
            raise TypeError(f&amp;quot;expected TTYReadEvent, got {type(event).__name__}&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
        previous_timestamp = self._last_timestamp_by_session.get(&lt;br /&gt;
            event.session_id&lt;br /&gt;
        )&lt;br /&gt;
        delta = None&lt;br /&gt;
&lt;br /&gt;
        if previous_timestamp is not None:&lt;br /&gt;
            delta = event.timestamp_ns - previous_timestamp&lt;br /&gt;
&lt;br /&gt;
        self._last_timestamp_by_session[event.session_id] = event.timestamp_ns&lt;br /&gt;
&lt;br /&gt;
        text = event.data.decode(&amp;quot;utf-8&amp;quot;, errors=&amp;quot;replace&amp;quot;)&lt;br /&gt;
        timing = KeystrokeTimingEvent(&lt;br /&gt;
            session_id=event.session_id,&lt;br /&gt;
            source_index=event.source_index,&lt;br /&gt;
            timestamp_ns=event.timestamp_ns,&lt;br /&gt;
            previous_timestamp_ns=previous_timestamp,&lt;br /&gt;
            delta_ns=delta,&lt;br /&gt;
            text=text,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter.emit(timing)&lt;br /&gt;
        return timing&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
from demos.event_processors.events import TTYReadEvent&lt;br /&gt;
from demos.event_processors.keystroke_timing import KeystrokeTimingProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
timing_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.keystroke.timing&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.tty.read&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;tty-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;tty-read&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = KeystrokeTimingProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit reads and process them:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_emitter.emit(&lt;br /&gt;
    TTYReadEvent(&lt;br /&gt;
        session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
        source_index=0,&lt;br /&gt;
        timestamp_ns=1000,&lt;br /&gt;
        data=b&amp;quot;l&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_emitter.emit(&lt;br /&gt;
    TTYReadEvent(&lt;br /&gt;
        session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
        source_index=1,&lt;br /&gt;
        timestamp_ns=1100,&lt;br /&gt;
        data=b&amp;quot;s&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor.process_one()&lt;br /&gt;
processor.process_one()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeystrokeTimingEvent(... previous_timestamp_ns=None, delta_ns=None, text=&#039;l&#039;)&lt;br /&gt;
KeystrokeTimingEvent(... previous_timestamp_ns=1000, delta_ns=100, text=&#039;s&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive timing events:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
timing_receiver.receive().payload&lt;br /&gt;
timing_receiver.receive().payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeystrokeTimingEvent(... source_index=0 ...)&lt;br /&gt;
KeystrokeTimingEvent(... source_index=1 ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 9: Introduce graph events ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Switch to a small graph problem that can demonstrate feedback loops safely.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* An edge is a source fact.&lt;br /&gt;
* A path is a derived fact.&lt;br /&gt;
* A direct edge implies a direct path.&lt;br /&gt;
* Longer paths can be derived later from known paths and edges.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_events.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/graph_events.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Graph event payloads for message bus demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from dataclasses import dataclass&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class EdgeDeclaredEvent:&lt;br /&gt;
    graph_id: str&lt;br /&gt;
    source: str&lt;br /&gt;
    target: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class PathKnownEvent:&lt;br /&gt;
    graph_id: str&lt;br /&gt;
    source: str&lt;br /&gt;
    target: str&lt;br /&gt;
    hop_count: int&lt;br /&gt;
&lt;br /&gt;
    def fact_key(self) -&amp;gt; tuple[str, str, str]:&lt;br /&gt;
        result = (self.graph_id, self.source, self.target)&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge = EdgeDeclaredEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;A&amp;quot;,&lt;br /&gt;
    target=&amp;quot;B&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path = PathKnownEvent(&lt;br /&gt;
    graph_id=edge.graph_id,&lt;br /&gt;
    source=edge.source,&lt;br /&gt;
    target=edge.target,&lt;br /&gt;
    hop_count=1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge&lt;br /&gt;
path&lt;br /&gt;
path.fact_key()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
EdgeDeclaredEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;)&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;, hop_count=1)&lt;br /&gt;
(&#039;demo-graph&#039;, &#039;A&#039;, &#039;B&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 10: Create a demo-only nonblocking receive helper ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Prepare for processor scheduling without editing &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;Receiver.receive()&amp;lt;/code&amp;gt; blocks until a message is available.&lt;br /&gt;
* A scheduler needs to try a processor and move on if no message is waiting.&lt;br /&gt;
* The current package does not expose a public nonblocking receiver method.&lt;br /&gt;
* This helper is local demonstration code. It can be replaced by a real public API later.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/demo_receive.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/demo_receive.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Demo-only helpers for receiver inspection and scheduling.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from queue import Empty&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.endpoints import Receiver&lt;br /&gt;
from mbus.message.records import ReceivedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class DemoReceiverError(RuntimeError):&lt;br /&gt;
    pass&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def try_receive(receiver: Receiver) -&amp;gt; ReceivedMessage | None:&lt;br /&gt;
    queue = getattr(receiver, &amp;quot;_queue&amp;quot;, None)&lt;br /&gt;
&lt;br /&gt;
    if queue is None:&lt;br /&gt;
        raise DemoReceiverError(&lt;br /&gt;
            &amp;quot;demo try_receive requires the current direct broker receiver&amp;quot;&lt;br /&gt;
        )&lt;br /&gt;
&lt;br /&gt;
    try:&lt;br /&gt;
        message = queue.get_nowait()&lt;br /&gt;
    except Empty:&lt;br /&gt;
        message = None&lt;br /&gt;
&lt;br /&gt;
    return message&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.demo_receive import try_receive&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
try_receive(receiver)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
None&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Now emit a message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;demo nonblocking receive&amp;quot;})&lt;br /&gt;
message = try_receive(receiver)&lt;br /&gt;
&lt;br /&gt;
message&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ReceivedMessage(...)&lt;br /&gt;
{&#039;text&#039;: &#039;demo nonblocking receive&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 11: Create the direct path processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Implement the graph rule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
edge(A, B) -&amp;gt; path(A, B)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The source emits edge events.&lt;br /&gt;
* The direct path processor emits derived path events.&lt;br /&gt;
* Other processors can subscribe to derived path events.&lt;br /&gt;
* The processor can be run by hand or by a scheduler.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_reachability.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/graph_reachability.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Graph reachability processors for message bus demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.demo_receive import try_receive&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class DirectPathProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _known_paths: set[tuple[str, str, str]]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._known_paths = set()&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; PathKnownEvent | None:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        result = self._process_payload(message.payload)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
    def process_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            self._process_payload(message.payload)&lt;br /&gt;
            work_count = 1&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def _process_payload(self, payload: object) -&amp;gt; PathKnownEvent | None:&lt;br /&gt;
        if not isinstance(payload, EdgeDeclaredEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected EdgeDeclaredEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        path = PathKnownEvent(&lt;br /&gt;
            graph_id=payload.graph_id,&lt;br /&gt;
            source=payload.source,&lt;br /&gt;
            target=payload.target,&lt;br /&gt;
            hop_count=1,&lt;br /&gt;
        )&lt;br /&gt;
&lt;br /&gt;
        if path.fact_key() not in self._known_paths:&lt;br /&gt;
            self._known_paths.add(path.fact_key())&lt;br /&gt;
            self._emitter.emit(path)&lt;br /&gt;
            result = path&lt;br /&gt;
        else:&lt;br /&gt;
            result = None&lt;br /&gt;
&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
from demos.event_processors.graph_events import EdgeDeclaredEvent&lt;br /&gt;
from demos.event_processors.graph_reachability import DirectPathProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
path_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = DirectPathProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Call the processor before any edge exists:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
0&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit one edge:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;A&amp;quot;,&lt;br /&gt;
        target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the edge:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the derived path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_message = path_receiver.receive()&lt;br /&gt;
path_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;, hop_count=1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 12: Explore the path extension rule by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Understand the feedback rule before implementing it.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* If &amp;lt;code&amp;gt;A&amp;lt;/code&amp;gt; reaches &amp;lt;code&amp;gt;B&amp;lt;/code&amp;gt;, and there is an edge from &amp;lt;code&amp;gt;B&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;C&amp;lt;/code&amp;gt;, then &amp;lt;code&amp;gt;A&amp;lt;/code&amp;gt; reaches &amp;lt;code&amp;gt;C&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Derived path facts can lead to more derived path facts.&lt;br /&gt;
* Duplicate prevention keeps the feedback loop from repeating the same fact forever.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_bc = EdgeDeclaredEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;B&amp;quot;,&lt;br /&gt;
    target=&amp;quot;C&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path_ab = PathKnownEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;A&amp;quot;,&lt;br /&gt;
    target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    hop_count=1,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Check whether the path and edge connect:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_ab.target&lt;br /&gt;
edge_bc.source&lt;br /&gt;
path_ab.target == edge_bc.source&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;B&#039;&lt;br /&gt;
&#039;B&#039;&lt;br /&gt;
True&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Derive a new path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_ac = PathKnownEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=path_ab.source,&lt;br /&gt;
    target=edge_bc.target,&lt;br /&gt;
    hop_count=path_ab.hop_count + 1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path_ac&lt;br /&gt;
path_ac.fact_key()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;C&#039;, hop_count=2)&lt;br /&gt;
(&#039;demo-graph&#039;, &#039;A&#039;, &#039;C&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Manually prevent duplicate path facts:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
known_paths = set()&lt;br /&gt;
known_paths.add(path_ab.fact_key())&lt;br /&gt;
&lt;br /&gt;
path_ac.fact_key() in known_paths&lt;br /&gt;
&lt;br /&gt;
if path_ac.fact_key() not in known_paths:&lt;br /&gt;
    known_paths.add(path_ac.fact_key())&lt;br /&gt;
&lt;br /&gt;
known_paths&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
False&lt;br /&gt;
{(&#039;demo-graph&#039;, &#039;A&#039;, &#039;B&#039;), (&#039;demo-graph&#039;, &#039;A&#039;, &#039;C&#039;)}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 13: Add the path extension processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Implement the feedback rule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
path(A, B) + edge(B, C) -&amp;gt; path(A, C)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The extension processor listens to edge events and path events.&lt;br /&gt;
* It emits new path events when a known path can be extended.&lt;br /&gt;
* It also listens to its own output because an extended path may be extendable again.&lt;br /&gt;
* This is a controlled feedback loop.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Append this class to:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_reachability.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
class PathExtensionProcessor:&lt;br /&gt;
    _edge_receiver: Receiver&lt;br /&gt;
    _direct_path_receiver: Receiver&lt;br /&gt;
    _extension_path_receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _edges: set[tuple[str, str, str]]&lt;br /&gt;
    _paths: dict[tuple[str, str, str], PathKnownEvent]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._edge_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._direct_path_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._extension_path_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._edges = set()&lt;br /&gt;
        self._paths = {}&lt;br /&gt;
&lt;br /&gt;
    def process_edge_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._edge_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_edge_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def process_direct_path_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._direct_path_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_path_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def process_extension_path_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._extension_path_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_path_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def _process_edge_payload(&lt;br /&gt;
        self,&lt;br /&gt;
        payload: object,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        if not isinstance(payload, EdgeDeclaredEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected EdgeDeclaredEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        edge = payload&lt;br /&gt;
        self._edges.add((edge.graph_id, edge.source, edge.target))&lt;br /&gt;
        paths = self._derive_from_new_edge(edge)&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _process_path_payload(&lt;br /&gt;
        self,&lt;br /&gt;
        payload: object,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        if not isinstance(payload, PathKnownEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected PathKnownEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        path = payload&lt;br /&gt;
        self._paths[path.fact_key()] = path&lt;br /&gt;
        paths = self._derive_from_new_path(path)&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _derive_from_new_edge(&lt;br /&gt;
        self,&lt;br /&gt;
        edge: EdgeDeclaredEvent,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        paths = []&lt;br /&gt;
&lt;br /&gt;
        for known_path in self._paths.values():&lt;br /&gt;
            if known_path.graph_id != edge.graph_id:&lt;br /&gt;
                continue&lt;br /&gt;
            if known_path.target != edge.source:&lt;br /&gt;
                continue&lt;br /&gt;
&lt;br /&gt;
            next_path = PathKnownEvent(&lt;br /&gt;
                graph_id=edge.graph_id,&lt;br /&gt;
                source=known_path.source,&lt;br /&gt;
                target=edge.target,&lt;br /&gt;
                hop_count=known_path.hop_count + 1,&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
            if self._emit_if_new(next_path):&lt;br /&gt;
                paths.append(next_path)&lt;br /&gt;
&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _derive_from_new_path(&lt;br /&gt;
        self,&lt;br /&gt;
        path: PathKnownEvent,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        paths = []&lt;br /&gt;
&lt;br /&gt;
        for graph_id, edge_source, edge_target in self._edges:&lt;br /&gt;
            if graph_id != path.graph_id:&lt;br /&gt;
                continue&lt;br /&gt;
            if edge_source != path.target:&lt;br /&gt;
                continue&lt;br /&gt;
&lt;br /&gt;
            next_path = PathKnownEvent(&lt;br /&gt;
                graph_id=path.graph_id,&lt;br /&gt;
                source=path.source,&lt;br /&gt;
                target=edge_target,&lt;br /&gt;
                hop_count=path.hop_count + 1,&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
            if self._emit_if_new(next_path):&lt;br /&gt;
                paths.append(next_path)&lt;br /&gt;
&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _emit_if_new(self, path: PathKnownEvent) -&amp;gt; bool:&lt;br /&gt;
        if path.fact_key() in self._paths:&lt;br /&gt;
            result = False&lt;br /&gt;
        else:&lt;br /&gt;
            self._paths[path.fact_key()] = path&lt;br /&gt;
            self._emitter.emit(path)&lt;br /&gt;
            result = True&lt;br /&gt;
&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_events import EdgeDeclaredEvent&lt;br /&gt;
from demos.event_processors.graph_reachability import (&lt;br /&gt;
    DirectPathProcessor,&lt;br /&gt;
    PathExtensionProcessor,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
extension_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
direct_processor = DirectPathProcessor(bus)&lt;br /&gt;
extension_processor = PathExtensionProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit two edges in fixed order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;A&amp;quot;,&lt;br /&gt;
        target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;B&amp;quot;,&lt;br /&gt;
        target=&amp;quot;C&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the direct paths:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
direct_processor.process_available()&lt;br /&gt;
direct_processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Let the extension processor learn the edges:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
extension_processor.process_edge_available()&lt;br /&gt;
extension_processor.process_edge_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Let the extension processor learn the direct paths:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
extension_processor.process_direct_path_available()&lt;br /&gt;
extension_processor.process_direct_path_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The second call may emit the extended &amp;lt;code&amp;gt;A -&amp;gt; C&amp;lt;/code&amp;gt; path after the processor has seen both direct paths and both edges.&lt;br /&gt;
&lt;br /&gt;
Receive the extended path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
message = extension_receiver.receive()&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;C&#039;, hop_count=2)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect captured graph path history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
path_messages = history.by_topic(&amp;quot;demo.graph.path&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
for message in path_messages:&lt;br /&gt;
    path = message.payload&lt;br /&gt;
    print(&lt;br /&gt;
        message.bus_sequence,&lt;br /&gt;
        message.msg_producer,&lt;br /&gt;
        path.source,&lt;br /&gt;
        path.target,&lt;br /&gt;
        path.hop_count,&lt;br /&gt;
    )&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
... direct-path A B 1&lt;br /&gt;
... direct-path B C 1&lt;br /&gt;
... path-extension A C 2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 14: Add a randomized processor scheduler ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Simulate concurrency effects without changing source input order.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The source will emit the same edge events in the same order every run.&lt;br /&gt;
* The scheduler varies which processor gets a chance to run first.&lt;br /&gt;
* This simulates uncertainty from processing time, scheduling priority, startup timing, or transmission delay.&lt;br /&gt;
* The graph example should converge to the same final path facts even when processor scheduling, and hence the order of intermediate derived processor events, differs.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/runners.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/runners.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Small processor runners for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from collections.abc import Callable&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
from random import Random&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class ProcessorStep:&lt;br /&gt;
    name: str&lt;br /&gt;
    run: Callable[[], int]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class RandomProcessorRunner:&lt;br /&gt;
    steps: tuple[ProcessorStep, ...]&lt;br /&gt;
    seed: int&lt;br /&gt;
    max_rounds: int = 30&lt;br /&gt;
    trace: list[tuple[int, str, int]] = field(default_factory=list)&lt;br /&gt;
&lt;br /&gt;
    def run_until_quiet(self) -&amp;gt; list[tuple[int, str, int]]:&lt;br /&gt;
        rng = Random(self.seed)&lt;br /&gt;
&lt;br /&gt;
        for round_index in range(self.max_rounds):&lt;br /&gt;
            work_count = self._run_round(rng, round_index)&lt;br /&gt;
&lt;br /&gt;
            if work_count == 0:&lt;br /&gt;
                break&lt;br /&gt;
&lt;br /&gt;
        result = list(self.trace)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
    def _run_round(self, rng: Random, round_index: int) -&amp;gt; int:&lt;br /&gt;
        steps = list(self.steps)&lt;br /&gt;
        rng.shuffle(steps)&lt;br /&gt;
        round_work_count = 0&lt;br /&gt;
&lt;br /&gt;
        for step in steps:&lt;br /&gt;
            step_work_count = step.run()&lt;br /&gt;
            self.trace.append((round_index, step.name, step_work_count))&lt;br /&gt;
            round_work_count += step_work_count&lt;br /&gt;
&lt;br /&gt;
        return round_work_count&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then inspect how a runner can shuffle placeholder steps:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.runners import (&lt;br /&gt;
    ProcessorStep,&lt;br /&gt;
    RandomProcessorRunner,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
calls = []&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def first_step() -&amp;gt; int:&lt;br /&gt;
    calls.append(&amp;quot;first&amp;quot;)&lt;br /&gt;
    return 0&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def second_step() -&amp;gt; int:&lt;br /&gt;
    calls.append(&amp;quot;second&amp;quot;)&lt;br /&gt;
    return 0&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
runner = RandomProcessorRunner(&lt;br /&gt;
    steps=(&lt;br /&gt;
        ProcessorStep(name=&amp;quot;first&amp;quot;, run=first_step),&lt;br /&gt;
        ProcessorStep(name=&amp;quot;second&amp;quot;, run=second_step),&lt;br /&gt;
    ),&lt;br /&gt;
    seed=1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
runner.run_until_quiet()&lt;br /&gt;
calls&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, &#039;...&#039;, 0), (0, &#039;...&#039;, 0)]&lt;br /&gt;
[&#039;...&#039;, &#039;...&#039;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 15: Run the graph example with randomized scheduling ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Run the same graph input under different processor schedules and compare the final path facts.&lt;br /&gt;
&lt;br /&gt;
; Interpreter setup&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_reachability import (&lt;br /&gt;
    DirectPathProcessor,&lt;br /&gt;
    PathExtensionProcessor,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.runners import (&lt;br /&gt;
    ProcessorStep,&lt;br /&gt;
    RandomProcessorRunner,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Define a helper function in the interpreter:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
def run_graph_demo(seed: int) -&amp;gt; tuple[&lt;br /&gt;
    list[tuple[int, str, int]],&lt;br /&gt;
    list[tuple[int, str, str, int]],&lt;br /&gt;
]:&lt;br /&gt;
    bus = DirectMessageBus()&lt;br /&gt;
    sink = AOStoreCaptureSink()&lt;br /&gt;
    bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
    edge_emitter = bus.register_emitter(&lt;br /&gt;
        msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
        msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
        default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    direct_processor = DirectPathProcessor(bus)&lt;br /&gt;
    extension_processor = PathExtensionProcessor(bus)&lt;br /&gt;
&lt;br /&gt;
    edges = (&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;A&amp;quot;,&lt;br /&gt;
            target=&amp;quot;B&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;B&amp;quot;,&lt;br /&gt;
            target=&amp;quot;C&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;C&amp;quot;,&lt;br /&gt;
            target=&amp;quot;D&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    for edge in edges:&lt;br /&gt;
        edge_emitter.emit(edge)&lt;br /&gt;
&lt;br /&gt;
    steps = (&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;direct path from edge&amp;quot;,&lt;br /&gt;
            run=direct_processor.process_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns edge&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_edge_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns direct path&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_direct_path_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns extension path&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_extension_path_available,&lt;br /&gt;
        ),&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    runner = RandomProcessorRunner(&lt;br /&gt;
        steps=steps,&lt;br /&gt;
        seed=seed,&lt;br /&gt;
    )&lt;br /&gt;
    trace = runner.run_until_quiet()&lt;br /&gt;
&lt;br /&gt;
    history = CaptureHistory(sink.all_records())&lt;br /&gt;
    path_facts = []&lt;br /&gt;
&lt;br /&gt;
    for message in history.by_topic(&amp;quot;demo.graph.path&amp;quot;):&lt;br /&gt;
        path = message.payload&lt;br /&gt;
        if isinstance(path, PathKnownEvent):&lt;br /&gt;
            fact = (&lt;br /&gt;
                message.bus_sequence,&lt;br /&gt;
                path.source,&lt;br /&gt;
                path.target,&lt;br /&gt;
                path.hop_count,&lt;br /&gt;
            )&lt;br /&gt;
            path_facts.append(fact)&lt;br /&gt;
&lt;br /&gt;
    result = (trace, path_facts)&lt;br /&gt;
    return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Run one schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_1, paths_1 = run_graph_demo(1)&lt;br /&gt;
&lt;br /&gt;
trace_1&lt;br /&gt;
paths_1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, &#039;...&#039;, ...), (0, &#039;...&#039;, ...), ...]&lt;br /&gt;
[(..., &#039;A&#039;, &#039;B&#039;, 1), (..., &#039;B&#039;, &#039;C&#039;, 1), (..., &#039;C&#039;, &#039;D&#039;, 1), ...]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Run another schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_2, paths_2 = run_graph_demo(2)&lt;br /&gt;
&lt;br /&gt;
trace_2&lt;br /&gt;
paths_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the schedules:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_1 == trace_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
False&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the final path facts without bus sequence numbers:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
final_paths_1 = {&lt;br /&gt;
    (source, target, hop_count)&lt;br /&gt;
    for _, source, target, hop_count in paths_1&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
final_paths_2 = {&lt;br /&gt;
    (source, target, hop_count)&lt;br /&gt;
    for _, source, target, hop_count in paths_2&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
final_paths_1&lt;br /&gt;
final_paths_2&lt;br /&gt;
final_paths_1 == final_paths_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
{(&#039;A&#039;, &#039;B&#039;, 1), (&#039;B&#039;, &#039;C&#039;, 1), (&#039;C&#039;, &#039;D&#039;, 1), (&#039;A&#039;, &#039;C&#039;, 2), (&#039;B&#039;, &#039;D&#039;, 2), (&#039;A&#039;, &#039;D&#039;, 3)}&lt;br /&gt;
{(&#039;A&#039;, &#039;B&#039;, 1), (&#039;B&#039;, &#039;C&#039;, 1), (&#039;C&#039;, &#039;D&#039;, 1), (&#039;A&#039;, &#039;C&#039;, 2), (&#039;B&#039;, &#039;D&#039;, 2), (&#039;A&#039;, &#039;D&#039;, 3)}&lt;br /&gt;
True&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for round_index, step_name, work_count in trace_1:&lt;br /&gt;
    print(round_index, step_name, work_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first captured path order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for bus_sequence, source, target, hop_count in paths_1:&lt;br /&gt;
    print(bus_sequence, source, target, hop_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the second schedule and path order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for round_index, step_name, work_count in trace_2:&lt;br /&gt;
    print(round_index, step_name, work_count)&lt;br /&gt;
&lt;br /&gt;
for bus_sequence, source, target, hop_count in paths_2:&lt;br /&gt;
    print(bus_sequence, source, target, hop_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The source input order did not change. The processor scheduling changed. The captured event order may differ. The final reachability facts should match.&lt;br /&gt;
&lt;br /&gt;
This models an important distributed-systems lesson: event history order and final derived state are related, but they are not the same thing.&lt;br /&gt;
&lt;br /&gt;
== Phase 16: Later refinement: graph index from history ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Introduce restart/recovery thinking only after the basic graph feedback example is familiar.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The first graph processors keep local working state.&lt;br /&gt;
* Local working state is enough for a short demonstration.&lt;br /&gt;
* Longer-lived processors need recovery behavior after restart.&lt;br /&gt;
* Since edge and path facts are captured as events, a graph index can later be rebuilt from history.&lt;br /&gt;
* This points toward projection-style services without changing the basic processor model.&lt;br /&gt;
&lt;br /&gt;
; Suggested later file&lt;br /&gt;
&lt;br /&gt;
Create later:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_index.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Suggested responsibilities:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
read captured history&lt;br /&gt;
collect EdgeDeclaredEvent payloads&lt;br /&gt;
collect PathKnownEvent payloads&lt;br /&gt;
answer whether a path fact exists&lt;br /&gt;
find edges starting at a node&lt;br /&gt;
find paths ending at a node&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This refinement belongs after the team has already run the graph reachability demo successfully.&lt;br /&gt;
&lt;br /&gt;
== Phase 17: Later refinement: shell command reconstruction ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Connect the TTY examples back to the shell command event shape used by regex milestones.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; is the high-level event consumed by the regex milestone processor.&lt;br /&gt;
* &amp;lt;code&amp;gt;TTYReadEvent&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;TTYWriteEvent&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;LineDisciplineEvent&amp;lt;/code&amp;gt; are lower-level observations.&lt;br /&gt;
* A reconstructor can subscribe to line-discipline events and query recent related observations.&lt;br /&gt;
* The reconstructor should emit shell command events; it should not perform regex analysis itself.&lt;br /&gt;
&lt;br /&gt;
; Suggested responsibilities&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
listen for completed line-discipline events&lt;br /&gt;
query history since the previous command boundary for the same session&lt;br /&gt;
collect matching reads, writes, and timing events&lt;br /&gt;
construct ShellCommandEvent&lt;br /&gt;
emit ShellCommandEvent on demo.shell.command&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This is an appropriate follow-up exercise after the team understands both regex milestone processing and history queries.&lt;br /&gt;
&lt;br /&gt;
= Related =&lt;br /&gt;
&lt;br /&gt;
== Top-level topic page ==&lt;br /&gt;
[[Message Bus Service]]&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=Message_Bus_Live_Coding_Exercise&amp;diff=935</id>
		<title>Message Bus Live Coding Exercise</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=Message_Bus_Live_Coding_Exercise&amp;diff=935"/>
		<updated>2026-06-16T04:27:57Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* Audience and scope */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Introduction =&lt;br /&gt;
&lt;br /&gt;
== Audience and scope ==&lt;br /&gt;
&lt;br /&gt;
This exercise is intended for EDURange developers learning how to write event processors with the message bus.&lt;br /&gt;
&lt;br /&gt;
The packaged &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; modules are treated as read-only for this exercise. All new code belongs under a demonstration directory such as:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
workspace/&lt;br /&gt;
    mbus/&lt;br /&gt;
    aostore/&lt;br /&gt;
    demos/&lt;br /&gt;
        __init__.py&lt;br /&gt;
        event_processors/&lt;br /&gt;
            __init__.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Pre-release copies of &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; are available on the Discord fileshare channel. Modules may be released under other package names; see also the EDURange GitHub.&lt;br /&gt;
&lt;br /&gt;
The exercise uses two problem families.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;1. Shell and TTY event processing&#039;&#039;&#039;&lt;br /&gt;
** Send and receive messages through the bus.&lt;br /&gt;
** Capture and inspect message history.&lt;br /&gt;
** Represent high-level shell command events.&lt;br /&gt;
** Recreate the regex-based milestone role in a simplified form.&lt;br /&gt;
** Mock up TTY-derived observations such as keystroke timing.&lt;br /&gt;
* &#039;&#039;&#039;2. Graph event processing&#039;&#039;&#039;&lt;br /&gt;
** Represent graph edge relationships as source events.&lt;br /&gt;
** Derive direct path facts from edge relation events.&lt;br /&gt;
** Add feedback by deriving longer paths from known paths.&lt;br /&gt;
** Simulate nondeterministic processor scheduling while keeping source input fixed.&lt;br /&gt;
&lt;br /&gt;
The shell examples draw from EDURange&#039;s institutional knowledge. The graph examples provide a compact computer science model for safe feedback loops.&lt;br /&gt;
&lt;br /&gt;
== Running the examples ==&lt;br /&gt;
&lt;br /&gt;
Adjust paths to match the local checkout layout if necessary. Start an interpreter from the workspace root with project packages available.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
PYTHONPATH=&amp;quot;$PWD/mbus:$PWD/aostore:$PWD&amp;quot; python3.14&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
When module files are changed, restart the interpreter unless the group is deliberately practicing &amp;lt;code&amp;gt;importlib.reload()&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Create the demonstration package ==&lt;br /&gt;
&lt;br /&gt;
Before the first durable file edit, create the demonstration package directories:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir -p demos/event_processors&lt;br /&gt;
touch demos/__init__.py&lt;br /&gt;
touch demos/event_processors/__init__.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
== Important note about capture records ==&lt;br /&gt;
&lt;br /&gt;
The current demonstration works close to the low-level capture layer. As a result, it exposes &amp;lt;code&amp;gt;MessageSymbolRegistration&amp;lt;/code&amp;gt; records while building the temporary history helper.&lt;br /&gt;
&lt;br /&gt;
That is not the intended ordinary user experience. In a more complete release, higher-level capture and history infrastructure should handle symbol registration bookkeeping on behalf of processor authors. In this exercise, symbol registration records are visible because the demonstration history helper reconstructs readable messages from compact captured records directly.&lt;br /&gt;
&lt;br /&gt;
== Suggested group session flow ==&lt;br /&gt;
&lt;br /&gt;
; Session 1 - Bus and capture basics&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 1:&#039;&#039;&#039;&lt;br /&gt;
** Signal over the bus by hand.&lt;br /&gt;
** Inspect received messages.&lt;br /&gt;
** Inspect low-level captured records.&lt;br /&gt;
* &#039;&#039;&#039;Phase 2:&#039;&#039;&#039;&lt;br /&gt;
** Discuss why symbol registration appears in this temporary demo.&lt;br /&gt;
** Build the capture history helper.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1-3 hours (1-2 hours work, 0.5-1 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 2 - Shell command and regex milestones&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 3:&#039;&#039;&#039;&lt;br /&gt;
** Represent a shell command as a dictionary.&lt;br /&gt;
* &#039;&#039;&#039;Phase 4:&#039;&#039;&#039;&lt;br /&gt;
** Replace it with &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Phase 5:&#039;&#039;&#039;&lt;br /&gt;
** Manually evaluate regex milestone expressions.&lt;br /&gt;
* &#039;&#039;&#039;Phase 6:&#039;&#039;&#039;&lt;br /&gt;
** Implement &amp;lt;code&amp;gt;RegexMilestoneProcessor&amp;lt;/code&amp;gt;.&lt;br /&gt;
** Inspect source and analysis events in history.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3 hours (1.5-2.5 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 3 - TTY-derived observations&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 7:&#039;&#039;&#039;&lt;br /&gt;
** Create raw TTY read, write, and line events.&lt;br /&gt;
** Show why regex milestones need reconstructed shell command events.&lt;br /&gt;
* &#039;&#039;&#039;Phase 8:&#039;&#039;&#039;&lt;br /&gt;
** Implement keystroke timing.&lt;br /&gt;
** Discuss shell command reconstruction as the next derived-event problem (to be continued in Session 6 onward).&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1-2.5 hours (1-2 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 4 - Graph-derived facts&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 9:&#039;&#039;&#039;&lt;br /&gt;
** Define graph edges and paths.&lt;br /&gt;
* &#039;&#039;&#039;Phase 10:&#039;&#039;&#039;&lt;br /&gt;
** Add the demo-only nonblocking receive helper.&lt;br /&gt;
* &#039;&#039;&#039;Phase 11:&#039;&#039;&#039;&lt;br /&gt;
** Implement direct path derivation.&lt;br /&gt;
* &#039;&#039;&#039;Phase 12:&#039;&#039;&#039;&lt;br /&gt;
** Explore the extension rule by hand.&lt;br /&gt;
* &#039;&#039;&#039;Phase 13:&#039;&#039;&#039;&lt;br /&gt;
** Add path extension feedback.&lt;br /&gt;
** Discuss duplicate prevention.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3.5 hours (1.5-3 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 5 - Nondeterministic processor scheduling&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 14:&#039;&#039;&#039;&lt;br /&gt;
** Add the randomized processor runner.&lt;br /&gt;
* &#039;&#039;&#039;Phase 15:&#039;&#039;&#039;&lt;br /&gt;
** Keep source events fixed.&lt;br /&gt;
** Vary processor scheduling by seed.&lt;br /&gt;
** Compare final path facts across runs.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3.5 hours (1.5-3 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 6 - Follow-up refinements&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 16:&#039;&#039;&#039;&lt;br /&gt;
** Rebuild a graph index from history.&lt;br /&gt;
* &#039;&#039;&#039;Phase 17:&#039;&#039;&#039;&lt;br /&gt;
** Implement shell command reconstruction.&lt;br /&gt;
** Replace local Python payload assumptions with explicit payload projection formats when the bus format layer is ready for the exercise.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-4.5 hours (1.5-4 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
= Event Processor Demonstration Plan =&lt;br /&gt;
&lt;br /&gt;
== Phase 1: Signal over the bus by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Send one message through the direct bus and inspect what the receiver sees.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* A bus is useful only when something emits and something receives.&lt;br /&gt;
* Public message setup uses readable strings: topic, producer, and message type.&lt;br /&gt;
* The direct broker delivers local Python payloads in this demonstration.&lt;br /&gt;
* Capture must be attached before delivery in the current prototype.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Import the bus and capture interfaces:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.capture.sink import CaptureSink&lt;br /&gt;
from mbus.message.records import CapturedMessage&lt;br /&gt;
from mbus.message.symbols import MessageSymbolRegistration&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create a temporary capture sink in the interpreter. This sink stores whatever the bus sends to the capture layer:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
CapturedRecord = MessageSymbolRegistration | CapturedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class ListCaptureSink(CaptureSink):&lt;br /&gt;
    records: list[CapturedRecord] = field(default_factory=list)&lt;br /&gt;
&lt;br /&gt;
    def capture_symbol_registration(&lt;br /&gt;
        self,&lt;br /&gt;
        registration: MessageSymbolRegistration,&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self.records.append(registration)&lt;br /&gt;
&lt;br /&gt;
    def capture(self, message: CapturedMessage) -&amp;gt; None:&lt;br /&gt;
        self.records.append(message)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create the bus and attach capture:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = ListCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the empty capture sink:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
sink&lt;br /&gt;
sink.records&lt;br /&gt;
len(sink.records)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ListCaptureSink(records=[])&lt;br /&gt;
[]&lt;br /&gt;
0&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create a receiver. This describes the messages the receiver wants to hear:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the receiver and confirm that capture is still empty:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
type(receiver)&lt;br /&gt;
vars(receiver)&lt;br /&gt;
sink.records&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;class &#039;mbus.broker.direct._BrokerReceiver&#039;&amp;gt;&lt;br /&gt;
{&#039;_queue&#039;: ...}&lt;br /&gt;
[]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create an emitter. Registering the emitter gives the bus enough information to bind readable names to compact internal IDs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the captured registration records:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
sink.records&lt;br /&gt;
[type(record).__name__ for record in sink.records]&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    print(record)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[&#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;]&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;demo.signals&#039; ...)&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;ping&#039; ...)&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;demo-source&#039; ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit one message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;hello bus&amp;quot;})&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect capture before receiving:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
len(sink.records)&lt;br /&gt;
[type(record).__name__ for record in sink.records]&lt;br /&gt;
sink.records[-1]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
4&lt;br /&gt;
[&#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;CapturedMessage&#039;]&lt;br /&gt;
CapturedMessage(... payload={&#039;text&#039;: &#039;hello bus&#039;} ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
message = receiver.receive()&lt;br /&gt;
&lt;br /&gt;
message&lt;br /&gt;
message.msg_topic&lt;br /&gt;
message.msg_producer&lt;br /&gt;
message.msg_type&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ReceivedMessage(...)&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&#039;demo-source&#039;&lt;br /&gt;
&#039;ping&#039;&lt;br /&gt;
{&#039;text&#039;: &#039;hello bus&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the received message with the captured record:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured = sink.records[-1]&lt;br /&gt;
&lt;br /&gt;
captured&lt;br /&gt;
captured.msg_topic_id&lt;br /&gt;
captured.msg_type_id&lt;br /&gt;
captured.msg_producer_id&lt;br /&gt;
captured.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
CapturedMessage(...)&lt;br /&gt;
TopicID(...)&lt;br /&gt;
MessageTypeID(...)&lt;br /&gt;
ProducerID(...)&lt;br /&gt;
{&#039;text&#039;: &#039;hello bus&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The receiver gets the readable message view. The capture sink sees compact IDs and local payload data. The readable names are still available through symbol registration records, but manually reconstructing them is awkward.&lt;br /&gt;
&lt;br /&gt;
That awkwardness motivates the next phase.&lt;br /&gt;
&lt;br /&gt;
== Phase 2: Build a temporary capture history helper ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a small demonstration helper that turns captured records back into readable history messages.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Capture records are useful, but raw captured messages use compact IDs.&lt;br /&gt;
* A history helper can rebuild readable message history from registration records.&lt;br /&gt;
* This is demonstration infrastructure, not the final persistence/query API.&lt;br /&gt;
* The helper writes captured records into an in-memory &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; sequential unit.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration before the file edit&lt;br /&gt;
&lt;br /&gt;
List only the captured messages:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured_messages = []&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    if isinstance(record, CapturedMessage):&lt;br /&gt;
        captured_messages.append(record)&lt;br /&gt;
&lt;br /&gt;
captured_messages&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[CapturedMessage(... payload={&#039;text&#039;: &#039;hello bus&#039;} ...)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first captured message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured_message = captured_messages[0]&lt;br /&gt;
&lt;br /&gt;
captured_message.msg_topic_id&lt;br /&gt;
captured_message.msg_type_id&lt;br /&gt;
captured_message.msg_producer_id&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
TopicID(...)&lt;br /&gt;
MessageTypeID(...)&lt;br /&gt;
ProducerID(...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Manually reconstruct a topic name:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
topic_names = {}&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    if isinstance(record, MessageSymbolRegistration):&lt;br /&gt;
        if record.symbol_kind.name == &amp;quot;TOPIC&amp;quot;:&lt;br /&gt;
            topic_names[record.symbol_id] = record.symbol&lt;br /&gt;
&lt;br /&gt;
topic_names&lt;br /&gt;
topic_names[captured_message.msg_topic_id]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
{TopicID(...): &#039;demo.signals&#039;}&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This works, but repeating it by hand would distract from writing event processors.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/capture_history.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/capture_history.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Capture and history helpers for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from collections.abc import Iterable&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
&lt;br /&gt;
from aostore.sequentialunit.listsequentialunit import ListSequentialUnit&lt;br /&gt;
from mbus.capture.sink import CaptureSink&lt;br /&gt;
from mbus.message.records import CapturedMessage&lt;br /&gt;
from mbus.message.symbols import (&lt;br /&gt;
    MessageSymbolKind,&lt;br /&gt;
    MessageSymbolRegistration,&lt;br /&gt;
    MessageTypeID,&lt;br /&gt;
    ProducerID,&lt;br /&gt;
    TopicID,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
CapturedRecord = MessageSymbolRegistration | CapturedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class AOStoreCaptureSink(CaptureSink):&lt;br /&gt;
    records: ListSequentialUnit[CapturedRecord] = field(&lt;br /&gt;
        default_factory=ListSequentialUnit&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    def capture_symbol_registration(&lt;br /&gt;
        self,&lt;br /&gt;
        registration: MessageSymbolRegistration,&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self.records.append(registration)&lt;br /&gt;
&lt;br /&gt;
    def capture(self, message: CapturedMessage) -&amp;gt; None:&lt;br /&gt;
        self.records.append(message)&lt;br /&gt;
&lt;br /&gt;
    def all_records(self) -&amp;gt; tuple[CapturedRecord, ...]:&lt;br /&gt;
        records = self.records.sequential_read(0, len(self.records))&lt;br /&gt;
        result = tuple(records)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class HistoryMessage:&lt;br /&gt;
    msg_topic: str&lt;br /&gt;
    msg_type: str&lt;br /&gt;
    msg_producer: str&lt;br /&gt;
    payload: object&lt;br /&gt;
    bus_sequence: int&lt;br /&gt;
    topic_sequence: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class CaptureHistory:&lt;br /&gt;
    _records: tuple[CapturedRecord, ...]&lt;br /&gt;
    _topics: dict[TopicID, str]&lt;br /&gt;
    _types: dict[MessageTypeID, str]&lt;br /&gt;
    _producers: dict[ProducerID, str]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, records: Iterable[CapturedRecord]) -&amp;gt; None:&lt;br /&gt;
        self._records = tuple(records)&lt;br /&gt;
        self._topics = {}&lt;br /&gt;
        self._types = {}&lt;br /&gt;
        self._producers = {}&lt;br /&gt;
        self._index_symbols()&lt;br /&gt;
&lt;br /&gt;
    def _index_symbols(self) -&amp;gt; None:&lt;br /&gt;
        for record in self._records:&lt;br /&gt;
            if isinstance(record, MessageSymbolRegistration):&lt;br /&gt;
                self._record_symbol(record)&lt;br /&gt;
&lt;br /&gt;
    def _record_symbol(self, record: MessageSymbolRegistration) -&amp;gt; None:&lt;br /&gt;
        if record.symbol_kind is MessageSymbolKind.TOPIC:&lt;br /&gt;
            self._topics[record.symbol_id] = record.symbol&lt;br /&gt;
        elif record.symbol_kind is MessageSymbolKind.MSG_TYPE:&lt;br /&gt;
            self._types[record.symbol_id] = record.symbol&lt;br /&gt;
        elif record.symbol_kind is MessageSymbolKind.PRODUCER:&lt;br /&gt;
            self._producers[record.symbol_id] = record.symbol&lt;br /&gt;
&lt;br /&gt;
    def messages(self) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for record in self._records:&lt;br /&gt;
            if isinstance(record, CapturedMessage):&lt;br /&gt;
                message = self._decode_message(record)&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def by_topic(self, msg_topic: str) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for message in self.messages():&lt;br /&gt;
            if message.msg_topic == msg_topic:&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def since(self, bus_sequence: int) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for message in self.messages():&lt;br /&gt;
            if message.bus_sequence &amp;gt;= bus_sequence:&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def _decode_message(self, message: CapturedMessage) -&amp;gt; HistoryMessage:&lt;br /&gt;
        history_message = HistoryMessage(&lt;br /&gt;
            msg_topic=self._topics[message.msg_topic_id],&lt;br /&gt;
            msg_type=self._types[message.msg_type_id],&lt;br /&gt;
            msg_producer=self._producers[message.msg_producer_id],&lt;br /&gt;
            payload=message.payload,&lt;br /&gt;
            bus_sequence=message.bus_sequence,&lt;br /&gt;
            topic_sequence=message.topic_sequence,&lt;br /&gt;
        )&lt;br /&gt;
        return history_message&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;history helper&amp;quot;})&lt;br /&gt;
message = receiver.receive()&lt;br /&gt;
&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
history.messages()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[HistoryMessage(msg_topic=&#039;demo.signals&#039;, msg_type=&#039;ping&#039;, msg_producer=&#039;demo-source&#039;, payload={&#039;text&#039;: &#039;history helper&#039;}, ...)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the decoded history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history_message = history.messages()[0]&lt;br /&gt;
&lt;br /&gt;
history_message.msg_topic&lt;br /&gt;
history_message.msg_type&lt;br /&gt;
history_message.msg_producer&lt;br /&gt;
history_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&#039;ping&#039;&lt;br /&gt;
&#039;demo-source&#039;&lt;br /&gt;
{&#039;text&#039;: &#039;history helper&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 3: Explore shell command data by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Identify the high-level event shape needed by a regex milestone processor.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The old milestone strategy operated on command-level records.&lt;br /&gt;
* A command-level record includes context such as host and working directory.&lt;br /&gt;
* It also includes shell input and shell output.&lt;br /&gt;
* Raw TTY events do not directly have this shape; reconstruction comes later.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Represent a shell command as a dictionary:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = {&lt;br /&gt;
    &amp;quot;session_id&amp;quot;: &amp;quot;session-1&amp;quot;,&lt;br /&gt;
    &amp;quot;command_index&amp;quot;: 0,&lt;br /&gt;
    &amp;quot;host&amp;quot;: &amp;quot;alpha&amp;quot;,&lt;br /&gt;
    &amp;quot;cwd&amp;quot;: &amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    &amp;quot;input_text&amp;quot;: &amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    &amp;quot;output_text&amp;quot;: &amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    &amp;quot;started_at_ns&amp;quot;: 1000,&lt;br /&gt;
    &amp;quot;ended_at_ns&amp;quot;: 2000,&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the fields used by regex milestone checks:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command[&amp;quot;host&amp;quot;]&lt;br /&gt;
command[&amp;quot;cwd&amp;quot;]&lt;br /&gt;
command[&amp;quot;input_text&amp;quot;]&lt;br /&gt;
command[&amp;quot;output_text&amp;quot;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;alpha&#039;&lt;br /&gt;
&#039;/home/student&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Try an accidental wrong key:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command[&amp;quot;working_directory&amp;quot;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeyError: &#039;working_directory&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The dictionary contains the information, but it does not document the shape of the event. A typo is discovered only when the field is used. A named event class makes the processor code easier to read and easier to explain.&lt;br /&gt;
&lt;br /&gt;
== Phase 4: Persist the event record classes ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create reusable payload classes for the TTY, shell command, and regex milestone examples.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/events.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/events.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Payload records for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from dataclasses import dataclass&lt;br /&gt;
from typing import Literal&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class TTYReadEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    data: bytes&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class TTYWriteEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    data: bytes&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class LineDisciplineEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    line: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class KeystrokeTimingEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    previous_timestamp_ns: int | None&lt;br /&gt;
    delta_ns: int | None&lt;br /&gt;
    text: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class ShellCommandEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    command_index: int&lt;br /&gt;
    host: str&lt;br /&gt;
    cwd: str&lt;br /&gt;
    input_text: str&lt;br /&gt;
    output_text: str&lt;br /&gt;
    started_at_ns: int&lt;br /&gt;
    ended_at_ns: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
RegexMilestoneField = Literal[&lt;br /&gt;
    &amp;quot;host&amp;quot;,&lt;br /&gt;
    &amp;quot;cwd&amp;quot;,&lt;br /&gt;
    &amp;quot;input_text&amp;quot;,&lt;br /&gt;
    &amp;quot;output_text&amp;quot;,&lt;br /&gt;
]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneExpression:&lt;br /&gt;
    name: str&lt;br /&gt;
    field: RegexMilestoneField&lt;br /&gt;
    pattern: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneResult:&lt;br /&gt;
    expression_name: str&lt;br /&gt;
    field: RegexMilestoneField&lt;br /&gt;
    matched: bool&lt;br /&gt;
    match_text: str | None = None&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneAnalysisEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    command_index: int&lt;br /&gt;
    results: tuple[RegexMilestoneResult, ...]&lt;br /&gt;
    matched_count: int&lt;br /&gt;
    expression_count: int&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.events import ShellCommandEvent&lt;br /&gt;
&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
command&lt;br /&gt;
command.host&lt;br /&gt;
command.cwd&lt;br /&gt;
command.input_text&lt;br /&gt;
command.output_text&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ShellCommandEvent(session_id=&#039;session-1&#039;, command_index=0, ...)&lt;br /&gt;
&#039;alpha&#039;&lt;br /&gt;
&#039;/home/student&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The event shape is now explicit. Other code can depend on a named &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; instead of assuming that a loose dictionary has the expected fields.&lt;br /&gt;
&lt;br /&gt;
== Phase 5: Evaluate regex milestones manually ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Recreate the core milestone check in a small, transparent form.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* A regex milestone expression names one check against one shell command field.&lt;br /&gt;
* The field may be command input, command output, working directory, or host context.&lt;br /&gt;
* The result records whether the expression matched and, when available, the matched text.&lt;br /&gt;
* Manual evaluation is useful for learning the shape of the problem before writing a processor.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Import regular expressions and the payload classes:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create one command event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create three milestone expressions:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
expressions = (&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;used-ls&amp;quot;,&lt;br /&gt;
        field=&amp;quot;input_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;\bls\b&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;saw-notes&amp;quot;,&lt;br /&gt;
        field=&amp;quot;output_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;notes\.txt&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;home-directory&amp;quot;,&lt;br /&gt;
        field=&amp;quot;cwd&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;^/home/student$&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Evaluate one expression:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
expression = expressions[0]&lt;br /&gt;
text = getattr(command, expression.field)&lt;br /&gt;
match = re.search(expression.pattern, text)&lt;br /&gt;
&lt;br /&gt;
expression.name&lt;br /&gt;
expression.field&lt;br /&gt;
text&lt;br /&gt;
match.group(0)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;used-ls&#039;&lt;br /&gt;
&#039;input_text&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;ls&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Evaluate all expressions:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
manual_results = []&lt;br /&gt;
&lt;br /&gt;
for expression in expressions:&lt;br /&gt;
    text = getattr(command, expression.field)&lt;br /&gt;
    match = re.search(expression.pattern, text)&lt;br /&gt;
    manual_results.append((expression.name, match is not None))&lt;br /&gt;
&lt;br /&gt;
manual_results&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(&#039;used-ls&#039;, True), (&#039;saw-notes&#039;, True), (&#039;home-directory&#039;, True)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
Manual evaluation is simple for one command, but the loop is already event processor logic. The next phase preserves the loop in a reusable processor.&lt;br /&gt;
&lt;br /&gt;
== Phase 6: Persist the RegexMilestone processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a processor that receives shell command events and emits regex milestone analysis events.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/regex_milestones.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/regex_milestones.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Regex milestone processor for shell command events.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneAnalysisEvent,&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    RegexMilestoneResult,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class RegexMilestoneProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _expressions: tuple[RegexMilestoneExpression, ...]&lt;br /&gt;
&lt;br /&gt;
    def __init__(&lt;br /&gt;
        self,&lt;br /&gt;
        bus: DirectMessageBus,&lt;br /&gt;
        expressions: tuple[RegexMilestoneExpression, ...],&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.shell.command&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;shell-reconstructor&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;shell-command&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.shell.regex_milestone&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;regex-milestone-processor&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;regex-milestone-analysis&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._expressions = expressions&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; RegexMilestoneAnalysisEvent:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        command = message.payload&lt;br /&gt;
&lt;br /&gt;
        if not isinstance(command, ShellCommandEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected ShellCommandEvent, got {type(command).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        analysis = analyze_regex_milestones(command, self._expressions)&lt;br /&gt;
        self._emitter.emit(analysis)&lt;br /&gt;
        return analysis&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def analyze_regex_milestones(&lt;br /&gt;
    command: ShellCommandEvent,&lt;br /&gt;
    expressions: tuple[RegexMilestoneExpression, ...],&lt;br /&gt;
) -&amp;gt; RegexMilestoneAnalysisEvent:&lt;br /&gt;
    results = []&lt;br /&gt;
    for expression in expressions:&lt;br /&gt;
        result = evaluate_regex_milestone(command, expression)&lt;br /&gt;
        results.append(result)&lt;br /&gt;
&lt;br /&gt;
    matched_count = sum(result.matched for result in results)&lt;br /&gt;
    analysis = RegexMilestoneAnalysisEvent(&lt;br /&gt;
        session_id=command.session_id,&lt;br /&gt;
        command_index=command.command_index,&lt;br /&gt;
        results=tuple(results),&lt;br /&gt;
        matched_count=matched_count,&lt;br /&gt;
        expression_count=len(expressions),&lt;br /&gt;
    )&lt;br /&gt;
    return analysis&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def evaluate_regex_milestone(&lt;br /&gt;
    command: ShellCommandEvent,&lt;br /&gt;
    expression: RegexMilestoneExpression,&lt;br /&gt;
) -&amp;gt; RegexMilestoneResult:&lt;br /&gt;
    text = getattr(command, expression.field)&lt;br /&gt;
    match = re.search(expression.pattern, text)&lt;br /&gt;
    match_text = None&lt;br /&gt;
&lt;br /&gt;
    if match is not None:&lt;br /&gt;
        match_text = match.group(0)&lt;br /&gt;
&lt;br /&gt;
    result = RegexMilestoneResult(&lt;br /&gt;
        expression_name=expression.name,&lt;br /&gt;
        field=expression.field,&lt;br /&gt;
        matched=match is not None,&lt;br /&gt;
        match_text=match_text,&lt;br /&gt;
    )&lt;br /&gt;
    return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then set up the bus:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.regex_milestones import RegexMilestoneProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Subscribe to the analysis topic before running the processor:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.shell.regex_milestone&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;regex-milestone-processor&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;regex-milestone-analysis&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create the shell command emitter and processor:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.shell.command&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;shell-reconstructor&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;shell-command&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
expressions = (&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;used-ls&amp;quot;,&lt;br /&gt;
        field=&amp;quot;input_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;\bls\b&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;saw-notes&amp;quot;,&lt;br /&gt;
        field=&amp;quot;output_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;notes\.txt&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;home-directory&amp;quot;,&lt;br /&gt;
        field=&amp;quot;cwd&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;^/home/student$&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = RegexMilestoneProcessor(bus, expressions)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit a shell command event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
command_emitter.emit(command)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the command:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis = processor.process_one()&lt;br /&gt;
analysis&lt;br /&gt;
analysis.results&lt;br /&gt;
analysis.matched_count&lt;br /&gt;
analysis.expression_count&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
RegexMilestoneAnalysisEvent(... matched_count=3, expression_count=3)&lt;br /&gt;
(RegexMilestoneResult(...), RegexMilestoneResult(...), RegexMilestoneResult(...))&lt;br /&gt;
3&lt;br /&gt;
3&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the derived analysis event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis_message = analysis_receiver.receive()&lt;br /&gt;
&lt;br /&gt;
analysis_message.msg_topic&lt;br /&gt;
analysis_message.msg_type&lt;br /&gt;
analysis_message.msg_producer&lt;br /&gt;
analysis_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;demo.shell.regex_milestone&#039;&lt;br /&gt;
&#039;regex-milestone-analysis&#039;&lt;br /&gt;
&#039;regex-milestone-processor&#039;&lt;br /&gt;
RegexMilestoneAnalysisEvent(... matched_count=3, expression_count=3)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
messages = history.messages()&lt;br /&gt;
&lt;br /&gt;
for message in messages:&lt;br /&gt;
    print(message.bus_sequence, message.msg_topic, message.msg_type)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
0 demo.shell.command shell-command&lt;br /&gt;
1 demo.shell.regex_milestone regex-milestone-analysis&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The regex milestone processor consumes one event and emits another. The derived analysis event is available to any later subscriber. History contains both the source command event and the derived analysis event.&lt;br /&gt;
&lt;br /&gt;
== Phase 7: Explore why shell reconstruction is separate ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Show that raw TTY observations are not the same thing as high-level shell command events.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Regex milestones should inspect high-level command events.&lt;br /&gt;
* TTY reads, TTY writes, and line-discipline lines are lower-level observations.&lt;br /&gt;
* A shell command event is reconstructed by correlating those observations.&lt;br /&gt;
* Keeping reconstruction separate prevents the regex milestone processor from becoming a large TTY parser.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Create a few low-level observations:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    LineDisciplineEvent,&lt;br /&gt;
    TTYReadEvent,&lt;br /&gt;
    TTYWriteEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_1 = TTYReadEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=0,&lt;br /&gt;
    timestamp_ns=1000,&lt;br /&gt;
    data=b&amp;quot;l&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_2 = TTYReadEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=1,&lt;br /&gt;
    timestamp_ns=1100,&lt;br /&gt;
    data=b&amp;quot;s&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
line = LineDisciplineEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=2,&lt;br /&gt;
    timestamp_ns=1200,&lt;br /&gt;
    line=&amp;quot;ls\n&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
write = TTYWriteEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=3,&lt;br /&gt;
    timestamp_ns=1600,&lt;br /&gt;
    data=b&amp;quot;notes.txt\n&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the raw pieces:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_1.data&lt;br /&gt;
read_2.data&lt;br /&gt;
line.line&lt;br /&gt;
write.data&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
b&#039;l&#039;&lt;br /&gt;
b&#039;s&#039;&lt;br /&gt;
&#039;ls\n&#039;&lt;br /&gt;
b&#039;notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Assemble command fields manually:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
input_text = line.line&lt;br /&gt;
output_text = write.data.decode(&amp;quot;utf-8&amp;quot;, errors=&amp;quot;replace&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
input_text&lt;br /&gt;
output_text&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;ls\n&#039;&lt;br /&gt;
&#039;notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The useful command event is derived from several lower-level observations. A later shell reconstructor can subscribe to line-discipline events, query recent reads/writes/timing observations, and emit &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; records.&lt;br /&gt;
&lt;br /&gt;
== Phase 8: Add keystroke timing as a smaller TTY-derived processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a simple stateful processor before attempting full shell reconstruction.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Timing requires comparing a TTY read with the previous read in the same session.&lt;br /&gt;
* A derived timing event makes the result explicit.&lt;br /&gt;
* Other processors can consume timing events instead of recalculating deltas.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration before the file edit&lt;br /&gt;
&lt;br /&gt;
Calculate timing manually from the two read events:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_events = [read_1, read_2]&lt;br /&gt;
previous_timestamp = None&lt;br /&gt;
timing_rows = []&lt;br /&gt;
&lt;br /&gt;
for event in read_events:&lt;br /&gt;
    delta = None&lt;br /&gt;
    if previous_timestamp is not None:&lt;br /&gt;
        delta = event.timestamp_ns - previous_timestamp&lt;br /&gt;
    timing_rows.append((event.source_index, previous_timestamp, delta))&lt;br /&gt;
    previous_timestamp = event.timestamp_ns&lt;br /&gt;
&lt;br /&gt;
timing_rows&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, None, None), (1, 1000, 100)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/keystroke_timing.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/keystroke_timing.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Keystroke timing processor for TTY read events.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    KeystrokeTimingEvent,&lt;br /&gt;
    TTYReadEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class KeystrokeTimingProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _last_timestamp_by_session: dict[str, int]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.tty.read&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;tty-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;tty-read&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.keystroke.timing&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._last_timestamp_by_session = {}&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; KeystrokeTimingEvent:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        event = message.payload&lt;br /&gt;
&lt;br /&gt;
        if not isinstance(event, TTYReadEvent):&lt;br /&gt;
            raise TypeError(f&amp;quot;expected TTYReadEvent, got {type(event).__name__}&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
        previous_timestamp = self._last_timestamp_by_session.get(&lt;br /&gt;
            event.session_id&lt;br /&gt;
        )&lt;br /&gt;
        delta = None&lt;br /&gt;
&lt;br /&gt;
        if previous_timestamp is not None:&lt;br /&gt;
            delta = event.timestamp_ns - previous_timestamp&lt;br /&gt;
&lt;br /&gt;
        self._last_timestamp_by_session[event.session_id] = event.timestamp_ns&lt;br /&gt;
&lt;br /&gt;
        text = event.data.decode(&amp;quot;utf-8&amp;quot;, errors=&amp;quot;replace&amp;quot;)&lt;br /&gt;
        timing = KeystrokeTimingEvent(&lt;br /&gt;
            session_id=event.session_id,&lt;br /&gt;
            source_index=event.source_index,&lt;br /&gt;
            timestamp_ns=event.timestamp_ns,&lt;br /&gt;
            previous_timestamp_ns=previous_timestamp,&lt;br /&gt;
            delta_ns=delta,&lt;br /&gt;
            text=text,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter.emit(timing)&lt;br /&gt;
        return timing&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
from demos.event_processors.events import TTYReadEvent&lt;br /&gt;
from demos.event_processors.keystroke_timing import KeystrokeTimingProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
timing_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.keystroke.timing&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.tty.read&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;tty-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;tty-read&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = KeystrokeTimingProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit reads and process them:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_emitter.emit(&lt;br /&gt;
    TTYReadEvent(&lt;br /&gt;
        session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
        source_index=0,&lt;br /&gt;
        timestamp_ns=1000,&lt;br /&gt;
        data=b&amp;quot;l&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_emitter.emit(&lt;br /&gt;
    TTYReadEvent(&lt;br /&gt;
        session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
        source_index=1,&lt;br /&gt;
        timestamp_ns=1100,&lt;br /&gt;
        data=b&amp;quot;s&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor.process_one()&lt;br /&gt;
processor.process_one()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeystrokeTimingEvent(... previous_timestamp_ns=None, delta_ns=None, text=&#039;l&#039;)&lt;br /&gt;
KeystrokeTimingEvent(... previous_timestamp_ns=1000, delta_ns=100, text=&#039;s&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive timing events:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
timing_receiver.receive().payload&lt;br /&gt;
timing_receiver.receive().payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeystrokeTimingEvent(... source_index=0 ...)&lt;br /&gt;
KeystrokeTimingEvent(... source_index=1 ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 9: Introduce graph events ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Switch to a small graph problem that can demonstrate feedback loops safely.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* An edge is a source fact.&lt;br /&gt;
* A path is a derived fact.&lt;br /&gt;
* A direct edge implies a direct path.&lt;br /&gt;
* Longer paths can be derived later from known paths and edges.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_events.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/graph_events.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Graph event payloads for message bus demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from dataclasses import dataclass&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class EdgeDeclaredEvent:&lt;br /&gt;
    graph_id: str&lt;br /&gt;
    source: str&lt;br /&gt;
    target: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class PathKnownEvent:&lt;br /&gt;
    graph_id: str&lt;br /&gt;
    source: str&lt;br /&gt;
    target: str&lt;br /&gt;
    hop_count: int&lt;br /&gt;
&lt;br /&gt;
    def fact_key(self) -&amp;gt; tuple[str, str, str]:&lt;br /&gt;
        result = (self.graph_id, self.source, self.target)&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge = EdgeDeclaredEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;A&amp;quot;,&lt;br /&gt;
    target=&amp;quot;B&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path = PathKnownEvent(&lt;br /&gt;
    graph_id=edge.graph_id,&lt;br /&gt;
    source=edge.source,&lt;br /&gt;
    target=edge.target,&lt;br /&gt;
    hop_count=1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge&lt;br /&gt;
path&lt;br /&gt;
path.fact_key()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
EdgeDeclaredEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;)&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;, hop_count=1)&lt;br /&gt;
(&#039;demo-graph&#039;, &#039;A&#039;, &#039;B&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 10: Create a demo-only nonblocking receive helper ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Prepare for processor scheduling without editing &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;Receiver.receive()&amp;lt;/code&amp;gt; blocks until a message is available.&lt;br /&gt;
* A scheduler needs to try a processor and move on if no message is waiting.&lt;br /&gt;
* The current package does not expose a public nonblocking receiver method.&lt;br /&gt;
* This helper is local demonstration code. It can be replaced by a real public API later.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/demo_receive.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/demo_receive.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Demo-only helpers for receiver inspection and scheduling.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from queue import Empty&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.endpoints import Receiver&lt;br /&gt;
from mbus.message.records import ReceivedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class DemoReceiverError(RuntimeError):&lt;br /&gt;
    pass&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def try_receive(receiver: Receiver) -&amp;gt; ReceivedMessage | None:&lt;br /&gt;
    queue = getattr(receiver, &amp;quot;_queue&amp;quot;, None)&lt;br /&gt;
&lt;br /&gt;
    if queue is None:&lt;br /&gt;
        raise DemoReceiverError(&lt;br /&gt;
            &amp;quot;demo try_receive requires the current direct broker receiver&amp;quot;&lt;br /&gt;
        )&lt;br /&gt;
&lt;br /&gt;
    try:&lt;br /&gt;
        message = queue.get_nowait()&lt;br /&gt;
    except Empty:&lt;br /&gt;
        message = None&lt;br /&gt;
&lt;br /&gt;
    return message&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.demo_receive import try_receive&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
try_receive(receiver)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
None&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Now emit a message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;demo nonblocking receive&amp;quot;})&lt;br /&gt;
message = try_receive(receiver)&lt;br /&gt;
&lt;br /&gt;
message&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ReceivedMessage(...)&lt;br /&gt;
{&#039;text&#039;: &#039;demo nonblocking receive&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 11: Create the direct path processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Implement the graph rule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
edge(A, B) -&amp;gt; path(A, B)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The source emits edge events.&lt;br /&gt;
* The direct path processor emits derived path events.&lt;br /&gt;
* Other processors can subscribe to derived path events.&lt;br /&gt;
* The processor can be run by hand or by a scheduler.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_reachability.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/graph_reachability.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Graph reachability processors for message bus demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.demo_receive import try_receive&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class DirectPathProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _known_paths: set[tuple[str, str, str]]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._known_paths = set()&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; PathKnownEvent | None:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        result = self._process_payload(message.payload)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
    def process_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            self._process_payload(message.payload)&lt;br /&gt;
            work_count = 1&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def _process_payload(self, payload: object) -&amp;gt; PathKnownEvent | None:&lt;br /&gt;
        if not isinstance(payload, EdgeDeclaredEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected EdgeDeclaredEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        path = PathKnownEvent(&lt;br /&gt;
            graph_id=payload.graph_id,&lt;br /&gt;
            source=payload.source,&lt;br /&gt;
            target=payload.target,&lt;br /&gt;
            hop_count=1,&lt;br /&gt;
        )&lt;br /&gt;
&lt;br /&gt;
        if path.fact_key() not in self._known_paths:&lt;br /&gt;
            self._known_paths.add(path.fact_key())&lt;br /&gt;
            self._emitter.emit(path)&lt;br /&gt;
            result = path&lt;br /&gt;
        else:&lt;br /&gt;
            result = None&lt;br /&gt;
&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
from demos.event_processors.graph_events import EdgeDeclaredEvent&lt;br /&gt;
from demos.event_processors.graph_reachability import DirectPathProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
path_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = DirectPathProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Call the processor before any edge exists:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
0&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit one edge:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;A&amp;quot;,&lt;br /&gt;
        target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the edge:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the derived path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_message = path_receiver.receive()&lt;br /&gt;
path_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;, hop_count=1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 12: Explore the path extension rule by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Understand the feedback rule before implementing it.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* If &amp;lt;code&amp;gt;A&amp;lt;/code&amp;gt; reaches &amp;lt;code&amp;gt;B&amp;lt;/code&amp;gt;, and there is an edge from &amp;lt;code&amp;gt;B&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;C&amp;lt;/code&amp;gt;, then &amp;lt;code&amp;gt;A&amp;lt;/code&amp;gt; reaches &amp;lt;code&amp;gt;C&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Derived path facts can lead to more derived path facts.&lt;br /&gt;
* Duplicate prevention keeps the feedback loop from repeating the same fact forever.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_bc = EdgeDeclaredEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;B&amp;quot;,&lt;br /&gt;
    target=&amp;quot;C&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path_ab = PathKnownEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;A&amp;quot;,&lt;br /&gt;
    target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    hop_count=1,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Check whether the path and edge connect:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_ab.target&lt;br /&gt;
edge_bc.source&lt;br /&gt;
path_ab.target == edge_bc.source&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;B&#039;&lt;br /&gt;
&#039;B&#039;&lt;br /&gt;
True&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Derive a new path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_ac = PathKnownEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=path_ab.source,&lt;br /&gt;
    target=edge_bc.target,&lt;br /&gt;
    hop_count=path_ab.hop_count + 1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path_ac&lt;br /&gt;
path_ac.fact_key()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;C&#039;, hop_count=2)&lt;br /&gt;
(&#039;demo-graph&#039;, &#039;A&#039;, &#039;C&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Manually prevent duplicate path facts:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
known_paths = set()&lt;br /&gt;
known_paths.add(path_ab.fact_key())&lt;br /&gt;
&lt;br /&gt;
path_ac.fact_key() in known_paths&lt;br /&gt;
&lt;br /&gt;
if path_ac.fact_key() not in known_paths:&lt;br /&gt;
    known_paths.add(path_ac.fact_key())&lt;br /&gt;
&lt;br /&gt;
known_paths&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
False&lt;br /&gt;
{(&#039;demo-graph&#039;, &#039;A&#039;, &#039;B&#039;), (&#039;demo-graph&#039;, &#039;A&#039;, &#039;C&#039;)}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 13: Add the path extension processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Implement the feedback rule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
path(A, B) + edge(B, C) -&amp;gt; path(A, C)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The extension processor listens to edge events and path events.&lt;br /&gt;
* It emits new path events when a known path can be extended.&lt;br /&gt;
* It also listens to its own output because an extended path may be extendable again.&lt;br /&gt;
* This is a controlled feedback loop.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Append this class to:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_reachability.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
class PathExtensionProcessor:&lt;br /&gt;
    _edge_receiver: Receiver&lt;br /&gt;
    _direct_path_receiver: Receiver&lt;br /&gt;
    _extension_path_receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _edges: set[tuple[str, str, str]]&lt;br /&gt;
    _paths: dict[tuple[str, str, str], PathKnownEvent]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._edge_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._direct_path_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._extension_path_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._edges = set()&lt;br /&gt;
        self._paths = {}&lt;br /&gt;
&lt;br /&gt;
    def process_edge_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._edge_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_edge_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def process_direct_path_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._direct_path_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_path_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def process_extension_path_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._extension_path_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_path_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def _process_edge_payload(&lt;br /&gt;
        self,&lt;br /&gt;
        payload: object,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        if not isinstance(payload, EdgeDeclaredEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected EdgeDeclaredEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        edge = payload&lt;br /&gt;
        self._edges.add((edge.graph_id, edge.source, edge.target))&lt;br /&gt;
        paths = self._derive_from_new_edge(edge)&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _process_path_payload(&lt;br /&gt;
        self,&lt;br /&gt;
        payload: object,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        if not isinstance(payload, PathKnownEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected PathKnownEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        path = payload&lt;br /&gt;
        self._paths[path.fact_key()] = path&lt;br /&gt;
        paths = self._derive_from_new_path(path)&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _derive_from_new_edge(&lt;br /&gt;
        self,&lt;br /&gt;
        edge: EdgeDeclaredEvent,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        paths = []&lt;br /&gt;
&lt;br /&gt;
        for known_path in self._paths.values():&lt;br /&gt;
            if known_path.graph_id != edge.graph_id:&lt;br /&gt;
                continue&lt;br /&gt;
            if known_path.target != edge.source:&lt;br /&gt;
                continue&lt;br /&gt;
&lt;br /&gt;
            next_path = PathKnownEvent(&lt;br /&gt;
                graph_id=edge.graph_id,&lt;br /&gt;
                source=known_path.source,&lt;br /&gt;
                target=edge.target,&lt;br /&gt;
                hop_count=known_path.hop_count + 1,&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
            if self._emit_if_new(next_path):&lt;br /&gt;
                paths.append(next_path)&lt;br /&gt;
&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _derive_from_new_path(&lt;br /&gt;
        self,&lt;br /&gt;
        path: PathKnownEvent,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        paths = []&lt;br /&gt;
&lt;br /&gt;
        for graph_id, edge_source, edge_target in self._edges:&lt;br /&gt;
            if graph_id != path.graph_id:&lt;br /&gt;
                continue&lt;br /&gt;
            if edge_source != path.target:&lt;br /&gt;
                continue&lt;br /&gt;
&lt;br /&gt;
            next_path = PathKnownEvent(&lt;br /&gt;
                graph_id=path.graph_id,&lt;br /&gt;
                source=path.source,&lt;br /&gt;
                target=edge_target,&lt;br /&gt;
                hop_count=path.hop_count + 1,&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
            if self._emit_if_new(next_path):&lt;br /&gt;
                paths.append(next_path)&lt;br /&gt;
&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _emit_if_new(self, path: PathKnownEvent) -&amp;gt; bool:&lt;br /&gt;
        if path.fact_key() in self._paths:&lt;br /&gt;
            result = False&lt;br /&gt;
        else:&lt;br /&gt;
            self._paths[path.fact_key()] = path&lt;br /&gt;
            self._emitter.emit(path)&lt;br /&gt;
            result = True&lt;br /&gt;
&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_events import EdgeDeclaredEvent&lt;br /&gt;
from demos.event_processors.graph_reachability import (&lt;br /&gt;
    DirectPathProcessor,&lt;br /&gt;
    PathExtensionProcessor,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
extension_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
direct_processor = DirectPathProcessor(bus)&lt;br /&gt;
extension_processor = PathExtensionProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit two edges in fixed order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;A&amp;quot;,&lt;br /&gt;
        target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;B&amp;quot;,&lt;br /&gt;
        target=&amp;quot;C&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the direct paths:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
direct_processor.process_available()&lt;br /&gt;
direct_processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Let the extension processor learn the edges:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
extension_processor.process_edge_available()&lt;br /&gt;
extension_processor.process_edge_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Let the extension processor learn the direct paths:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
extension_processor.process_direct_path_available()&lt;br /&gt;
extension_processor.process_direct_path_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The second call may emit the extended &amp;lt;code&amp;gt;A -&amp;gt; C&amp;lt;/code&amp;gt; path after the processor has seen both direct paths and both edges.&lt;br /&gt;
&lt;br /&gt;
Receive the extended path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
message = extension_receiver.receive()&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;C&#039;, hop_count=2)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect captured graph path history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
path_messages = history.by_topic(&amp;quot;demo.graph.path&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
for message in path_messages:&lt;br /&gt;
    path = message.payload&lt;br /&gt;
    print(&lt;br /&gt;
        message.bus_sequence,&lt;br /&gt;
        message.msg_producer,&lt;br /&gt;
        path.source,&lt;br /&gt;
        path.target,&lt;br /&gt;
        path.hop_count,&lt;br /&gt;
    )&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
... direct-path A B 1&lt;br /&gt;
... direct-path B C 1&lt;br /&gt;
... path-extension A C 2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 14: Add a randomized processor scheduler ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Simulate concurrency effects without changing source input order.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The source will emit the same edge events in the same order every run.&lt;br /&gt;
* The scheduler varies which processor gets a chance to run first.&lt;br /&gt;
* This simulates uncertainty from processing time, scheduling priority, startup timing, or transmission delay.&lt;br /&gt;
* The graph example should converge to the same final path facts even when processor scheduling, and hence the order of intermediate derived processor events, differs.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/runners.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/runners.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Small processor runners for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from collections.abc import Callable&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
from random import Random&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class ProcessorStep:&lt;br /&gt;
    name: str&lt;br /&gt;
    run: Callable[[], int]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class RandomProcessorRunner:&lt;br /&gt;
    steps: tuple[ProcessorStep, ...]&lt;br /&gt;
    seed: int&lt;br /&gt;
    max_rounds: int = 30&lt;br /&gt;
    trace: list[tuple[int, str, int]] = field(default_factory=list)&lt;br /&gt;
&lt;br /&gt;
    def run_until_quiet(self) -&amp;gt; list[tuple[int, str, int]]:&lt;br /&gt;
        rng = Random(self.seed)&lt;br /&gt;
&lt;br /&gt;
        for round_index in range(self.max_rounds):&lt;br /&gt;
            work_count = self._run_round(rng, round_index)&lt;br /&gt;
&lt;br /&gt;
            if work_count == 0:&lt;br /&gt;
                break&lt;br /&gt;
&lt;br /&gt;
        result = list(self.trace)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
    def _run_round(self, rng: Random, round_index: int) -&amp;gt; int:&lt;br /&gt;
        steps = list(self.steps)&lt;br /&gt;
        rng.shuffle(steps)&lt;br /&gt;
        round_work_count = 0&lt;br /&gt;
&lt;br /&gt;
        for step in steps:&lt;br /&gt;
            step_work_count = step.run()&lt;br /&gt;
            self.trace.append((round_index, step.name, step_work_count))&lt;br /&gt;
            round_work_count += step_work_count&lt;br /&gt;
&lt;br /&gt;
        return round_work_count&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then inspect how a runner can shuffle placeholder steps:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.runners import (&lt;br /&gt;
    ProcessorStep,&lt;br /&gt;
    RandomProcessorRunner,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
calls = []&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def first_step() -&amp;gt; int:&lt;br /&gt;
    calls.append(&amp;quot;first&amp;quot;)&lt;br /&gt;
    return 0&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def second_step() -&amp;gt; int:&lt;br /&gt;
    calls.append(&amp;quot;second&amp;quot;)&lt;br /&gt;
    return 0&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
runner = RandomProcessorRunner(&lt;br /&gt;
    steps=(&lt;br /&gt;
        ProcessorStep(name=&amp;quot;first&amp;quot;, run=first_step),&lt;br /&gt;
        ProcessorStep(name=&amp;quot;second&amp;quot;, run=second_step),&lt;br /&gt;
    ),&lt;br /&gt;
    seed=1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
runner.run_until_quiet()&lt;br /&gt;
calls&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, &#039;...&#039;, 0), (0, &#039;...&#039;, 0)]&lt;br /&gt;
[&#039;...&#039;, &#039;...&#039;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 15: Run the graph example with randomized scheduling ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Run the same graph input under different processor schedules and compare the final path facts.&lt;br /&gt;
&lt;br /&gt;
; Interpreter setup&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_reachability import (&lt;br /&gt;
    DirectPathProcessor,&lt;br /&gt;
    PathExtensionProcessor,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.runners import (&lt;br /&gt;
    ProcessorStep,&lt;br /&gt;
    RandomProcessorRunner,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Define a helper function in the interpreter:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
def run_graph_demo(seed: int) -&amp;gt; tuple[&lt;br /&gt;
    list[tuple[int, str, int]],&lt;br /&gt;
    list[tuple[int, str, str, int]],&lt;br /&gt;
]:&lt;br /&gt;
    bus = DirectMessageBus()&lt;br /&gt;
    sink = AOStoreCaptureSink()&lt;br /&gt;
    bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
    edge_emitter = bus.register_emitter(&lt;br /&gt;
        msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
        msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
        default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    direct_processor = DirectPathProcessor(bus)&lt;br /&gt;
    extension_processor = PathExtensionProcessor(bus)&lt;br /&gt;
&lt;br /&gt;
    edges = (&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;A&amp;quot;,&lt;br /&gt;
            target=&amp;quot;B&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;B&amp;quot;,&lt;br /&gt;
            target=&amp;quot;C&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;C&amp;quot;,&lt;br /&gt;
            target=&amp;quot;D&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    for edge in edges:&lt;br /&gt;
        edge_emitter.emit(edge)&lt;br /&gt;
&lt;br /&gt;
    steps = (&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;direct path from edge&amp;quot;,&lt;br /&gt;
            run=direct_processor.process_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns edge&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_edge_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns direct path&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_direct_path_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns extension path&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_extension_path_available,&lt;br /&gt;
        ),&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    runner = RandomProcessorRunner(&lt;br /&gt;
        steps=steps,&lt;br /&gt;
        seed=seed,&lt;br /&gt;
    )&lt;br /&gt;
    trace = runner.run_until_quiet()&lt;br /&gt;
&lt;br /&gt;
    history = CaptureHistory(sink.all_records())&lt;br /&gt;
    path_facts = []&lt;br /&gt;
&lt;br /&gt;
    for message in history.by_topic(&amp;quot;demo.graph.path&amp;quot;):&lt;br /&gt;
        path = message.payload&lt;br /&gt;
        if isinstance(path, PathKnownEvent):&lt;br /&gt;
            fact = (&lt;br /&gt;
                message.bus_sequence,&lt;br /&gt;
                path.source,&lt;br /&gt;
                path.target,&lt;br /&gt;
                path.hop_count,&lt;br /&gt;
            )&lt;br /&gt;
            path_facts.append(fact)&lt;br /&gt;
&lt;br /&gt;
    result = (trace, path_facts)&lt;br /&gt;
    return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Run one schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_1, paths_1 = run_graph_demo(1)&lt;br /&gt;
&lt;br /&gt;
trace_1&lt;br /&gt;
paths_1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, &#039;...&#039;, ...), (0, &#039;...&#039;, ...), ...]&lt;br /&gt;
[(..., &#039;A&#039;, &#039;B&#039;, 1), (..., &#039;B&#039;, &#039;C&#039;, 1), (..., &#039;C&#039;, &#039;D&#039;, 1), ...]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Run another schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_2, paths_2 = run_graph_demo(2)&lt;br /&gt;
&lt;br /&gt;
trace_2&lt;br /&gt;
paths_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the schedules:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_1 == trace_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
False&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the final path facts without bus sequence numbers:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
final_paths_1 = {&lt;br /&gt;
    (source, target, hop_count)&lt;br /&gt;
    for _, source, target, hop_count in paths_1&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
final_paths_2 = {&lt;br /&gt;
    (source, target, hop_count)&lt;br /&gt;
    for _, source, target, hop_count in paths_2&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
final_paths_1&lt;br /&gt;
final_paths_2&lt;br /&gt;
final_paths_1 == final_paths_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
{(&#039;A&#039;, &#039;B&#039;, 1), (&#039;B&#039;, &#039;C&#039;, 1), (&#039;C&#039;, &#039;D&#039;, 1), (&#039;A&#039;, &#039;C&#039;, 2), (&#039;B&#039;, &#039;D&#039;, 2), (&#039;A&#039;, &#039;D&#039;, 3)}&lt;br /&gt;
{(&#039;A&#039;, &#039;B&#039;, 1), (&#039;B&#039;, &#039;C&#039;, 1), (&#039;C&#039;, &#039;D&#039;, 1), (&#039;A&#039;, &#039;C&#039;, 2), (&#039;B&#039;, &#039;D&#039;, 2), (&#039;A&#039;, &#039;D&#039;, 3)}&lt;br /&gt;
True&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for round_index, step_name, work_count in trace_1:&lt;br /&gt;
    print(round_index, step_name, work_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first captured path order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for bus_sequence, source, target, hop_count in paths_1:&lt;br /&gt;
    print(bus_sequence, source, target, hop_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the second schedule and path order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for round_index, step_name, work_count in trace_2:&lt;br /&gt;
    print(round_index, step_name, work_count)&lt;br /&gt;
&lt;br /&gt;
for bus_sequence, source, target, hop_count in paths_2:&lt;br /&gt;
    print(bus_sequence, source, target, hop_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The source input order did not change. The processor scheduling changed. The captured event order may differ. The final reachability facts should match.&lt;br /&gt;
&lt;br /&gt;
This models an important distributed-systems lesson: event history order and final derived state are related, but they are not the same thing.&lt;br /&gt;
&lt;br /&gt;
== Phase 16: Later refinement: graph index from history ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Introduce restart/recovery thinking only after the basic graph feedback example is familiar.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The first graph processors keep local working state.&lt;br /&gt;
* Local working state is enough for a short demonstration.&lt;br /&gt;
* Longer-lived processors need recovery behavior after restart.&lt;br /&gt;
* Since edge and path facts are captured as events, a graph index can later be rebuilt from history.&lt;br /&gt;
* This points toward projection-style services without changing the basic processor model.&lt;br /&gt;
&lt;br /&gt;
; Suggested later file&lt;br /&gt;
&lt;br /&gt;
Create later:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_index.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Suggested responsibilities:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
read captured history&lt;br /&gt;
collect EdgeDeclaredEvent payloads&lt;br /&gt;
collect PathKnownEvent payloads&lt;br /&gt;
answer whether a path fact exists&lt;br /&gt;
find edges starting at a node&lt;br /&gt;
find paths ending at a node&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This refinement belongs after the team has already run the graph reachability demo successfully.&lt;br /&gt;
&lt;br /&gt;
== Phase 17: Later refinement: shell command reconstruction ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Connect the TTY examples back to the shell command event shape used by regex milestones.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; is the high-level event consumed by the regex milestone processor.&lt;br /&gt;
* &amp;lt;code&amp;gt;TTYReadEvent&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;TTYWriteEvent&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;LineDisciplineEvent&amp;lt;/code&amp;gt; are lower-level observations.&lt;br /&gt;
* A reconstructor can subscribe to line-discipline events and query recent related observations.&lt;br /&gt;
* The reconstructor should emit shell command events; it should not perform regex analysis itself.&lt;br /&gt;
&lt;br /&gt;
; Suggested responsibilities&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
listen for completed line-discipline events&lt;br /&gt;
query history since the previous command boundary for the same session&lt;br /&gt;
collect matching reads, writes, and timing events&lt;br /&gt;
construct ShellCommandEvent&lt;br /&gt;
emit ShellCommandEvent on demo.shell.command&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This is an appropriate follow-up exercise after the team understands both regex milestone processing and history queries.&lt;br /&gt;
&lt;br /&gt;
= Related =&lt;br /&gt;
&lt;br /&gt;
== Top-level topic page ==&lt;br /&gt;
[[Message Bus Service]]&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=TTY_BPF_Instrument&amp;diff=934</id>
		<title>TTY BPF Instrument</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=TTY_BPF_Instrument&amp;diff=934"/>
		<updated>2026-06-16T04:10:31Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Demonstrations and preliminary documentation can be found at https://github.com/edurange/demo-bpf-tty-logger and https://github.com/edurange/prototype-tty-bpf-instrument. Wiki documentation is pending. Preliminary draft files are available in the Discord &amp;lt;code&amp;gt;#fileshare&amp;lt;/code&amp;gt; channel and will be linked here once Wiki configuration is adjusted to support attached non-image documents.&lt;br /&gt;
&lt;br /&gt;
= Summary =&lt;br /&gt;
The TTY instrument is a BPF-based kernel observation tool for capturing terminal activity across a host system. Its purpose is to observe TTY byte activity at the kernel boundary and emit records that downstream components can reconstruct, validate, and analyze. The current design treats terminal activity as factual kernel observations first, and reserves higher-level ideas like sessions, users, commands, and learning activity for later interpretation rather than things the probe is responsible for determining. &lt;br /&gt;
&lt;br /&gt;
Although the immediate target is TTY activity, the design is also meant to serve as a template for future host-level instruments. It separates kernel observation, bounded transport, user-space draining, reconstruction, and downstream interpretation in a way that can be reused for other event sources. The TTY instrument is intentionally demanding: it involves raw byte payloads, ambiguous identity, ordering challenges, fragmentation, loss accounting, and privacy-sensitive data. If this pattern works for TTY activity, it provides a strong example for building other instruments with similar fidelity and reliability requirements - for the kernel-level services like the filesystem or network interfaces, common user-level processes like `bash`, etc.&lt;br /&gt;
&lt;br /&gt;
== Why TTY Capture Is Difficult ==&lt;br /&gt;
This project is difficult because TTY activity is not a single clean stream. A user’s apparent terminal session can pass through SSH, shells, subprocesses, pseudoterminals, terminal echo, line discipline behavior, containers, namespaces, and reconnects. The instrument therefore preserves multiple identity axes, including process IDs, user IDs, cgroups, namespaces, TTY device numbers, inode context, and command/process names. No one field is treated as “the session” or “the user.” Streams or sessions may later be reconstructed from these facts, but the raw record should keep the original evidence intact.&lt;br /&gt;
&lt;br /&gt;
== Tracepoints, BPF, and Kernel Boundaries ==&lt;br /&gt;
The current implementation is tracepoint-based. Kernel tracepoints provide minimal, upstream-plausible attachment surfaces; BPF probes attach to those tracepoints, read bounded payload buffers, enrich records with available identity/timing/ordering context, and emit records through BPF ring buffers. The tracepoint is not the logging schema. The BPF observation record is the instrument-facing schema.&lt;br /&gt;
&lt;br /&gt;
The current prototype includes kernel modifications because the existing kernel does not expose all of the stable attachment surfaces needed for this instrument. Those modifications add narrowly scoped TTY tracepoints so BPF programs can attach at the relevant read, write, and line-discipline receive paths. The goal is not to move logging policy into the kernel or to make the kernel produce the full instrument schema.&lt;br /&gt;
&lt;br /&gt;
The kernel changes must remain minimal, reviewable, and justifiable as tracepoint support; the BPF probe and user-space components remain responsible for the instrument-specific observation record, enrichment, transport, and reconstruction behavior. This distinction matters because tracepoints need to be acceptable to upstream kernel maintainers as general kernel instrumentation, while the richer record schema is specific to this project’s BPF instrument.&lt;br /&gt;
&lt;br /&gt;
== Pipeline and Component Responsibilities ==&lt;br /&gt;
The TTY instrument is also important because it represents a characteristic high-pressure workload for the larger event pipeline. Human terminal input can produce many small payloads with high metadata overhead, while command output can produce sudden bursts of larger byte streams. Multi-user SSH workloads combine both patterns. This makes the instrument a useful benchmark for the event bus and downstream data store: it exercises throughput, buffering, ordering, loss reporting, payload preservation, and reconstruction under conditions that are demanding yet meaningful with a minimal number of tracepoints and probe sites. (Contrast with the filesystem, where roughly a dozen such sites are needed to capture the wider variety of filesystem operations.) The TTY instrument does not define the bus or storage policy, but its output should help reveal whether those downstream systems can handle realistic observational pressure without hiding loss or collapsing important context.&lt;br /&gt;
&lt;br /&gt;
The basic pipeline is:&amp;lt;pre&amp;gt;&lt;br /&gt;
TTY kernel paths&lt;br /&gt;
    -&amp;gt; kernel tracepoints&lt;br /&gt;
    -&amp;gt; BPF probes&lt;br /&gt;
    -&amp;gt; primary observation channel + auxiliary/status channel&lt;br /&gt;
    -&amp;gt; user-space spool&lt;br /&gt;
    -&amp;gt; collator&lt;br /&gt;
    -&amp;gt; downstream storage, analysis, policy, or reporting&lt;br /&gt;
&amp;lt;/pre&amp;gt;The probe’s job is bounded observation: copy raw bytes, attach factual context, assign ordering metadata, and emit records. The spool’s job is to drain kernel output quickly and forward it onward, relieving pressure on the kernel ring buffer channels. The collator’s job is reconstruction: reassemble fragments, check sequence continuity, place loss, resolve safe aggregations, and prepare externally useful event representations. &lt;br /&gt;
&lt;br /&gt;
== Core Fidelity Invariants ==&lt;br /&gt;
A central invariant is that captured bytes are preserved as bytes. The probe does not decode Unicode, detect commands, infer prompts, identify student intent, or decide where lines begin and end. Payload may contain terminal control sequences, partial multi-byte characters, shell output, pasted text, or fragments of larger observations - the data is handled the same at the instrument level regardless of its nature. Decoding and semantic interpretation happen downstream. &lt;br /&gt;
&lt;br /&gt;
Another central invariant is that fragmentation must be explicit. A single observed TTY occurrence may require multiple emitted records if the payload is too large for the bounded BPF record size. Fragmentation is not loss; it is just the transport representation of one observation. If bytes cannot be emitted, the missing portion must remain visible through loss/status reporting rather than being hidden by truncation or best-effort concatenation. &lt;br /&gt;
&lt;br /&gt;
Ordering is based on sequence accounting, not timestamps alone. CPU-local sequence numbers are useful now for validating local emission order, fragmentation behavior, and probe diagnostics. The target design adds monotonic per-stream sequence numbers for authoritative stream-level ordering and loss placement. Timestamps are still important, but they support wall-clock calibration and approximate correlation rather than serving as the primary proof of order. &lt;br /&gt;
&lt;br /&gt;
Loss accounting is a first-class part of the instrument. The design explicitly prefers capturing less data with accurate loss reporting over capturing more data whose completeness cannot be trusted. Loss can occur in the kernel probe, spool, collator, or downstream ingestion path; where possible, loss reports should identify the origin, affected sequence range, affected identity context, and lost record or byte counts. &lt;br /&gt;
&lt;br /&gt;
The auxiliary/status channel exists because status records are needed to interpret captured data, but must not contend for transport resources with observational records. Health/status records may describe loss, backpressure, saturation, calibration, drift, lifecycle transitions, verifier failures, spool pressure, collator pressure, or reconstruction ambiguity. These records describe the behavior and reliability of the instrument, not additional user activity. &lt;br /&gt;
&lt;br /&gt;
== Prototype Lineage ==&lt;br /&gt;
The instrument’s proof of concept is https://github.com/edurange/demo-bpf-tty-logger/tree/main, which demonstrates host-wide TTY capture using BPF kernel probes. That demo shows the basic idea: instead of attaching to one TTY device, it hooks kernel activity system-wide, captures raw buffers and context, and warns about feedback loops if the logger prints to a TTY it is also observing.&lt;br /&gt;
&lt;br /&gt;
The newer prototype, reflected in https://github.com/edurange/prototype-tty-bpf-instrument, moves beyond the early BCC/kprobe sketch toward the current tracepoint/CO-RE direction. It contains a kernel patch for tracepoints, BPF probe code, shared observation ABI, constants, event maps, CPU-local sequence state, and a user-space dump tool. That archive should be treated as the current implementation reference, while the formal design document remains the higher-level specification.&lt;br /&gt;
&lt;br /&gt;
== Design Boundaries and Non-Goals ==&lt;br /&gt;
The instrument is not meant to be a simple keylogger, even though it necessarily captures raw terminal bytes. It is an instrument for producing trustworthy observational records from kernel TTY activity under realistic multi-user workloads. The important work is preserving factual byte activity, identity context, ordering evidence, timing calibration, fragmentation metadata, and loss visibility without letting the kernel probe become an analyzer. The probe observes; the spool drains; and the collator reconstructs. Interpretation is the domain of downstream systems that consume the instrument’s output.&lt;br /&gt;
&lt;br /&gt;
To keep the components individually maintainable, it is crucial to preserve these boundaries. Tracepoints should stay minimal and justifiable to kernel maintainers. BPF code should stay bounded and observational. The spool should reduce pressure without inferring meaning. The collator should only aggregate when identity, sequence continuity, fragment completeness, and loss boundaries make aggregation safe. Storage policy, privacy enforcement, governance, and final analysis are outside the instrument itself unless a later design explicitly brings them in.&lt;br /&gt;
&lt;br /&gt;
= Related =&lt;br /&gt;
&lt;br /&gt;
== Project Charter ==&lt;br /&gt;
[[TTY BPF Instrument Project Charter]]&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=Message_Bus_Service&amp;diff=933</id>
		<title>Message Bus Service</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=Message_Bus_Service&amp;diff=933"/>
		<updated>2026-06-16T04:09:33Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Module repository and wiki documentation are pending. This is a placeholder topic page to index resources related to the development of the message bus service.&lt;br /&gt;
&lt;br /&gt;
= Related =&lt;br /&gt;
&lt;br /&gt;
== Project Charter ==&lt;br /&gt;
[[Message Bus Project Charter]]&lt;br /&gt;
&lt;br /&gt;
== Examples, Tutorials, Supporting Documents ==&lt;br /&gt;
[[Message Bus Live Coding Exercise]]&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=Message_Bus_Live_Coding_Exercise&amp;diff=932</id>
		<title>Message Bus Live Coding Exercise</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=Message_Bus_Live_Coding_Exercise&amp;diff=932"/>
		<updated>2026-06-16T04:09:15Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Introduction =&lt;br /&gt;
&lt;br /&gt;
== Audience and scope ==&lt;br /&gt;
&lt;br /&gt;
This exercise is intended for EDURange developers learning how to write event processors with the message bus.&lt;br /&gt;
&lt;br /&gt;
The packaged &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; modules are treated as read-only for this exercise. All new code belongs under a demonstration directory such as:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
workspace/&lt;br /&gt;
    mbus/&lt;br /&gt;
    aostore/&lt;br /&gt;
    demos/&lt;br /&gt;
        __init__.py&lt;br /&gt;
        event_processors/&lt;br /&gt;
            __init__.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The exercise uses two problem families.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;1. Shell and TTY event processing&#039;&#039;&#039;&lt;br /&gt;
** Send and receive messages through the bus.&lt;br /&gt;
** Capture and inspect message history.&lt;br /&gt;
** Represent high-level shell command events.&lt;br /&gt;
** Recreate the regex-based milestone role in a simplified form.&lt;br /&gt;
** Mock up TTY-derived observations such as keystroke timing.&lt;br /&gt;
* &#039;&#039;&#039;2. Graph event processing&#039;&#039;&#039;&lt;br /&gt;
** Represent graph edge relationships as source events.&lt;br /&gt;
** Derive direct path facts from edge relation events.&lt;br /&gt;
** Add feedback by deriving longer paths from known paths.&lt;br /&gt;
** Simulate nondeterministic processor scheduling while keeping source input fixed.&lt;br /&gt;
&lt;br /&gt;
The shell examples draw from EDURange&#039;s institutional knowledge. The graph examples provide a compact computer science model for safe feedback loops.&lt;br /&gt;
&lt;br /&gt;
== Running the examples ==&lt;br /&gt;
&lt;br /&gt;
Adjust paths to match the local checkout layout if necessary. Start an interpreter from the workspace root with project packages available.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
PYTHONPATH=&amp;quot;$PWD/mbus:$PWD/aostore:$PWD&amp;quot; python3.14&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
When module files are changed, restart the interpreter unless the group is deliberately practicing &amp;lt;code&amp;gt;importlib.reload()&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Create the demonstration package ==&lt;br /&gt;
&lt;br /&gt;
Before the first durable file edit, create the demonstration package directories:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir -p demos/event_processors&lt;br /&gt;
touch demos/__init__.py&lt;br /&gt;
touch demos/event_processors/__init__.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
== Important note about capture records ==&lt;br /&gt;
&lt;br /&gt;
The current demonstration works close to the low-level capture layer. As a result, it exposes &amp;lt;code&amp;gt;MessageSymbolRegistration&amp;lt;/code&amp;gt; records while building the temporary history helper.&lt;br /&gt;
&lt;br /&gt;
That is not the intended ordinary user experience. In a more complete release, higher-level capture and history infrastructure should handle symbol registration bookkeeping on behalf of processor authors. In this exercise, symbol registration records are visible because the demonstration history helper reconstructs readable messages from compact captured records directly.&lt;br /&gt;
&lt;br /&gt;
== Suggested group session flow ==&lt;br /&gt;
&lt;br /&gt;
; Session 1 - Bus and capture basics&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 1:&#039;&#039;&#039;&lt;br /&gt;
** Signal over the bus by hand.&lt;br /&gt;
** Inspect received messages.&lt;br /&gt;
** Inspect low-level captured records.&lt;br /&gt;
* &#039;&#039;&#039;Phase 2:&#039;&#039;&#039;&lt;br /&gt;
** Discuss why symbol registration appears in this temporary demo.&lt;br /&gt;
** Build the capture history helper.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1-3 hours (1-2 hours work, 0.5-1 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 2 - Shell command and regex milestones&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 3:&#039;&#039;&#039;&lt;br /&gt;
** Represent a shell command as a dictionary.&lt;br /&gt;
* &#039;&#039;&#039;Phase 4:&#039;&#039;&#039;&lt;br /&gt;
** Replace it with &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Phase 5:&#039;&#039;&#039;&lt;br /&gt;
** Manually evaluate regex milestone expressions.&lt;br /&gt;
* &#039;&#039;&#039;Phase 6:&#039;&#039;&#039;&lt;br /&gt;
** Implement &amp;lt;code&amp;gt;RegexMilestoneProcessor&amp;lt;/code&amp;gt;.&lt;br /&gt;
** Inspect source and analysis events in history.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3 hours (1.5-2.5 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 3 - TTY-derived observations&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 7:&#039;&#039;&#039;&lt;br /&gt;
** Create raw TTY read, write, and line events.&lt;br /&gt;
** Show why regex milestones need reconstructed shell command events.&lt;br /&gt;
* &#039;&#039;&#039;Phase 8:&#039;&#039;&#039;&lt;br /&gt;
** Implement keystroke timing.&lt;br /&gt;
** Discuss shell command reconstruction as the next derived-event problem (to be continued in Session 6 onward).&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1-2.5 hours (1-2 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 4 - Graph-derived facts&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 9:&#039;&#039;&#039;&lt;br /&gt;
** Define graph edges and paths.&lt;br /&gt;
* &#039;&#039;&#039;Phase 10:&#039;&#039;&#039;&lt;br /&gt;
** Add the demo-only nonblocking receive helper.&lt;br /&gt;
* &#039;&#039;&#039;Phase 11:&#039;&#039;&#039;&lt;br /&gt;
** Implement direct path derivation.&lt;br /&gt;
* &#039;&#039;&#039;Phase 12:&#039;&#039;&#039;&lt;br /&gt;
** Explore the extension rule by hand.&lt;br /&gt;
* &#039;&#039;&#039;Phase 13:&#039;&#039;&#039;&lt;br /&gt;
** Add path extension feedback.&lt;br /&gt;
** Discuss duplicate prevention.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3.5 hours (1.5-3 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 5 - Nondeterministic processor scheduling&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 14:&#039;&#039;&#039;&lt;br /&gt;
** Add the randomized processor runner.&lt;br /&gt;
* &#039;&#039;&#039;Phase 15:&#039;&#039;&#039;&lt;br /&gt;
** Keep source events fixed.&lt;br /&gt;
** Vary processor scheduling by seed.&lt;br /&gt;
** Compare final path facts across runs.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3.5 hours (1.5-3 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 6 - Follow-up refinements&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 16:&#039;&#039;&#039;&lt;br /&gt;
** Rebuild a graph index from history.&lt;br /&gt;
* &#039;&#039;&#039;Phase 17:&#039;&#039;&#039;&lt;br /&gt;
** Implement shell command reconstruction.&lt;br /&gt;
** Replace local Python payload assumptions with explicit payload projection formats when the bus format layer is ready for the exercise.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-4.5 hours (1.5-4 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
= Event Processor Demonstration Plan =&lt;br /&gt;
&lt;br /&gt;
== Phase 1: Signal over the bus by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Send one message through the direct bus and inspect what the receiver sees.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* A bus is useful only when something emits and something receives.&lt;br /&gt;
* Public message setup uses readable strings: topic, producer, and message type.&lt;br /&gt;
* The direct broker delivers local Python payloads in this demonstration.&lt;br /&gt;
* Capture must be attached before delivery in the current prototype.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Import the bus and capture interfaces:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.capture.sink import CaptureSink&lt;br /&gt;
from mbus.message.records import CapturedMessage&lt;br /&gt;
from mbus.message.symbols import MessageSymbolRegistration&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create a temporary capture sink in the interpreter. This sink stores whatever the bus sends to the capture layer:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
CapturedRecord = MessageSymbolRegistration | CapturedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class ListCaptureSink(CaptureSink):&lt;br /&gt;
    records: list[CapturedRecord] = field(default_factory=list)&lt;br /&gt;
&lt;br /&gt;
    def capture_symbol_registration(&lt;br /&gt;
        self,&lt;br /&gt;
        registration: MessageSymbolRegistration,&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self.records.append(registration)&lt;br /&gt;
&lt;br /&gt;
    def capture(self, message: CapturedMessage) -&amp;gt; None:&lt;br /&gt;
        self.records.append(message)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create the bus and attach capture:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = ListCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the empty capture sink:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
sink&lt;br /&gt;
sink.records&lt;br /&gt;
len(sink.records)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ListCaptureSink(records=[])&lt;br /&gt;
[]&lt;br /&gt;
0&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create a receiver. This describes the messages the receiver wants to hear:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the receiver and confirm that capture is still empty:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
type(receiver)&lt;br /&gt;
vars(receiver)&lt;br /&gt;
sink.records&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;class &#039;mbus.broker.direct._BrokerReceiver&#039;&amp;gt;&lt;br /&gt;
{&#039;_queue&#039;: ...}&lt;br /&gt;
[]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create an emitter. Registering the emitter gives the bus enough information to bind readable names to compact internal IDs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the captured registration records:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
sink.records&lt;br /&gt;
[type(record).__name__ for record in sink.records]&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    print(record)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[&#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;]&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;demo.signals&#039; ...)&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;ping&#039; ...)&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;demo-source&#039; ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit one message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;hello bus&amp;quot;})&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect capture before receiving:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
len(sink.records)&lt;br /&gt;
[type(record).__name__ for record in sink.records]&lt;br /&gt;
sink.records[-1]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
4&lt;br /&gt;
[&#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;CapturedMessage&#039;]&lt;br /&gt;
CapturedMessage(... payload={&#039;text&#039;: &#039;hello bus&#039;} ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
message = receiver.receive()&lt;br /&gt;
&lt;br /&gt;
message&lt;br /&gt;
message.msg_topic&lt;br /&gt;
message.msg_producer&lt;br /&gt;
message.msg_type&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ReceivedMessage(...)&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&#039;demo-source&#039;&lt;br /&gt;
&#039;ping&#039;&lt;br /&gt;
{&#039;text&#039;: &#039;hello bus&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the received message with the captured record:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured = sink.records[-1]&lt;br /&gt;
&lt;br /&gt;
captured&lt;br /&gt;
captured.msg_topic_id&lt;br /&gt;
captured.msg_type_id&lt;br /&gt;
captured.msg_producer_id&lt;br /&gt;
captured.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
CapturedMessage(...)&lt;br /&gt;
TopicID(...)&lt;br /&gt;
MessageTypeID(...)&lt;br /&gt;
ProducerID(...)&lt;br /&gt;
{&#039;text&#039;: &#039;hello bus&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The receiver gets the readable message view. The capture sink sees compact IDs and local payload data. The readable names are still available through symbol registration records, but manually reconstructing them is awkward.&lt;br /&gt;
&lt;br /&gt;
That awkwardness motivates the next phase.&lt;br /&gt;
&lt;br /&gt;
== Phase 2: Build a temporary capture history helper ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a small demonstration helper that turns captured records back into readable history messages.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Capture records are useful, but raw captured messages use compact IDs.&lt;br /&gt;
* A history helper can rebuild readable message history from registration records.&lt;br /&gt;
* This is demonstration infrastructure, not the final persistence/query API.&lt;br /&gt;
* The helper writes captured records into an in-memory &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; sequential unit.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration before the file edit&lt;br /&gt;
&lt;br /&gt;
List only the captured messages:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured_messages = []&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    if isinstance(record, CapturedMessage):&lt;br /&gt;
        captured_messages.append(record)&lt;br /&gt;
&lt;br /&gt;
captured_messages&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[CapturedMessage(... payload={&#039;text&#039;: &#039;hello bus&#039;} ...)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first captured message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured_message = captured_messages[0]&lt;br /&gt;
&lt;br /&gt;
captured_message.msg_topic_id&lt;br /&gt;
captured_message.msg_type_id&lt;br /&gt;
captured_message.msg_producer_id&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
TopicID(...)&lt;br /&gt;
MessageTypeID(...)&lt;br /&gt;
ProducerID(...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Manually reconstruct a topic name:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
topic_names = {}&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    if isinstance(record, MessageSymbolRegistration):&lt;br /&gt;
        if record.symbol_kind.name == &amp;quot;TOPIC&amp;quot;:&lt;br /&gt;
            topic_names[record.symbol_id] = record.symbol&lt;br /&gt;
&lt;br /&gt;
topic_names&lt;br /&gt;
topic_names[captured_message.msg_topic_id]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
{TopicID(...): &#039;demo.signals&#039;}&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This works, but repeating it by hand would distract from writing event processors.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/capture_history.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/capture_history.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Capture and history helpers for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from collections.abc import Iterable&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
&lt;br /&gt;
from aostore.sequentialunit.listsequentialunit import ListSequentialUnit&lt;br /&gt;
from mbus.capture.sink import CaptureSink&lt;br /&gt;
from mbus.message.records import CapturedMessage&lt;br /&gt;
from mbus.message.symbols import (&lt;br /&gt;
    MessageSymbolKind,&lt;br /&gt;
    MessageSymbolRegistration,&lt;br /&gt;
    MessageTypeID,&lt;br /&gt;
    ProducerID,&lt;br /&gt;
    TopicID,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
CapturedRecord = MessageSymbolRegistration | CapturedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class AOStoreCaptureSink(CaptureSink):&lt;br /&gt;
    records: ListSequentialUnit[CapturedRecord] = field(&lt;br /&gt;
        default_factory=ListSequentialUnit&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    def capture_symbol_registration(&lt;br /&gt;
        self,&lt;br /&gt;
        registration: MessageSymbolRegistration,&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self.records.append(registration)&lt;br /&gt;
&lt;br /&gt;
    def capture(self, message: CapturedMessage) -&amp;gt; None:&lt;br /&gt;
        self.records.append(message)&lt;br /&gt;
&lt;br /&gt;
    def all_records(self) -&amp;gt; tuple[CapturedRecord, ...]:&lt;br /&gt;
        records = self.records.sequential_read(0, len(self.records))&lt;br /&gt;
        result = tuple(records)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class HistoryMessage:&lt;br /&gt;
    msg_topic: str&lt;br /&gt;
    msg_type: str&lt;br /&gt;
    msg_producer: str&lt;br /&gt;
    payload: object&lt;br /&gt;
    bus_sequence: int&lt;br /&gt;
    topic_sequence: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class CaptureHistory:&lt;br /&gt;
    _records: tuple[CapturedRecord, ...]&lt;br /&gt;
    _topics: dict[TopicID, str]&lt;br /&gt;
    _types: dict[MessageTypeID, str]&lt;br /&gt;
    _producers: dict[ProducerID, str]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, records: Iterable[CapturedRecord]) -&amp;gt; None:&lt;br /&gt;
        self._records = tuple(records)&lt;br /&gt;
        self._topics = {}&lt;br /&gt;
        self._types = {}&lt;br /&gt;
        self._producers = {}&lt;br /&gt;
        self._index_symbols()&lt;br /&gt;
&lt;br /&gt;
    def _index_symbols(self) -&amp;gt; None:&lt;br /&gt;
        for record in self._records:&lt;br /&gt;
            if isinstance(record, MessageSymbolRegistration):&lt;br /&gt;
                self._record_symbol(record)&lt;br /&gt;
&lt;br /&gt;
    def _record_symbol(self, record: MessageSymbolRegistration) -&amp;gt; None:&lt;br /&gt;
        if record.symbol_kind is MessageSymbolKind.TOPIC:&lt;br /&gt;
            self._topics[record.symbol_id] = record.symbol&lt;br /&gt;
        elif record.symbol_kind is MessageSymbolKind.MSG_TYPE:&lt;br /&gt;
            self._types[record.symbol_id] = record.symbol&lt;br /&gt;
        elif record.symbol_kind is MessageSymbolKind.PRODUCER:&lt;br /&gt;
            self._producers[record.symbol_id] = record.symbol&lt;br /&gt;
&lt;br /&gt;
    def messages(self) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for record in self._records:&lt;br /&gt;
            if isinstance(record, CapturedMessage):&lt;br /&gt;
                message = self._decode_message(record)&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def by_topic(self, msg_topic: str) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for message in self.messages():&lt;br /&gt;
            if message.msg_topic == msg_topic:&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def since(self, bus_sequence: int) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for message in self.messages():&lt;br /&gt;
            if message.bus_sequence &amp;gt;= bus_sequence:&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def _decode_message(self, message: CapturedMessage) -&amp;gt; HistoryMessage:&lt;br /&gt;
        history_message = HistoryMessage(&lt;br /&gt;
            msg_topic=self._topics[message.msg_topic_id],&lt;br /&gt;
            msg_type=self._types[message.msg_type_id],&lt;br /&gt;
            msg_producer=self._producers[message.msg_producer_id],&lt;br /&gt;
            payload=message.payload,&lt;br /&gt;
            bus_sequence=message.bus_sequence,&lt;br /&gt;
            topic_sequence=message.topic_sequence,&lt;br /&gt;
        )&lt;br /&gt;
        return history_message&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;history helper&amp;quot;})&lt;br /&gt;
message = receiver.receive()&lt;br /&gt;
&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
history.messages()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[HistoryMessage(msg_topic=&#039;demo.signals&#039;, msg_type=&#039;ping&#039;, msg_producer=&#039;demo-source&#039;, payload={&#039;text&#039;: &#039;history helper&#039;}, ...)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the decoded history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history_message = history.messages()[0]&lt;br /&gt;
&lt;br /&gt;
history_message.msg_topic&lt;br /&gt;
history_message.msg_type&lt;br /&gt;
history_message.msg_producer&lt;br /&gt;
history_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&#039;ping&#039;&lt;br /&gt;
&#039;demo-source&#039;&lt;br /&gt;
{&#039;text&#039;: &#039;history helper&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 3: Explore shell command data by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Identify the high-level event shape needed by a regex milestone processor.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The old milestone strategy operated on command-level records.&lt;br /&gt;
* A command-level record includes context such as host and working directory.&lt;br /&gt;
* It also includes shell input and shell output.&lt;br /&gt;
* Raw TTY events do not directly have this shape; reconstruction comes later.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Represent a shell command as a dictionary:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = {&lt;br /&gt;
    &amp;quot;session_id&amp;quot;: &amp;quot;session-1&amp;quot;,&lt;br /&gt;
    &amp;quot;command_index&amp;quot;: 0,&lt;br /&gt;
    &amp;quot;host&amp;quot;: &amp;quot;alpha&amp;quot;,&lt;br /&gt;
    &amp;quot;cwd&amp;quot;: &amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    &amp;quot;input_text&amp;quot;: &amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    &amp;quot;output_text&amp;quot;: &amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    &amp;quot;started_at_ns&amp;quot;: 1000,&lt;br /&gt;
    &amp;quot;ended_at_ns&amp;quot;: 2000,&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the fields used by regex milestone checks:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command[&amp;quot;host&amp;quot;]&lt;br /&gt;
command[&amp;quot;cwd&amp;quot;]&lt;br /&gt;
command[&amp;quot;input_text&amp;quot;]&lt;br /&gt;
command[&amp;quot;output_text&amp;quot;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;alpha&#039;&lt;br /&gt;
&#039;/home/student&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Try an accidental wrong key:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command[&amp;quot;working_directory&amp;quot;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeyError: &#039;working_directory&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The dictionary contains the information, but it does not document the shape of the event. A typo is discovered only when the field is used. A named event class makes the processor code easier to read and easier to explain.&lt;br /&gt;
&lt;br /&gt;
== Phase 4: Persist the event record classes ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create reusable payload classes for the TTY, shell command, and regex milestone examples.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/events.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/events.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Payload records for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from dataclasses import dataclass&lt;br /&gt;
from typing import Literal&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class TTYReadEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    data: bytes&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class TTYWriteEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    data: bytes&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class LineDisciplineEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    line: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class KeystrokeTimingEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    previous_timestamp_ns: int | None&lt;br /&gt;
    delta_ns: int | None&lt;br /&gt;
    text: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class ShellCommandEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    command_index: int&lt;br /&gt;
    host: str&lt;br /&gt;
    cwd: str&lt;br /&gt;
    input_text: str&lt;br /&gt;
    output_text: str&lt;br /&gt;
    started_at_ns: int&lt;br /&gt;
    ended_at_ns: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
RegexMilestoneField = Literal[&lt;br /&gt;
    &amp;quot;host&amp;quot;,&lt;br /&gt;
    &amp;quot;cwd&amp;quot;,&lt;br /&gt;
    &amp;quot;input_text&amp;quot;,&lt;br /&gt;
    &amp;quot;output_text&amp;quot;,&lt;br /&gt;
]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneExpression:&lt;br /&gt;
    name: str&lt;br /&gt;
    field: RegexMilestoneField&lt;br /&gt;
    pattern: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneResult:&lt;br /&gt;
    expression_name: str&lt;br /&gt;
    field: RegexMilestoneField&lt;br /&gt;
    matched: bool&lt;br /&gt;
    match_text: str | None = None&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneAnalysisEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    command_index: int&lt;br /&gt;
    results: tuple[RegexMilestoneResult, ...]&lt;br /&gt;
    matched_count: int&lt;br /&gt;
    expression_count: int&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.events import ShellCommandEvent&lt;br /&gt;
&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
command&lt;br /&gt;
command.host&lt;br /&gt;
command.cwd&lt;br /&gt;
command.input_text&lt;br /&gt;
command.output_text&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ShellCommandEvent(session_id=&#039;session-1&#039;, command_index=0, ...)&lt;br /&gt;
&#039;alpha&#039;&lt;br /&gt;
&#039;/home/student&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The event shape is now explicit. Other code can depend on a named &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; instead of assuming that a loose dictionary has the expected fields.&lt;br /&gt;
&lt;br /&gt;
== Phase 5: Evaluate regex milestones manually ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Recreate the core milestone check in a small, transparent form.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* A regex milestone expression names one check against one shell command field.&lt;br /&gt;
* The field may be command input, command output, working directory, or host context.&lt;br /&gt;
* The result records whether the expression matched and, when available, the matched text.&lt;br /&gt;
* Manual evaluation is useful for learning the shape of the problem before writing a processor.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Import regular expressions and the payload classes:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create one command event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create three milestone expressions:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
expressions = (&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;used-ls&amp;quot;,&lt;br /&gt;
        field=&amp;quot;input_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;\bls\b&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;saw-notes&amp;quot;,&lt;br /&gt;
        field=&amp;quot;output_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;notes\.txt&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;home-directory&amp;quot;,&lt;br /&gt;
        field=&amp;quot;cwd&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;^/home/student$&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Evaluate one expression:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
expression = expressions[0]&lt;br /&gt;
text = getattr(command, expression.field)&lt;br /&gt;
match = re.search(expression.pattern, text)&lt;br /&gt;
&lt;br /&gt;
expression.name&lt;br /&gt;
expression.field&lt;br /&gt;
text&lt;br /&gt;
match.group(0)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;used-ls&#039;&lt;br /&gt;
&#039;input_text&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;ls&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Evaluate all expressions:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
manual_results = []&lt;br /&gt;
&lt;br /&gt;
for expression in expressions:&lt;br /&gt;
    text = getattr(command, expression.field)&lt;br /&gt;
    match = re.search(expression.pattern, text)&lt;br /&gt;
    manual_results.append((expression.name, match is not None))&lt;br /&gt;
&lt;br /&gt;
manual_results&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(&#039;used-ls&#039;, True), (&#039;saw-notes&#039;, True), (&#039;home-directory&#039;, True)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
Manual evaluation is simple for one command, but the loop is already event processor logic. The next phase preserves the loop in a reusable processor.&lt;br /&gt;
&lt;br /&gt;
== Phase 6: Persist the RegexMilestone processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a processor that receives shell command events and emits regex milestone analysis events.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/regex_milestones.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/regex_milestones.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Regex milestone processor for shell command events.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneAnalysisEvent,&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    RegexMilestoneResult,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class RegexMilestoneProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _expressions: tuple[RegexMilestoneExpression, ...]&lt;br /&gt;
&lt;br /&gt;
    def __init__(&lt;br /&gt;
        self,&lt;br /&gt;
        bus: DirectMessageBus,&lt;br /&gt;
        expressions: tuple[RegexMilestoneExpression, ...],&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.shell.command&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;shell-reconstructor&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;shell-command&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.shell.regex_milestone&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;regex-milestone-processor&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;regex-milestone-analysis&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._expressions = expressions&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; RegexMilestoneAnalysisEvent:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        command = message.payload&lt;br /&gt;
&lt;br /&gt;
        if not isinstance(command, ShellCommandEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected ShellCommandEvent, got {type(command).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        analysis = analyze_regex_milestones(command, self._expressions)&lt;br /&gt;
        self._emitter.emit(analysis)&lt;br /&gt;
        return analysis&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def analyze_regex_milestones(&lt;br /&gt;
    command: ShellCommandEvent,&lt;br /&gt;
    expressions: tuple[RegexMilestoneExpression, ...],&lt;br /&gt;
) -&amp;gt; RegexMilestoneAnalysisEvent:&lt;br /&gt;
    results = []&lt;br /&gt;
    for expression in expressions:&lt;br /&gt;
        result = evaluate_regex_milestone(command, expression)&lt;br /&gt;
        results.append(result)&lt;br /&gt;
&lt;br /&gt;
    matched_count = sum(result.matched for result in results)&lt;br /&gt;
    analysis = RegexMilestoneAnalysisEvent(&lt;br /&gt;
        session_id=command.session_id,&lt;br /&gt;
        command_index=command.command_index,&lt;br /&gt;
        results=tuple(results),&lt;br /&gt;
        matched_count=matched_count,&lt;br /&gt;
        expression_count=len(expressions),&lt;br /&gt;
    )&lt;br /&gt;
    return analysis&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def evaluate_regex_milestone(&lt;br /&gt;
    command: ShellCommandEvent,&lt;br /&gt;
    expression: RegexMilestoneExpression,&lt;br /&gt;
) -&amp;gt; RegexMilestoneResult:&lt;br /&gt;
    text = getattr(command, expression.field)&lt;br /&gt;
    match = re.search(expression.pattern, text)&lt;br /&gt;
    match_text = None&lt;br /&gt;
&lt;br /&gt;
    if match is not None:&lt;br /&gt;
        match_text = match.group(0)&lt;br /&gt;
&lt;br /&gt;
    result = RegexMilestoneResult(&lt;br /&gt;
        expression_name=expression.name,&lt;br /&gt;
        field=expression.field,&lt;br /&gt;
        matched=match is not None,&lt;br /&gt;
        match_text=match_text,&lt;br /&gt;
    )&lt;br /&gt;
    return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then set up the bus:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.regex_milestones import RegexMilestoneProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Subscribe to the analysis topic before running the processor:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.shell.regex_milestone&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;regex-milestone-processor&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;regex-milestone-analysis&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create the shell command emitter and processor:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.shell.command&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;shell-reconstructor&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;shell-command&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
expressions = (&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;used-ls&amp;quot;,&lt;br /&gt;
        field=&amp;quot;input_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;\bls\b&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;saw-notes&amp;quot;,&lt;br /&gt;
        field=&amp;quot;output_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;notes\.txt&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;home-directory&amp;quot;,&lt;br /&gt;
        field=&amp;quot;cwd&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;^/home/student$&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = RegexMilestoneProcessor(bus, expressions)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit a shell command event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
command_emitter.emit(command)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the command:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis = processor.process_one()&lt;br /&gt;
analysis&lt;br /&gt;
analysis.results&lt;br /&gt;
analysis.matched_count&lt;br /&gt;
analysis.expression_count&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
RegexMilestoneAnalysisEvent(... matched_count=3, expression_count=3)&lt;br /&gt;
(RegexMilestoneResult(...), RegexMilestoneResult(...), RegexMilestoneResult(...))&lt;br /&gt;
3&lt;br /&gt;
3&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the derived analysis event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis_message = analysis_receiver.receive()&lt;br /&gt;
&lt;br /&gt;
analysis_message.msg_topic&lt;br /&gt;
analysis_message.msg_type&lt;br /&gt;
analysis_message.msg_producer&lt;br /&gt;
analysis_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;demo.shell.regex_milestone&#039;&lt;br /&gt;
&#039;regex-milestone-analysis&#039;&lt;br /&gt;
&#039;regex-milestone-processor&#039;&lt;br /&gt;
RegexMilestoneAnalysisEvent(... matched_count=3, expression_count=3)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
messages = history.messages()&lt;br /&gt;
&lt;br /&gt;
for message in messages:&lt;br /&gt;
    print(message.bus_sequence, message.msg_topic, message.msg_type)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
0 demo.shell.command shell-command&lt;br /&gt;
1 demo.shell.regex_milestone regex-milestone-analysis&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The regex milestone processor consumes one event and emits another. The derived analysis event is available to any later subscriber. History contains both the source command event and the derived analysis event.&lt;br /&gt;
&lt;br /&gt;
== Phase 7: Explore why shell reconstruction is separate ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Show that raw TTY observations are not the same thing as high-level shell command events.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Regex milestones should inspect high-level command events.&lt;br /&gt;
* TTY reads, TTY writes, and line-discipline lines are lower-level observations.&lt;br /&gt;
* A shell command event is reconstructed by correlating those observations.&lt;br /&gt;
* Keeping reconstruction separate prevents the regex milestone processor from becoming a large TTY parser.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Create a few low-level observations:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    LineDisciplineEvent,&lt;br /&gt;
    TTYReadEvent,&lt;br /&gt;
    TTYWriteEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_1 = TTYReadEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=0,&lt;br /&gt;
    timestamp_ns=1000,&lt;br /&gt;
    data=b&amp;quot;l&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_2 = TTYReadEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=1,&lt;br /&gt;
    timestamp_ns=1100,&lt;br /&gt;
    data=b&amp;quot;s&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
line = LineDisciplineEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=2,&lt;br /&gt;
    timestamp_ns=1200,&lt;br /&gt;
    line=&amp;quot;ls\n&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
write = TTYWriteEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=3,&lt;br /&gt;
    timestamp_ns=1600,&lt;br /&gt;
    data=b&amp;quot;notes.txt\n&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the raw pieces:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_1.data&lt;br /&gt;
read_2.data&lt;br /&gt;
line.line&lt;br /&gt;
write.data&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
b&#039;l&#039;&lt;br /&gt;
b&#039;s&#039;&lt;br /&gt;
&#039;ls\n&#039;&lt;br /&gt;
b&#039;notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Assemble command fields manually:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
input_text = line.line&lt;br /&gt;
output_text = write.data.decode(&amp;quot;utf-8&amp;quot;, errors=&amp;quot;replace&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
input_text&lt;br /&gt;
output_text&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;ls\n&#039;&lt;br /&gt;
&#039;notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The useful command event is derived from several lower-level observations. A later shell reconstructor can subscribe to line-discipline events, query recent reads/writes/timing observations, and emit &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; records.&lt;br /&gt;
&lt;br /&gt;
== Phase 8: Add keystroke timing as a smaller TTY-derived processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a simple stateful processor before attempting full shell reconstruction.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Timing requires comparing a TTY read with the previous read in the same session.&lt;br /&gt;
* A derived timing event makes the result explicit.&lt;br /&gt;
* Other processors can consume timing events instead of recalculating deltas.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration before the file edit&lt;br /&gt;
&lt;br /&gt;
Calculate timing manually from the two read events:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_events = [read_1, read_2]&lt;br /&gt;
previous_timestamp = None&lt;br /&gt;
timing_rows = []&lt;br /&gt;
&lt;br /&gt;
for event in read_events:&lt;br /&gt;
    delta = None&lt;br /&gt;
    if previous_timestamp is not None:&lt;br /&gt;
        delta = event.timestamp_ns - previous_timestamp&lt;br /&gt;
    timing_rows.append((event.source_index, previous_timestamp, delta))&lt;br /&gt;
    previous_timestamp = event.timestamp_ns&lt;br /&gt;
&lt;br /&gt;
timing_rows&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, None, None), (1, 1000, 100)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/keystroke_timing.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/keystroke_timing.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Keystroke timing processor for TTY read events.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    KeystrokeTimingEvent,&lt;br /&gt;
    TTYReadEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class KeystrokeTimingProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _last_timestamp_by_session: dict[str, int]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.tty.read&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;tty-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;tty-read&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.keystroke.timing&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._last_timestamp_by_session = {}&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; KeystrokeTimingEvent:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        event = message.payload&lt;br /&gt;
&lt;br /&gt;
        if not isinstance(event, TTYReadEvent):&lt;br /&gt;
            raise TypeError(f&amp;quot;expected TTYReadEvent, got {type(event).__name__}&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
        previous_timestamp = self._last_timestamp_by_session.get(&lt;br /&gt;
            event.session_id&lt;br /&gt;
        )&lt;br /&gt;
        delta = None&lt;br /&gt;
&lt;br /&gt;
        if previous_timestamp is not None:&lt;br /&gt;
            delta = event.timestamp_ns - previous_timestamp&lt;br /&gt;
&lt;br /&gt;
        self._last_timestamp_by_session[event.session_id] = event.timestamp_ns&lt;br /&gt;
&lt;br /&gt;
        text = event.data.decode(&amp;quot;utf-8&amp;quot;, errors=&amp;quot;replace&amp;quot;)&lt;br /&gt;
        timing = KeystrokeTimingEvent(&lt;br /&gt;
            session_id=event.session_id,&lt;br /&gt;
            source_index=event.source_index,&lt;br /&gt;
            timestamp_ns=event.timestamp_ns,&lt;br /&gt;
            previous_timestamp_ns=previous_timestamp,&lt;br /&gt;
            delta_ns=delta,&lt;br /&gt;
            text=text,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter.emit(timing)&lt;br /&gt;
        return timing&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
from demos.event_processors.events import TTYReadEvent&lt;br /&gt;
from demos.event_processors.keystroke_timing import KeystrokeTimingProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
timing_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.keystroke.timing&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.tty.read&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;tty-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;tty-read&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = KeystrokeTimingProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit reads and process them:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_emitter.emit(&lt;br /&gt;
    TTYReadEvent(&lt;br /&gt;
        session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
        source_index=0,&lt;br /&gt;
        timestamp_ns=1000,&lt;br /&gt;
        data=b&amp;quot;l&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_emitter.emit(&lt;br /&gt;
    TTYReadEvent(&lt;br /&gt;
        session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
        source_index=1,&lt;br /&gt;
        timestamp_ns=1100,&lt;br /&gt;
        data=b&amp;quot;s&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor.process_one()&lt;br /&gt;
processor.process_one()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeystrokeTimingEvent(... previous_timestamp_ns=None, delta_ns=None, text=&#039;l&#039;)&lt;br /&gt;
KeystrokeTimingEvent(... previous_timestamp_ns=1000, delta_ns=100, text=&#039;s&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive timing events:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
timing_receiver.receive().payload&lt;br /&gt;
timing_receiver.receive().payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeystrokeTimingEvent(... source_index=0 ...)&lt;br /&gt;
KeystrokeTimingEvent(... source_index=1 ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 9: Introduce graph events ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Switch to a small graph problem that can demonstrate feedback loops safely.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* An edge is a source fact.&lt;br /&gt;
* A path is a derived fact.&lt;br /&gt;
* A direct edge implies a direct path.&lt;br /&gt;
* Longer paths can be derived later from known paths and edges.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_events.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/graph_events.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Graph event payloads for message bus demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from dataclasses import dataclass&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class EdgeDeclaredEvent:&lt;br /&gt;
    graph_id: str&lt;br /&gt;
    source: str&lt;br /&gt;
    target: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class PathKnownEvent:&lt;br /&gt;
    graph_id: str&lt;br /&gt;
    source: str&lt;br /&gt;
    target: str&lt;br /&gt;
    hop_count: int&lt;br /&gt;
&lt;br /&gt;
    def fact_key(self) -&amp;gt; tuple[str, str, str]:&lt;br /&gt;
        result = (self.graph_id, self.source, self.target)&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge = EdgeDeclaredEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;A&amp;quot;,&lt;br /&gt;
    target=&amp;quot;B&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path = PathKnownEvent(&lt;br /&gt;
    graph_id=edge.graph_id,&lt;br /&gt;
    source=edge.source,&lt;br /&gt;
    target=edge.target,&lt;br /&gt;
    hop_count=1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge&lt;br /&gt;
path&lt;br /&gt;
path.fact_key()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
EdgeDeclaredEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;)&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;, hop_count=1)&lt;br /&gt;
(&#039;demo-graph&#039;, &#039;A&#039;, &#039;B&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 10: Create a demo-only nonblocking receive helper ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Prepare for processor scheduling without editing &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;Receiver.receive()&amp;lt;/code&amp;gt; blocks until a message is available.&lt;br /&gt;
* A scheduler needs to try a processor and move on if no message is waiting.&lt;br /&gt;
* The current package does not expose a public nonblocking receiver method.&lt;br /&gt;
* This helper is local demonstration code. It can be replaced by a real public API later.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/demo_receive.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/demo_receive.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Demo-only helpers for receiver inspection and scheduling.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from queue import Empty&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.endpoints import Receiver&lt;br /&gt;
from mbus.message.records import ReceivedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class DemoReceiverError(RuntimeError):&lt;br /&gt;
    pass&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def try_receive(receiver: Receiver) -&amp;gt; ReceivedMessage | None:&lt;br /&gt;
    queue = getattr(receiver, &amp;quot;_queue&amp;quot;, None)&lt;br /&gt;
&lt;br /&gt;
    if queue is None:&lt;br /&gt;
        raise DemoReceiverError(&lt;br /&gt;
            &amp;quot;demo try_receive requires the current direct broker receiver&amp;quot;&lt;br /&gt;
        )&lt;br /&gt;
&lt;br /&gt;
    try:&lt;br /&gt;
        message = queue.get_nowait()&lt;br /&gt;
    except Empty:&lt;br /&gt;
        message = None&lt;br /&gt;
&lt;br /&gt;
    return message&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.demo_receive import try_receive&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
try_receive(receiver)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
None&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Now emit a message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;demo nonblocking receive&amp;quot;})&lt;br /&gt;
message = try_receive(receiver)&lt;br /&gt;
&lt;br /&gt;
message&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ReceivedMessage(...)&lt;br /&gt;
{&#039;text&#039;: &#039;demo nonblocking receive&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 11: Create the direct path processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Implement the graph rule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
edge(A, B) -&amp;gt; path(A, B)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The source emits edge events.&lt;br /&gt;
* The direct path processor emits derived path events.&lt;br /&gt;
* Other processors can subscribe to derived path events.&lt;br /&gt;
* The processor can be run by hand or by a scheduler.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_reachability.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/graph_reachability.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Graph reachability processors for message bus demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.demo_receive import try_receive&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class DirectPathProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _known_paths: set[tuple[str, str, str]]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._known_paths = set()&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; PathKnownEvent | None:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        result = self._process_payload(message.payload)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
    def process_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            self._process_payload(message.payload)&lt;br /&gt;
            work_count = 1&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def _process_payload(self, payload: object) -&amp;gt; PathKnownEvent | None:&lt;br /&gt;
        if not isinstance(payload, EdgeDeclaredEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected EdgeDeclaredEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        path = PathKnownEvent(&lt;br /&gt;
            graph_id=payload.graph_id,&lt;br /&gt;
            source=payload.source,&lt;br /&gt;
            target=payload.target,&lt;br /&gt;
            hop_count=1,&lt;br /&gt;
        )&lt;br /&gt;
&lt;br /&gt;
        if path.fact_key() not in self._known_paths:&lt;br /&gt;
            self._known_paths.add(path.fact_key())&lt;br /&gt;
            self._emitter.emit(path)&lt;br /&gt;
            result = path&lt;br /&gt;
        else:&lt;br /&gt;
            result = None&lt;br /&gt;
&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
from demos.event_processors.graph_events import EdgeDeclaredEvent&lt;br /&gt;
from demos.event_processors.graph_reachability import DirectPathProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
path_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = DirectPathProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Call the processor before any edge exists:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
0&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit one edge:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;A&amp;quot;,&lt;br /&gt;
        target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the edge:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the derived path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_message = path_receiver.receive()&lt;br /&gt;
path_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;, hop_count=1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 12: Explore the path extension rule by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Understand the feedback rule before implementing it.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* If &amp;lt;code&amp;gt;A&amp;lt;/code&amp;gt; reaches &amp;lt;code&amp;gt;B&amp;lt;/code&amp;gt;, and there is an edge from &amp;lt;code&amp;gt;B&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;C&amp;lt;/code&amp;gt;, then &amp;lt;code&amp;gt;A&amp;lt;/code&amp;gt; reaches &amp;lt;code&amp;gt;C&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Derived path facts can lead to more derived path facts.&lt;br /&gt;
* Duplicate prevention keeps the feedback loop from repeating the same fact forever.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_bc = EdgeDeclaredEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;B&amp;quot;,&lt;br /&gt;
    target=&amp;quot;C&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path_ab = PathKnownEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;A&amp;quot;,&lt;br /&gt;
    target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    hop_count=1,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Check whether the path and edge connect:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_ab.target&lt;br /&gt;
edge_bc.source&lt;br /&gt;
path_ab.target == edge_bc.source&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;B&#039;&lt;br /&gt;
&#039;B&#039;&lt;br /&gt;
True&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Derive a new path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_ac = PathKnownEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=path_ab.source,&lt;br /&gt;
    target=edge_bc.target,&lt;br /&gt;
    hop_count=path_ab.hop_count + 1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path_ac&lt;br /&gt;
path_ac.fact_key()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;C&#039;, hop_count=2)&lt;br /&gt;
(&#039;demo-graph&#039;, &#039;A&#039;, &#039;C&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Manually prevent duplicate path facts:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
known_paths = set()&lt;br /&gt;
known_paths.add(path_ab.fact_key())&lt;br /&gt;
&lt;br /&gt;
path_ac.fact_key() in known_paths&lt;br /&gt;
&lt;br /&gt;
if path_ac.fact_key() not in known_paths:&lt;br /&gt;
    known_paths.add(path_ac.fact_key())&lt;br /&gt;
&lt;br /&gt;
known_paths&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
False&lt;br /&gt;
{(&#039;demo-graph&#039;, &#039;A&#039;, &#039;B&#039;), (&#039;demo-graph&#039;, &#039;A&#039;, &#039;C&#039;)}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 13: Add the path extension processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Implement the feedback rule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
path(A, B) + edge(B, C) -&amp;gt; path(A, C)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The extension processor listens to edge events and path events.&lt;br /&gt;
* It emits new path events when a known path can be extended.&lt;br /&gt;
* It also listens to its own output because an extended path may be extendable again.&lt;br /&gt;
* This is a controlled feedback loop.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Append this class to:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_reachability.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
class PathExtensionProcessor:&lt;br /&gt;
    _edge_receiver: Receiver&lt;br /&gt;
    _direct_path_receiver: Receiver&lt;br /&gt;
    _extension_path_receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _edges: set[tuple[str, str, str]]&lt;br /&gt;
    _paths: dict[tuple[str, str, str], PathKnownEvent]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._edge_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._direct_path_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._extension_path_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._edges = set()&lt;br /&gt;
        self._paths = {}&lt;br /&gt;
&lt;br /&gt;
    def process_edge_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._edge_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_edge_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def process_direct_path_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._direct_path_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_path_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def process_extension_path_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._extension_path_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_path_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def _process_edge_payload(&lt;br /&gt;
        self,&lt;br /&gt;
        payload: object,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        if not isinstance(payload, EdgeDeclaredEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected EdgeDeclaredEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        edge = payload&lt;br /&gt;
        self._edges.add((edge.graph_id, edge.source, edge.target))&lt;br /&gt;
        paths = self._derive_from_new_edge(edge)&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _process_path_payload(&lt;br /&gt;
        self,&lt;br /&gt;
        payload: object,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        if not isinstance(payload, PathKnownEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected PathKnownEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        path = payload&lt;br /&gt;
        self._paths[path.fact_key()] = path&lt;br /&gt;
        paths = self._derive_from_new_path(path)&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _derive_from_new_edge(&lt;br /&gt;
        self,&lt;br /&gt;
        edge: EdgeDeclaredEvent,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        paths = []&lt;br /&gt;
&lt;br /&gt;
        for known_path in self._paths.values():&lt;br /&gt;
            if known_path.graph_id != edge.graph_id:&lt;br /&gt;
                continue&lt;br /&gt;
            if known_path.target != edge.source:&lt;br /&gt;
                continue&lt;br /&gt;
&lt;br /&gt;
            next_path = PathKnownEvent(&lt;br /&gt;
                graph_id=edge.graph_id,&lt;br /&gt;
                source=known_path.source,&lt;br /&gt;
                target=edge.target,&lt;br /&gt;
                hop_count=known_path.hop_count + 1,&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
            if self._emit_if_new(next_path):&lt;br /&gt;
                paths.append(next_path)&lt;br /&gt;
&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _derive_from_new_path(&lt;br /&gt;
        self,&lt;br /&gt;
        path: PathKnownEvent,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        paths = []&lt;br /&gt;
&lt;br /&gt;
        for graph_id, edge_source, edge_target in self._edges:&lt;br /&gt;
            if graph_id != path.graph_id:&lt;br /&gt;
                continue&lt;br /&gt;
            if edge_source != path.target:&lt;br /&gt;
                continue&lt;br /&gt;
&lt;br /&gt;
            next_path = PathKnownEvent(&lt;br /&gt;
                graph_id=path.graph_id,&lt;br /&gt;
                source=path.source,&lt;br /&gt;
                target=edge_target,&lt;br /&gt;
                hop_count=path.hop_count + 1,&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
            if self._emit_if_new(next_path):&lt;br /&gt;
                paths.append(next_path)&lt;br /&gt;
&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _emit_if_new(self, path: PathKnownEvent) -&amp;gt; bool:&lt;br /&gt;
        if path.fact_key() in self._paths:&lt;br /&gt;
            result = False&lt;br /&gt;
        else:&lt;br /&gt;
            self._paths[path.fact_key()] = path&lt;br /&gt;
            self._emitter.emit(path)&lt;br /&gt;
            result = True&lt;br /&gt;
&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_events import EdgeDeclaredEvent&lt;br /&gt;
from demos.event_processors.graph_reachability import (&lt;br /&gt;
    DirectPathProcessor,&lt;br /&gt;
    PathExtensionProcessor,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
extension_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
direct_processor = DirectPathProcessor(bus)&lt;br /&gt;
extension_processor = PathExtensionProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit two edges in fixed order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;A&amp;quot;,&lt;br /&gt;
        target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;B&amp;quot;,&lt;br /&gt;
        target=&amp;quot;C&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the direct paths:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
direct_processor.process_available()&lt;br /&gt;
direct_processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Let the extension processor learn the edges:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
extension_processor.process_edge_available()&lt;br /&gt;
extension_processor.process_edge_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Let the extension processor learn the direct paths:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
extension_processor.process_direct_path_available()&lt;br /&gt;
extension_processor.process_direct_path_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The second call may emit the extended &amp;lt;code&amp;gt;A -&amp;gt; C&amp;lt;/code&amp;gt; path after the processor has seen both direct paths and both edges.&lt;br /&gt;
&lt;br /&gt;
Receive the extended path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
message = extension_receiver.receive()&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;C&#039;, hop_count=2)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect captured graph path history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
path_messages = history.by_topic(&amp;quot;demo.graph.path&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
for message in path_messages:&lt;br /&gt;
    path = message.payload&lt;br /&gt;
    print(&lt;br /&gt;
        message.bus_sequence,&lt;br /&gt;
        message.msg_producer,&lt;br /&gt;
        path.source,&lt;br /&gt;
        path.target,&lt;br /&gt;
        path.hop_count,&lt;br /&gt;
    )&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
... direct-path A B 1&lt;br /&gt;
... direct-path B C 1&lt;br /&gt;
... path-extension A C 2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 14: Add a randomized processor scheduler ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Simulate concurrency effects without changing source input order.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The source will emit the same edge events in the same order every run.&lt;br /&gt;
* The scheduler varies which processor gets a chance to run first.&lt;br /&gt;
* This simulates uncertainty from processing time, scheduling priority, startup timing, or transmission delay.&lt;br /&gt;
* The graph example should converge to the same final path facts even when processor scheduling, and hence the order of intermediate derived processor events, differs.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/runners.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/runners.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Small processor runners for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from collections.abc import Callable&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
from random import Random&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class ProcessorStep:&lt;br /&gt;
    name: str&lt;br /&gt;
    run: Callable[[], int]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class RandomProcessorRunner:&lt;br /&gt;
    steps: tuple[ProcessorStep, ...]&lt;br /&gt;
    seed: int&lt;br /&gt;
    max_rounds: int = 30&lt;br /&gt;
    trace: list[tuple[int, str, int]] = field(default_factory=list)&lt;br /&gt;
&lt;br /&gt;
    def run_until_quiet(self) -&amp;gt; list[tuple[int, str, int]]:&lt;br /&gt;
        rng = Random(self.seed)&lt;br /&gt;
&lt;br /&gt;
        for round_index in range(self.max_rounds):&lt;br /&gt;
            work_count = self._run_round(rng, round_index)&lt;br /&gt;
&lt;br /&gt;
            if work_count == 0:&lt;br /&gt;
                break&lt;br /&gt;
&lt;br /&gt;
        result = list(self.trace)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
    def _run_round(self, rng: Random, round_index: int) -&amp;gt; int:&lt;br /&gt;
        steps = list(self.steps)&lt;br /&gt;
        rng.shuffle(steps)&lt;br /&gt;
        round_work_count = 0&lt;br /&gt;
&lt;br /&gt;
        for step in steps:&lt;br /&gt;
            step_work_count = step.run()&lt;br /&gt;
            self.trace.append((round_index, step.name, step_work_count))&lt;br /&gt;
            round_work_count += step_work_count&lt;br /&gt;
&lt;br /&gt;
        return round_work_count&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then inspect how a runner can shuffle placeholder steps:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.runners import (&lt;br /&gt;
    ProcessorStep,&lt;br /&gt;
    RandomProcessorRunner,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
calls = []&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def first_step() -&amp;gt; int:&lt;br /&gt;
    calls.append(&amp;quot;first&amp;quot;)&lt;br /&gt;
    return 0&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def second_step() -&amp;gt; int:&lt;br /&gt;
    calls.append(&amp;quot;second&amp;quot;)&lt;br /&gt;
    return 0&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
runner = RandomProcessorRunner(&lt;br /&gt;
    steps=(&lt;br /&gt;
        ProcessorStep(name=&amp;quot;first&amp;quot;, run=first_step),&lt;br /&gt;
        ProcessorStep(name=&amp;quot;second&amp;quot;, run=second_step),&lt;br /&gt;
    ),&lt;br /&gt;
    seed=1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
runner.run_until_quiet()&lt;br /&gt;
calls&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, &#039;...&#039;, 0), (0, &#039;...&#039;, 0)]&lt;br /&gt;
[&#039;...&#039;, &#039;...&#039;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 15: Run the graph example with randomized scheduling ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Run the same graph input under different processor schedules and compare the final path facts.&lt;br /&gt;
&lt;br /&gt;
; Interpreter setup&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_reachability import (&lt;br /&gt;
    DirectPathProcessor,&lt;br /&gt;
    PathExtensionProcessor,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.runners import (&lt;br /&gt;
    ProcessorStep,&lt;br /&gt;
    RandomProcessorRunner,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Define a helper function in the interpreter:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
def run_graph_demo(seed: int) -&amp;gt; tuple[&lt;br /&gt;
    list[tuple[int, str, int]],&lt;br /&gt;
    list[tuple[int, str, str, int]],&lt;br /&gt;
]:&lt;br /&gt;
    bus = DirectMessageBus()&lt;br /&gt;
    sink = AOStoreCaptureSink()&lt;br /&gt;
    bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
    edge_emitter = bus.register_emitter(&lt;br /&gt;
        msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
        msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
        default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    direct_processor = DirectPathProcessor(bus)&lt;br /&gt;
    extension_processor = PathExtensionProcessor(bus)&lt;br /&gt;
&lt;br /&gt;
    edges = (&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;A&amp;quot;,&lt;br /&gt;
            target=&amp;quot;B&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;B&amp;quot;,&lt;br /&gt;
            target=&amp;quot;C&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;C&amp;quot;,&lt;br /&gt;
            target=&amp;quot;D&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    for edge in edges:&lt;br /&gt;
        edge_emitter.emit(edge)&lt;br /&gt;
&lt;br /&gt;
    steps = (&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;direct path from edge&amp;quot;,&lt;br /&gt;
            run=direct_processor.process_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns edge&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_edge_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns direct path&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_direct_path_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns extension path&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_extension_path_available,&lt;br /&gt;
        ),&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    runner = RandomProcessorRunner(&lt;br /&gt;
        steps=steps,&lt;br /&gt;
        seed=seed,&lt;br /&gt;
    )&lt;br /&gt;
    trace = runner.run_until_quiet()&lt;br /&gt;
&lt;br /&gt;
    history = CaptureHistory(sink.all_records())&lt;br /&gt;
    path_facts = []&lt;br /&gt;
&lt;br /&gt;
    for message in history.by_topic(&amp;quot;demo.graph.path&amp;quot;):&lt;br /&gt;
        path = message.payload&lt;br /&gt;
        if isinstance(path, PathKnownEvent):&lt;br /&gt;
            fact = (&lt;br /&gt;
                message.bus_sequence,&lt;br /&gt;
                path.source,&lt;br /&gt;
                path.target,&lt;br /&gt;
                path.hop_count,&lt;br /&gt;
            )&lt;br /&gt;
            path_facts.append(fact)&lt;br /&gt;
&lt;br /&gt;
    result = (trace, path_facts)&lt;br /&gt;
    return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Run one schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_1, paths_1 = run_graph_demo(1)&lt;br /&gt;
&lt;br /&gt;
trace_1&lt;br /&gt;
paths_1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, &#039;...&#039;, ...), (0, &#039;...&#039;, ...), ...]&lt;br /&gt;
[(..., &#039;A&#039;, &#039;B&#039;, 1), (..., &#039;B&#039;, &#039;C&#039;, 1), (..., &#039;C&#039;, &#039;D&#039;, 1), ...]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Run another schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_2, paths_2 = run_graph_demo(2)&lt;br /&gt;
&lt;br /&gt;
trace_2&lt;br /&gt;
paths_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the schedules:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_1 == trace_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
False&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the final path facts without bus sequence numbers:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
final_paths_1 = {&lt;br /&gt;
    (source, target, hop_count)&lt;br /&gt;
    for _, source, target, hop_count in paths_1&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
final_paths_2 = {&lt;br /&gt;
    (source, target, hop_count)&lt;br /&gt;
    for _, source, target, hop_count in paths_2&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
final_paths_1&lt;br /&gt;
final_paths_2&lt;br /&gt;
final_paths_1 == final_paths_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
{(&#039;A&#039;, &#039;B&#039;, 1), (&#039;B&#039;, &#039;C&#039;, 1), (&#039;C&#039;, &#039;D&#039;, 1), (&#039;A&#039;, &#039;C&#039;, 2), (&#039;B&#039;, &#039;D&#039;, 2), (&#039;A&#039;, &#039;D&#039;, 3)}&lt;br /&gt;
{(&#039;A&#039;, &#039;B&#039;, 1), (&#039;B&#039;, &#039;C&#039;, 1), (&#039;C&#039;, &#039;D&#039;, 1), (&#039;A&#039;, &#039;C&#039;, 2), (&#039;B&#039;, &#039;D&#039;, 2), (&#039;A&#039;, &#039;D&#039;, 3)}&lt;br /&gt;
True&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for round_index, step_name, work_count in trace_1:&lt;br /&gt;
    print(round_index, step_name, work_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first captured path order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for bus_sequence, source, target, hop_count in paths_1:&lt;br /&gt;
    print(bus_sequence, source, target, hop_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the second schedule and path order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for round_index, step_name, work_count in trace_2:&lt;br /&gt;
    print(round_index, step_name, work_count)&lt;br /&gt;
&lt;br /&gt;
for bus_sequence, source, target, hop_count in paths_2:&lt;br /&gt;
    print(bus_sequence, source, target, hop_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The source input order did not change. The processor scheduling changed. The captured event order may differ. The final reachability facts should match.&lt;br /&gt;
&lt;br /&gt;
This models an important distributed-systems lesson: event history order and final derived state are related, but they are not the same thing.&lt;br /&gt;
&lt;br /&gt;
== Phase 16: Later refinement: graph index from history ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Introduce restart/recovery thinking only after the basic graph feedback example is familiar.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The first graph processors keep local working state.&lt;br /&gt;
* Local working state is enough for a short demonstration.&lt;br /&gt;
* Longer-lived processors need recovery behavior after restart.&lt;br /&gt;
* Since edge and path facts are captured as events, a graph index can later be rebuilt from history.&lt;br /&gt;
* This points toward projection-style services without changing the basic processor model.&lt;br /&gt;
&lt;br /&gt;
; Suggested later file&lt;br /&gt;
&lt;br /&gt;
Create later:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_index.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Suggested responsibilities:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
read captured history&lt;br /&gt;
collect EdgeDeclaredEvent payloads&lt;br /&gt;
collect PathKnownEvent payloads&lt;br /&gt;
answer whether a path fact exists&lt;br /&gt;
find edges starting at a node&lt;br /&gt;
find paths ending at a node&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This refinement belongs after the team has already run the graph reachability demo successfully.&lt;br /&gt;
&lt;br /&gt;
== Phase 17: Later refinement: shell command reconstruction ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Connect the TTY examples back to the shell command event shape used by regex milestones.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; is the high-level event consumed by the regex milestone processor.&lt;br /&gt;
* &amp;lt;code&amp;gt;TTYReadEvent&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;TTYWriteEvent&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;LineDisciplineEvent&amp;lt;/code&amp;gt; are lower-level observations.&lt;br /&gt;
* A reconstructor can subscribe to line-discipline events and query recent related observations.&lt;br /&gt;
* The reconstructor should emit shell command events; it should not perform regex analysis itself.&lt;br /&gt;
&lt;br /&gt;
; Suggested responsibilities&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
listen for completed line-discipline events&lt;br /&gt;
query history since the previous command boundary for the same session&lt;br /&gt;
collect matching reads, writes, and timing events&lt;br /&gt;
construct ShellCommandEvent&lt;br /&gt;
emit ShellCommandEvent on demo.shell.command&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This is an appropriate follow-up exercise after the team understands both regex milestone processing and history queries.&lt;br /&gt;
&lt;br /&gt;
= Related =&lt;br /&gt;
&lt;br /&gt;
== Top-level topic page ==&lt;br /&gt;
[[Message Bus Service]]&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=Message_Bus_Live_Coding_Exercise&amp;diff=931</id>
		<title>Message Bus Live Coding Exercise</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=Message_Bus_Live_Coding_Exercise&amp;diff=931"/>
		<updated>2026-06-16T04:08:13Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Introduction =&lt;br /&gt;
&lt;br /&gt;
== Audience and scope ==&lt;br /&gt;
&lt;br /&gt;
This exercise is intended for EDURange developers learning how to write event processors with the message bus.&lt;br /&gt;
&lt;br /&gt;
The packaged &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; modules are treated as read-only for this exercise. All new code belongs under a demonstration directory such as:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
workspace/&lt;br /&gt;
    mbus/&lt;br /&gt;
    aostore/&lt;br /&gt;
    demos/&lt;br /&gt;
        __init__.py&lt;br /&gt;
        event_processors/&lt;br /&gt;
            __init__.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The exercise uses two problem families.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;1. Shell and TTY event processing&#039;&#039;&#039;&lt;br /&gt;
** Send and receive messages through the bus.&lt;br /&gt;
** Capture and inspect message history.&lt;br /&gt;
** Represent high-level shell command events.&lt;br /&gt;
** Recreate the regex-based milestone role in a simplified form.&lt;br /&gt;
** Mock up TTY-derived observations such as keystroke timing.&lt;br /&gt;
* &#039;&#039;&#039;2. Graph event processing&#039;&#039;&#039;&lt;br /&gt;
** Represent graph edge relationships as source events.&lt;br /&gt;
** Derive direct path facts from edge relation events.&lt;br /&gt;
** Add feedback by deriving longer paths from known paths.&lt;br /&gt;
** Simulate nondeterministic processor scheduling while keeping source input fixed.&lt;br /&gt;
&lt;br /&gt;
The shell examples draw from EDURange&#039;s institutional knowledge. The graph examples provide a compact computer science model for safe feedback loops.&lt;br /&gt;
&lt;br /&gt;
== Running the examples ==&lt;br /&gt;
&lt;br /&gt;
Adjust paths to match the local checkout layout if necessary. Start an interpreter from the workspace root with project packages available.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
PYTHONPATH=&amp;quot;$PWD/mbus:$PWD/aostore:$PWD&amp;quot; python3.14&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
When module files are changed, restart the interpreter unless the group is deliberately practicing &amp;lt;code&amp;gt;importlib.reload()&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Create the demonstration package ==&lt;br /&gt;
&lt;br /&gt;
Before the first durable file edit, create the demonstration package directories:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir -p demos/event_processors&lt;br /&gt;
touch demos/__init__.py&lt;br /&gt;
touch demos/event_processors/__init__.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
== Important note about capture records ==&lt;br /&gt;
&lt;br /&gt;
The current demonstration works close to the low-level capture layer. As a result, it exposes &amp;lt;code&amp;gt;MessageSymbolRegistration&amp;lt;/code&amp;gt; records while building the temporary history helper.&lt;br /&gt;
&lt;br /&gt;
That is not the intended ordinary user experience. In a more complete release, higher-level capture and history infrastructure should handle symbol registration bookkeeping on behalf of processor authors. In this exercise, symbol registration records are visible because the demonstration history helper reconstructs readable messages from compact captured records directly.&lt;br /&gt;
&lt;br /&gt;
== Suggested group session flow ==&lt;br /&gt;
&lt;br /&gt;
; Session 1 - Bus and capture basics&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 1:&#039;&#039;&#039;&lt;br /&gt;
** Signal over the bus by hand.&lt;br /&gt;
** Inspect received messages.&lt;br /&gt;
** Inspect low-level captured records.&lt;br /&gt;
* &#039;&#039;&#039;Phase 2:&#039;&#039;&#039;&lt;br /&gt;
** Discuss why symbol registration appears in this temporary demo.&lt;br /&gt;
** Build the capture history helper.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1-3 hours (1-2 hours work, 0.5-1 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 2 - Shell command and regex milestones&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 3:&#039;&#039;&#039;&lt;br /&gt;
** Represent a shell command as a dictionary.&lt;br /&gt;
* &#039;&#039;&#039;Phase 4:&#039;&#039;&#039;&lt;br /&gt;
** Replace it with &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Phase 5:&#039;&#039;&#039;&lt;br /&gt;
** Manually evaluate regex milestone expressions.&lt;br /&gt;
* &#039;&#039;&#039;Phase 6:&#039;&#039;&#039;&lt;br /&gt;
** Implement &amp;lt;code&amp;gt;RegexMilestoneProcessor&amp;lt;/code&amp;gt;.&lt;br /&gt;
** Inspect source and analysis events in history.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3 hours (1.5-2.5 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 3 - TTY-derived observations&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 7:&#039;&#039;&#039;&lt;br /&gt;
** Create raw TTY read, write, and line events.&lt;br /&gt;
** Show why regex milestones need reconstructed shell command events.&lt;br /&gt;
* &#039;&#039;&#039;Phase 8:&#039;&#039;&#039;&lt;br /&gt;
** Implement keystroke timing.&lt;br /&gt;
** Discuss shell command reconstruction as the next derived-event problem (to be continued in Session 6 onward).&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1-2.5 hours (1-2 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 4 - Graph-derived facts&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 9:&#039;&#039;&#039;&lt;br /&gt;
** Define graph edges and paths.&lt;br /&gt;
* &#039;&#039;&#039;Phase 10:&#039;&#039;&#039;&lt;br /&gt;
** Add the demo-only nonblocking receive helper.&lt;br /&gt;
* &#039;&#039;&#039;Phase 11:&#039;&#039;&#039;&lt;br /&gt;
** Implement direct path derivation.&lt;br /&gt;
* &#039;&#039;&#039;Phase 12:&#039;&#039;&#039;&lt;br /&gt;
** Explore the extension rule by hand.&lt;br /&gt;
* &#039;&#039;&#039;Phase 13:&#039;&#039;&#039;&lt;br /&gt;
** Add path extension feedback.&lt;br /&gt;
** Discuss duplicate prevention.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3.5 hours (1.5-3 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 5 - Nondeterministic processor scheduling&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 14:&#039;&#039;&#039;&lt;br /&gt;
** Add the randomized processor runner.&lt;br /&gt;
* &#039;&#039;&#039;Phase 15:&#039;&#039;&#039;&lt;br /&gt;
** Keep source events fixed.&lt;br /&gt;
** Vary processor scheduling by seed.&lt;br /&gt;
** Compare final path facts across runs.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3.5 hours (1.5-3 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 6 - Follow-up refinements&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 16:&#039;&#039;&#039;&lt;br /&gt;
** Rebuild a graph index from history.&lt;br /&gt;
* &#039;&#039;&#039;Phase 17:&#039;&#039;&#039;&lt;br /&gt;
** Implement shell command reconstruction.&lt;br /&gt;
** Replace local Python payload assumptions with explicit payload projection formats when the bus format layer is ready for the exercise.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-4.5 hours (1.5-4 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
= Event Processor Demonstration Plan =&lt;br /&gt;
&lt;br /&gt;
== Phase 1: Signal over the bus by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Send one message through the direct bus and inspect what the receiver sees.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* A bus is useful only when something emits and something receives.&lt;br /&gt;
* Public message setup uses readable strings: topic, producer, and message type.&lt;br /&gt;
* The direct broker delivers local Python payloads in this demonstration.&lt;br /&gt;
* Capture must be attached before delivery in the current prototype.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Import the bus and capture interfaces:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.capture.sink import CaptureSink&lt;br /&gt;
from mbus.message.records import CapturedMessage&lt;br /&gt;
from mbus.message.symbols import MessageSymbolRegistration&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create a temporary capture sink in the interpreter. This sink stores whatever the bus sends to the capture layer:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
CapturedRecord = MessageSymbolRegistration | CapturedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class ListCaptureSink(CaptureSink):&lt;br /&gt;
    records: list[CapturedRecord] = field(default_factory=list)&lt;br /&gt;
&lt;br /&gt;
    def capture_symbol_registration(&lt;br /&gt;
        self,&lt;br /&gt;
        registration: MessageSymbolRegistration,&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self.records.append(registration)&lt;br /&gt;
&lt;br /&gt;
    def capture(self, message: CapturedMessage) -&amp;gt; None:&lt;br /&gt;
        self.records.append(message)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create the bus and attach capture:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = ListCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the empty capture sink:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
sink&lt;br /&gt;
sink.records&lt;br /&gt;
len(sink.records)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ListCaptureSink(records=[])&lt;br /&gt;
[]&lt;br /&gt;
0&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create a receiver. This describes the messages the receiver wants to hear:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the receiver and confirm that capture is still empty:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
type(receiver)&lt;br /&gt;
vars(receiver)&lt;br /&gt;
sink.records&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;class &#039;mbus.broker.direct._BrokerReceiver&#039;&amp;gt;&lt;br /&gt;
{&#039;_queue&#039;: ...}&lt;br /&gt;
[]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create an emitter. Registering the emitter gives the bus enough information to bind readable names to compact internal IDs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the captured registration records:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
sink.records&lt;br /&gt;
[type(record).__name__ for record in sink.records]&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    print(record)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[&#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;]&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;demo.signals&#039; ...)&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;ping&#039; ...)&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;demo-source&#039; ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit one message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;hello bus&amp;quot;})&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect capture before receiving:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
len(sink.records)&lt;br /&gt;
[type(record).__name__ for record in sink.records]&lt;br /&gt;
sink.records[-1]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
4&lt;br /&gt;
[&#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;CapturedMessage&#039;]&lt;br /&gt;
CapturedMessage(... payload={&#039;text&#039;: &#039;hello bus&#039;} ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
message = receiver.receive()&lt;br /&gt;
&lt;br /&gt;
message&lt;br /&gt;
message.msg_topic&lt;br /&gt;
message.msg_producer&lt;br /&gt;
message.msg_type&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ReceivedMessage(...)&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&#039;demo-source&#039;&lt;br /&gt;
&#039;ping&#039;&lt;br /&gt;
{&#039;text&#039;: &#039;hello bus&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the received message with the captured record:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured = sink.records[-1]&lt;br /&gt;
&lt;br /&gt;
captured&lt;br /&gt;
captured.msg_topic_id&lt;br /&gt;
captured.msg_type_id&lt;br /&gt;
captured.msg_producer_id&lt;br /&gt;
captured.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
CapturedMessage(...)&lt;br /&gt;
TopicID(...)&lt;br /&gt;
MessageTypeID(...)&lt;br /&gt;
ProducerID(...)&lt;br /&gt;
{&#039;text&#039;: &#039;hello bus&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The receiver gets the readable message view. The capture sink sees compact IDs and local payload data. The readable names are still available through symbol registration records, but manually reconstructing them is awkward.&lt;br /&gt;
&lt;br /&gt;
That awkwardness motivates the next phase.&lt;br /&gt;
&lt;br /&gt;
== Phase 2: Build a temporary capture history helper ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a small demonstration helper that turns captured records back into readable history messages.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Capture records are useful, but raw captured messages use compact IDs.&lt;br /&gt;
* A history helper can rebuild readable message history from registration records.&lt;br /&gt;
* This is demonstration infrastructure, not the final persistence/query API.&lt;br /&gt;
* The helper writes captured records into an in-memory &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; sequential unit.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration before the file edit&lt;br /&gt;
&lt;br /&gt;
List only the captured messages:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured_messages = []&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    if isinstance(record, CapturedMessage):&lt;br /&gt;
        captured_messages.append(record)&lt;br /&gt;
&lt;br /&gt;
captured_messages&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[CapturedMessage(... payload={&#039;text&#039;: &#039;hello bus&#039;} ...)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first captured message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured_message = captured_messages[0]&lt;br /&gt;
&lt;br /&gt;
captured_message.msg_topic_id&lt;br /&gt;
captured_message.msg_type_id&lt;br /&gt;
captured_message.msg_producer_id&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
TopicID(...)&lt;br /&gt;
MessageTypeID(...)&lt;br /&gt;
ProducerID(...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Manually reconstruct a topic name:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
topic_names = {}&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    if isinstance(record, MessageSymbolRegistration):&lt;br /&gt;
        if record.symbol_kind.name == &amp;quot;TOPIC&amp;quot;:&lt;br /&gt;
            topic_names[record.symbol_id] = record.symbol&lt;br /&gt;
&lt;br /&gt;
topic_names&lt;br /&gt;
topic_names[captured_message.msg_topic_id]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
{TopicID(...): &#039;demo.signals&#039;}&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This works, but repeating it by hand would distract from writing event processors.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/capture_history.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/capture_history.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Capture and history helpers for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from collections.abc import Iterable&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
&lt;br /&gt;
from aostore.sequentialunit.listsequentialunit import ListSequentialUnit&lt;br /&gt;
from mbus.capture.sink import CaptureSink&lt;br /&gt;
from mbus.message.records import CapturedMessage&lt;br /&gt;
from mbus.message.symbols import (&lt;br /&gt;
    MessageSymbolKind,&lt;br /&gt;
    MessageSymbolRegistration,&lt;br /&gt;
    MessageTypeID,&lt;br /&gt;
    ProducerID,&lt;br /&gt;
    TopicID,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
CapturedRecord = MessageSymbolRegistration | CapturedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class AOStoreCaptureSink(CaptureSink):&lt;br /&gt;
    records: ListSequentialUnit[CapturedRecord] = field(&lt;br /&gt;
        default_factory=ListSequentialUnit&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    def capture_symbol_registration(&lt;br /&gt;
        self,&lt;br /&gt;
        registration: MessageSymbolRegistration,&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self.records.append(registration)&lt;br /&gt;
&lt;br /&gt;
    def capture(self, message: CapturedMessage) -&amp;gt; None:&lt;br /&gt;
        self.records.append(message)&lt;br /&gt;
&lt;br /&gt;
    def all_records(self) -&amp;gt; tuple[CapturedRecord, ...]:&lt;br /&gt;
        records = self.records.sequential_read(0, len(self.records))&lt;br /&gt;
        result = tuple(records)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class HistoryMessage:&lt;br /&gt;
    msg_topic: str&lt;br /&gt;
    msg_type: str&lt;br /&gt;
    msg_producer: str&lt;br /&gt;
    payload: object&lt;br /&gt;
    bus_sequence: int&lt;br /&gt;
    topic_sequence: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class CaptureHistory:&lt;br /&gt;
    _records: tuple[CapturedRecord, ...]&lt;br /&gt;
    _topics: dict[TopicID, str]&lt;br /&gt;
    _types: dict[MessageTypeID, str]&lt;br /&gt;
    _producers: dict[ProducerID, str]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, records: Iterable[CapturedRecord]) -&amp;gt; None:&lt;br /&gt;
        self._records = tuple(records)&lt;br /&gt;
        self._topics = {}&lt;br /&gt;
        self._types = {}&lt;br /&gt;
        self._producers = {}&lt;br /&gt;
        self._index_symbols()&lt;br /&gt;
&lt;br /&gt;
    def _index_symbols(self) -&amp;gt; None:&lt;br /&gt;
        for record in self._records:&lt;br /&gt;
            if isinstance(record, MessageSymbolRegistration):&lt;br /&gt;
                self._record_symbol(record)&lt;br /&gt;
&lt;br /&gt;
    def _record_symbol(self, record: MessageSymbolRegistration) -&amp;gt; None:&lt;br /&gt;
        if record.symbol_kind is MessageSymbolKind.TOPIC:&lt;br /&gt;
            self._topics[record.symbol_id] = record.symbol&lt;br /&gt;
        elif record.symbol_kind is MessageSymbolKind.MSG_TYPE:&lt;br /&gt;
            self._types[record.symbol_id] = record.symbol&lt;br /&gt;
        elif record.symbol_kind is MessageSymbolKind.PRODUCER:&lt;br /&gt;
            self._producers[record.symbol_id] = record.symbol&lt;br /&gt;
&lt;br /&gt;
    def messages(self) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for record in self._records:&lt;br /&gt;
            if isinstance(record, CapturedMessage):&lt;br /&gt;
                message = self._decode_message(record)&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def by_topic(self, msg_topic: str) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for message in self.messages():&lt;br /&gt;
            if message.msg_topic == msg_topic:&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def since(self, bus_sequence: int) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for message in self.messages():&lt;br /&gt;
            if message.bus_sequence &amp;gt;= bus_sequence:&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def _decode_message(self, message: CapturedMessage) -&amp;gt; HistoryMessage:&lt;br /&gt;
        history_message = HistoryMessage(&lt;br /&gt;
            msg_topic=self._topics[message.msg_topic_id],&lt;br /&gt;
            msg_type=self._types[message.msg_type_id],&lt;br /&gt;
            msg_producer=self._producers[message.msg_producer_id],&lt;br /&gt;
            payload=message.payload,&lt;br /&gt;
            bus_sequence=message.bus_sequence,&lt;br /&gt;
            topic_sequence=message.topic_sequence,&lt;br /&gt;
        )&lt;br /&gt;
        return history_message&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;history helper&amp;quot;})&lt;br /&gt;
message = receiver.receive()&lt;br /&gt;
&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
history.messages()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[HistoryMessage(msg_topic=&#039;demo.signals&#039;, msg_type=&#039;ping&#039;, msg_producer=&#039;demo-source&#039;, payload={&#039;text&#039;: &#039;history helper&#039;}, ...)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the decoded history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history_message = history.messages()[0]&lt;br /&gt;
&lt;br /&gt;
history_message.msg_topic&lt;br /&gt;
history_message.msg_type&lt;br /&gt;
history_message.msg_producer&lt;br /&gt;
history_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&#039;ping&#039;&lt;br /&gt;
&#039;demo-source&#039;&lt;br /&gt;
{&#039;text&#039;: &#039;history helper&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 3: Explore shell command data by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Identify the high-level event shape needed by a regex milestone processor.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The old milestone strategy operated on command-level records.&lt;br /&gt;
* A command-level record includes context such as host and working directory.&lt;br /&gt;
* It also includes shell input and shell output.&lt;br /&gt;
* Raw TTY events do not directly have this shape; reconstruction comes later.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Represent a shell command as a dictionary:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = {&lt;br /&gt;
    &amp;quot;session_id&amp;quot;: &amp;quot;session-1&amp;quot;,&lt;br /&gt;
    &amp;quot;command_index&amp;quot;: 0,&lt;br /&gt;
    &amp;quot;host&amp;quot;: &amp;quot;alpha&amp;quot;,&lt;br /&gt;
    &amp;quot;cwd&amp;quot;: &amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    &amp;quot;input_text&amp;quot;: &amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    &amp;quot;output_text&amp;quot;: &amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    &amp;quot;started_at_ns&amp;quot;: 1000,&lt;br /&gt;
    &amp;quot;ended_at_ns&amp;quot;: 2000,&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the fields used by regex milestone checks:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command[&amp;quot;host&amp;quot;]&lt;br /&gt;
command[&amp;quot;cwd&amp;quot;]&lt;br /&gt;
command[&amp;quot;input_text&amp;quot;]&lt;br /&gt;
command[&amp;quot;output_text&amp;quot;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;alpha&#039;&lt;br /&gt;
&#039;/home/student&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Try an accidental wrong key:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command[&amp;quot;working_directory&amp;quot;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeyError: &#039;working_directory&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The dictionary contains the information, but it does not document the shape of the event. A typo is discovered only when the field is used. A named event class makes the processor code easier to read and easier to explain.&lt;br /&gt;
&lt;br /&gt;
== Phase 4: Persist the event record classes ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create reusable payload classes for the TTY, shell command, and regex milestone examples.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/events.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/events.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Payload records for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from dataclasses import dataclass&lt;br /&gt;
from typing import Literal&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class TTYReadEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    data: bytes&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class TTYWriteEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    data: bytes&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class LineDisciplineEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    line: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class KeystrokeTimingEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    previous_timestamp_ns: int | None&lt;br /&gt;
    delta_ns: int | None&lt;br /&gt;
    text: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class ShellCommandEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    command_index: int&lt;br /&gt;
    host: str&lt;br /&gt;
    cwd: str&lt;br /&gt;
    input_text: str&lt;br /&gt;
    output_text: str&lt;br /&gt;
    started_at_ns: int&lt;br /&gt;
    ended_at_ns: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
RegexMilestoneField = Literal[&lt;br /&gt;
    &amp;quot;host&amp;quot;,&lt;br /&gt;
    &amp;quot;cwd&amp;quot;,&lt;br /&gt;
    &amp;quot;input_text&amp;quot;,&lt;br /&gt;
    &amp;quot;output_text&amp;quot;,&lt;br /&gt;
]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneExpression:&lt;br /&gt;
    name: str&lt;br /&gt;
    field: RegexMilestoneField&lt;br /&gt;
    pattern: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneResult:&lt;br /&gt;
    expression_name: str&lt;br /&gt;
    field: RegexMilestoneField&lt;br /&gt;
    matched: bool&lt;br /&gt;
    match_text: str | None = None&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneAnalysisEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    command_index: int&lt;br /&gt;
    results: tuple[RegexMilestoneResult, ...]&lt;br /&gt;
    matched_count: int&lt;br /&gt;
    expression_count: int&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.events import ShellCommandEvent&lt;br /&gt;
&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
command&lt;br /&gt;
command.host&lt;br /&gt;
command.cwd&lt;br /&gt;
command.input_text&lt;br /&gt;
command.output_text&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ShellCommandEvent(session_id=&#039;session-1&#039;, command_index=0, ...)&lt;br /&gt;
&#039;alpha&#039;&lt;br /&gt;
&#039;/home/student&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The event shape is now explicit. Other code can depend on a named &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; instead of assuming that a loose dictionary has the expected fields.&lt;br /&gt;
&lt;br /&gt;
== Phase 5: Evaluate regex milestones manually ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Recreate the core milestone check in a small, transparent form.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* A regex milestone expression names one check against one shell command field.&lt;br /&gt;
* The field may be command input, command output, working directory, or host context.&lt;br /&gt;
* The result records whether the expression matched and, when available, the matched text.&lt;br /&gt;
* Manual evaluation is useful for learning the shape of the problem before writing a processor.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Import regular expressions and the payload classes:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create one command event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create three milestone expressions:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
expressions = (&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;used-ls&amp;quot;,&lt;br /&gt;
        field=&amp;quot;input_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;\bls\b&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;saw-notes&amp;quot;,&lt;br /&gt;
        field=&amp;quot;output_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;notes\.txt&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;home-directory&amp;quot;,&lt;br /&gt;
        field=&amp;quot;cwd&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;^/home/student$&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Evaluate one expression:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
expression = expressions[0]&lt;br /&gt;
text = getattr(command, expression.field)&lt;br /&gt;
match = re.search(expression.pattern, text)&lt;br /&gt;
&lt;br /&gt;
expression.name&lt;br /&gt;
expression.field&lt;br /&gt;
text&lt;br /&gt;
match.group(0)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;used-ls&#039;&lt;br /&gt;
&#039;input_text&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;ls&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Evaluate all expressions:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
manual_results = []&lt;br /&gt;
&lt;br /&gt;
for expression in expressions:&lt;br /&gt;
    text = getattr(command, expression.field)&lt;br /&gt;
    match = re.search(expression.pattern, text)&lt;br /&gt;
    manual_results.append((expression.name, match is not None))&lt;br /&gt;
&lt;br /&gt;
manual_results&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(&#039;used-ls&#039;, True), (&#039;saw-notes&#039;, True), (&#039;home-directory&#039;, True)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
Manual evaluation is simple for one command, but the loop is already event processor logic. The next phase preserves the loop in a reusable processor.&lt;br /&gt;
&lt;br /&gt;
== Phase 6: Persist the RegexMilestone processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a processor that receives shell command events and emits regex milestone analysis events.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/regex_milestones.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/regex_milestones.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Regex milestone processor for shell command events.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneAnalysisEvent,&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    RegexMilestoneResult,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class RegexMilestoneProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _expressions: tuple[RegexMilestoneExpression, ...]&lt;br /&gt;
&lt;br /&gt;
    def __init__(&lt;br /&gt;
        self,&lt;br /&gt;
        bus: DirectMessageBus,&lt;br /&gt;
        expressions: tuple[RegexMilestoneExpression, ...],&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.shell.command&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;shell-reconstructor&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;shell-command&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.shell.regex_milestone&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;regex-milestone-processor&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;regex-milestone-analysis&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._expressions = expressions&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; RegexMilestoneAnalysisEvent:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        command = message.payload&lt;br /&gt;
&lt;br /&gt;
        if not isinstance(command, ShellCommandEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected ShellCommandEvent, got {type(command).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        analysis = analyze_regex_milestones(command, self._expressions)&lt;br /&gt;
        self._emitter.emit(analysis)&lt;br /&gt;
        return analysis&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def analyze_regex_milestones(&lt;br /&gt;
    command: ShellCommandEvent,&lt;br /&gt;
    expressions: tuple[RegexMilestoneExpression, ...],&lt;br /&gt;
) -&amp;gt; RegexMilestoneAnalysisEvent:&lt;br /&gt;
    results = []&lt;br /&gt;
    for expression in expressions:&lt;br /&gt;
        result = evaluate_regex_milestone(command, expression)&lt;br /&gt;
        results.append(result)&lt;br /&gt;
&lt;br /&gt;
    matched_count = sum(result.matched for result in results)&lt;br /&gt;
    analysis = RegexMilestoneAnalysisEvent(&lt;br /&gt;
        session_id=command.session_id,&lt;br /&gt;
        command_index=command.command_index,&lt;br /&gt;
        results=tuple(results),&lt;br /&gt;
        matched_count=matched_count,&lt;br /&gt;
        expression_count=len(expressions),&lt;br /&gt;
    )&lt;br /&gt;
    return analysis&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def evaluate_regex_milestone(&lt;br /&gt;
    command: ShellCommandEvent,&lt;br /&gt;
    expression: RegexMilestoneExpression,&lt;br /&gt;
) -&amp;gt; RegexMilestoneResult:&lt;br /&gt;
    text = getattr(command, expression.field)&lt;br /&gt;
    match = re.search(expression.pattern, text)&lt;br /&gt;
    match_text = None&lt;br /&gt;
&lt;br /&gt;
    if match is not None:&lt;br /&gt;
        match_text = match.group(0)&lt;br /&gt;
&lt;br /&gt;
    result = RegexMilestoneResult(&lt;br /&gt;
        expression_name=expression.name,&lt;br /&gt;
        field=expression.field,&lt;br /&gt;
        matched=match is not None,&lt;br /&gt;
        match_text=match_text,&lt;br /&gt;
    )&lt;br /&gt;
    return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then set up the bus:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.regex_milestones import RegexMilestoneProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Subscribe to the analysis topic before running the processor:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.shell.regex_milestone&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;regex-milestone-processor&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;regex-milestone-analysis&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create the shell command emitter and processor:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.shell.command&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;shell-reconstructor&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;shell-command&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
expressions = (&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;used-ls&amp;quot;,&lt;br /&gt;
        field=&amp;quot;input_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;\bls\b&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;saw-notes&amp;quot;,&lt;br /&gt;
        field=&amp;quot;output_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;notes\.txt&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;home-directory&amp;quot;,&lt;br /&gt;
        field=&amp;quot;cwd&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;^/home/student$&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = RegexMilestoneProcessor(bus, expressions)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit a shell command event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
command_emitter.emit(command)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the command:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis = processor.process_one()&lt;br /&gt;
analysis&lt;br /&gt;
analysis.results&lt;br /&gt;
analysis.matched_count&lt;br /&gt;
analysis.expression_count&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
RegexMilestoneAnalysisEvent(... matched_count=3, expression_count=3)&lt;br /&gt;
(RegexMilestoneResult(...), RegexMilestoneResult(...), RegexMilestoneResult(...))&lt;br /&gt;
3&lt;br /&gt;
3&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the derived analysis event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis_message = analysis_receiver.receive()&lt;br /&gt;
&lt;br /&gt;
analysis_message.msg_topic&lt;br /&gt;
analysis_message.msg_type&lt;br /&gt;
analysis_message.msg_producer&lt;br /&gt;
analysis_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;demo.shell.regex_milestone&#039;&lt;br /&gt;
&#039;regex-milestone-analysis&#039;&lt;br /&gt;
&#039;regex-milestone-processor&#039;&lt;br /&gt;
RegexMilestoneAnalysisEvent(... matched_count=3, expression_count=3)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
messages = history.messages()&lt;br /&gt;
&lt;br /&gt;
for message in messages:&lt;br /&gt;
    print(message.bus_sequence, message.msg_topic, message.msg_type)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
0 demo.shell.command shell-command&lt;br /&gt;
1 demo.shell.regex_milestone regex-milestone-analysis&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The regex milestone processor consumes one event and emits another. The derived analysis event is available to any later subscriber. History contains both the source command event and the derived analysis event.&lt;br /&gt;
&lt;br /&gt;
== Phase 7: Explore why shell reconstruction is separate ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Show that raw TTY observations are not the same thing as high-level shell command events.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Regex milestones should inspect high-level command events.&lt;br /&gt;
* TTY reads, TTY writes, and line-discipline lines are lower-level observations.&lt;br /&gt;
* A shell command event is reconstructed by correlating those observations.&lt;br /&gt;
* Keeping reconstruction separate prevents the regex milestone processor from becoming a large TTY parser.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Create a few low-level observations:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    LineDisciplineEvent,&lt;br /&gt;
    TTYReadEvent,&lt;br /&gt;
    TTYWriteEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_1 = TTYReadEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=0,&lt;br /&gt;
    timestamp_ns=1000,&lt;br /&gt;
    data=b&amp;quot;l&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_2 = TTYReadEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=1,&lt;br /&gt;
    timestamp_ns=1100,&lt;br /&gt;
    data=b&amp;quot;s&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
line = LineDisciplineEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=2,&lt;br /&gt;
    timestamp_ns=1200,&lt;br /&gt;
    line=&amp;quot;ls\n&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
write = TTYWriteEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=3,&lt;br /&gt;
    timestamp_ns=1600,&lt;br /&gt;
    data=b&amp;quot;notes.txt\n&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the raw pieces:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_1.data&lt;br /&gt;
read_2.data&lt;br /&gt;
line.line&lt;br /&gt;
write.data&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
b&#039;l&#039;&lt;br /&gt;
b&#039;s&#039;&lt;br /&gt;
&#039;ls\n&#039;&lt;br /&gt;
b&#039;notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Assemble command fields manually:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
input_text = line.line&lt;br /&gt;
output_text = write.data.decode(&amp;quot;utf-8&amp;quot;, errors=&amp;quot;replace&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
input_text&lt;br /&gt;
output_text&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;ls\n&#039;&lt;br /&gt;
&#039;notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The useful command event is derived from several lower-level observations. A later shell reconstructor can subscribe to line-discipline events, query recent reads/writes/timing observations, and emit &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; records.&lt;br /&gt;
&lt;br /&gt;
== Phase 8: Add keystroke timing as a smaller TTY-derived processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a simple stateful processor before attempting full shell reconstruction.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Timing requires comparing a TTY read with the previous read in the same session.&lt;br /&gt;
* A derived timing event makes the result explicit.&lt;br /&gt;
* Other processors can consume timing events instead of recalculating deltas.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration before the file edit&lt;br /&gt;
&lt;br /&gt;
Calculate timing manually from the two read events:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_events = [read_1, read_2]&lt;br /&gt;
previous_timestamp = None&lt;br /&gt;
timing_rows = []&lt;br /&gt;
&lt;br /&gt;
for event in read_events:&lt;br /&gt;
    delta = None&lt;br /&gt;
    if previous_timestamp is not None:&lt;br /&gt;
        delta = event.timestamp_ns - previous_timestamp&lt;br /&gt;
    timing_rows.append((event.source_index, previous_timestamp, delta))&lt;br /&gt;
    previous_timestamp = event.timestamp_ns&lt;br /&gt;
&lt;br /&gt;
timing_rows&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, None, None), (1, 1000, 100)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/keystroke_timing.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/keystroke_timing.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Keystroke timing processor for TTY read events.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    KeystrokeTimingEvent,&lt;br /&gt;
    TTYReadEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class KeystrokeTimingProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _last_timestamp_by_session: dict[str, int]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.tty.read&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;tty-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;tty-read&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.keystroke.timing&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._last_timestamp_by_session = {}&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; KeystrokeTimingEvent:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        event = message.payload&lt;br /&gt;
&lt;br /&gt;
        if not isinstance(event, TTYReadEvent):&lt;br /&gt;
            raise TypeError(f&amp;quot;expected TTYReadEvent, got {type(event).__name__}&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
        previous_timestamp = self._last_timestamp_by_session.get(&lt;br /&gt;
            event.session_id&lt;br /&gt;
        )&lt;br /&gt;
        delta = None&lt;br /&gt;
&lt;br /&gt;
        if previous_timestamp is not None:&lt;br /&gt;
            delta = event.timestamp_ns - previous_timestamp&lt;br /&gt;
&lt;br /&gt;
        self._last_timestamp_by_session[event.session_id] = event.timestamp_ns&lt;br /&gt;
&lt;br /&gt;
        text = event.data.decode(&amp;quot;utf-8&amp;quot;, errors=&amp;quot;replace&amp;quot;)&lt;br /&gt;
        timing = KeystrokeTimingEvent(&lt;br /&gt;
            session_id=event.session_id,&lt;br /&gt;
            source_index=event.source_index,&lt;br /&gt;
            timestamp_ns=event.timestamp_ns,&lt;br /&gt;
            previous_timestamp_ns=previous_timestamp,&lt;br /&gt;
            delta_ns=delta,&lt;br /&gt;
            text=text,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter.emit(timing)&lt;br /&gt;
        return timing&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
from demos.event_processors.events import TTYReadEvent&lt;br /&gt;
from demos.event_processors.keystroke_timing import KeystrokeTimingProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
timing_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.keystroke.timing&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.tty.read&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;tty-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;tty-read&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = KeystrokeTimingProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit reads and process them:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_emitter.emit(&lt;br /&gt;
    TTYReadEvent(&lt;br /&gt;
        session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
        source_index=0,&lt;br /&gt;
        timestamp_ns=1000,&lt;br /&gt;
        data=b&amp;quot;l&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_emitter.emit(&lt;br /&gt;
    TTYReadEvent(&lt;br /&gt;
        session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
        source_index=1,&lt;br /&gt;
        timestamp_ns=1100,&lt;br /&gt;
        data=b&amp;quot;s&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor.process_one()&lt;br /&gt;
processor.process_one()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeystrokeTimingEvent(... previous_timestamp_ns=None, delta_ns=None, text=&#039;l&#039;)&lt;br /&gt;
KeystrokeTimingEvent(... previous_timestamp_ns=1000, delta_ns=100, text=&#039;s&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive timing events:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
timing_receiver.receive().payload&lt;br /&gt;
timing_receiver.receive().payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeystrokeTimingEvent(... source_index=0 ...)&lt;br /&gt;
KeystrokeTimingEvent(... source_index=1 ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 9: Introduce graph events ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Switch to a small graph problem that can demonstrate feedback loops safely.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* An edge is a source fact.&lt;br /&gt;
* A path is a derived fact.&lt;br /&gt;
* A direct edge implies a direct path.&lt;br /&gt;
* Longer paths can be derived later from known paths and edges.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_events.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/graph_events.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Graph event payloads for message bus demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from dataclasses import dataclass&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class EdgeDeclaredEvent:&lt;br /&gt;
    graph_id: str&lt;br /&gt;
    source: str&lt;br /&gt;
    target: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class PathKnownEvent:&lt;br /&gt;
    graph_id: str&lt;br /&gt;
    source: str&lt;br /&gt;
    target: str&lt;br /&gt;
    hop_count: int&lt;br /&gt;
&lt;br /&gt;
    def fact_key(self) -&amp;gt; tuple[str, str, str]:&lt;br /&gt;
        result = (self.graph_id, self.source, self.target)&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge = EdgeDeclaredEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;A&amp;quot;,&lt;br /&gt;
    target=&amp;quot;B&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path = PathKnownEvent(&lt;br /&gt;
    graph_id=edge.graph_id,&lt;br /&gt;
    source=edge.source,&lt;br /&gt;
    target=edge.target,&lt;br /&gt;
    hop_count=1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge&lt;br /&gt;
path&lt;br /&gt;
path.fact_key()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
EdgeDeclaredEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;)&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;, hop_count=1)&lt;br /&gt;
(&#039;demo-graph&#039;, &#039;A&#039;, &#039;B&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 10: Create a demo-only nonblocking receive helper ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Prepare for processor scheduling without editing &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;Receiver.receive()&amp;lt;/code&amp;gt; blocks until a message is available.&lt;br /&gt;
* A scheduler needs to try a processor and move on if no message is waiting.&lt;br /&gt;
* The current package does not expose a public nonblocking receiver method.&lt;br /&gt;
* This helper is local demonstration code. It can be replaced by a real public API later.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/demo_receive.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/demo_receive.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Demo-only helpers for receiver inspection and scheduling.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from queue import Empty&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.endpoints import Receiver&lt;br /&gt;
from mbus.message.records import ReceivedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class DemoReceiverError(RuntimeError):&lt;br /&gt;
    pass&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def try_receive(receiver: Receiver) -&amp;gt; ReceivedMessage | None:&lt;br /&gt;
    queue = getattr(receiver, &amp;quot;_queue&amp;quot;, None)&lt;br /&gt;
&lt;br /&gt;
    if queue is None:&lt;br /&gt;
        raise DemoReceiverError(&lt;br /&gt;
            &amp;quot;demo try_receive requires the current direct broker receiver&amp;quot;&lt;br /&gt;
        )&lt;br /&gt;
&lt;br /&gt;
    try:&lt;br /&gt;
        message = queue.get_nowait()&lt;br /&gt;
    except Empty:&lt;br /&gt;
        message = None&lt;br /&gt;
&lt;br /&gt;
    return message&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.demo_receive import try_receive&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
try_receive(receiver)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
None&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Now emit a message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;demo nonblocking receive&amp;quot;})&lt;br /&gt;
message = try_receive(receiver)&lt;br /&gt;
&lt;br /&gt;
message&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ReceivedMessage(...)&lt;br /&gt;
{&#039;text&#039;: &#039;demo nonblocking receive&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 11: Create the direct path processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Implement the graph rule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
edge(A, B) -&amp;gt; path(A, B)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The source emits edge events.&lt;br /&gt;
* The direct path processor emits derived path events.&lt;br /&gt;
* Other processors can subscribe to derived path events.&lt;br /&gt;
* The processor can be run by hand or by a scheduler.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_reachability.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/graph_reachability.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Graph reachability processors for message bus demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.demo_receive import try_receive&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class DirectPathProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _known_paths: set[tuple[str, str, str]]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._known_paths = set()&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; PathKnownEvent | None:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        result = self._process_payload(message.payload)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
    def process_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            self._process_payload(message.payload)&lt;br /&gt;
            work_count = 1&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def _process_payload(self, payload: object) -&amp;gt; PathKnownEvent | None:&lt;br /&gt;
        if not isinstance(payload, EdgeDeclaredEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected EdgeDeclaredEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        path = PathKnownEvent(&lt;br /&gt;
            graph_id=payload.graph_id,&lt;br /&gt;
            source=payload.source,&lt;br /&gt;
            target=payload.target,&lt;br /&gt;
            hop_count=1,&lt;br /&gt;
        )&lt;br /&gt;
&lt;br /&gt;
        if path.fact_key() not in self._known_paths:&lt;br /&gt;
            self._known_paths.add(path.fact_key())&lt;br /&gt;
            self._emitter.emit(path)&lt;br /&gt;
            result = path&lt;br /&gt;
        else:&lt;br /&gt;
            result = None&lt;br /&gt;
&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
from demos.event_processors.graph_events import EdgeDeclaredEvent&lt;br /&gt;
from demos.event_processors.graph_reachability import DirectPathProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
path_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = DirectPathProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Call the processor before any edge exists:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
0&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit one edge:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;A&amp;quot;,&lt;br /&gt;
        target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the edge:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the derived path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_message = path_receiver.receive()&lt;br /&gt;
path_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;, hop_count=1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 12: Explore the path extension rule by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Understand the feedback rule before implementing it.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* If &amp;lt;code&amp;gt;A&amp;lt;/code&amp;gt; reaches &amp;lt;code&amp;gt;B&amp;lt;/code&amp;gt;, and there is an edge from &amp;lt;code&amp;gt;B&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;C&amp;lt;/code&amp;gt;, then &amp;lt;code&amp;gt;A&amp;lt;/code&amp;gt; reaches &amp;lt;code&amp;gt;C&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Derived path facts can lead to more derived path facts.&lt;br /&gt;
* Duplicate prevention keeps the feedback loop from repeating the same fact forever.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_bc = EdgeDeclaredEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;B&amp;quot;,&lt;br /&gt;
    target=&amp;quot;C&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path_ab = PathKnownEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;A&amp;quot;,&lt;br /&gt;
    target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    hop_count=1,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Check whether the path and edge connect:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_ab.target&lt;br /&gt;
edge_bc.source&lt;br /&gt;
path_ab.target == edge_bc.source&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;B&#039;&lt;br /&gt;
&#039;B&#039;&lt;br /&gt;
True&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Derive a new path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_ac = PathKnownEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=path_ab.source,&lt;br /&gt;
    target=edge_bc.target,&lt;br /&gt;
    hop_count=path_ab.hop_count + 1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path_ac&lt;br /&gt;
path_ac.fact_key()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;C&#039;, hop_count=2)&lt;br /&gt;
(&#039;demo-graph&#039;, &#039;A&#039;, &#039;C&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Manually prevent duplicate path facts:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
known_paths = set()&lt;br /&gt;
known_paths.add(path_ab.fact_key())&lt;br /&gt;
&lt;br /&gt;
path_ac.fact_key() in known_paths&lt;br /&gt;
&lt;br /&gt;
if path_ac.fact_key() not in known_paths:&lt;br /&gt;
    known_paths.add(path_ac.fact_key())&lt;br /&gt;
&lt;br /&gt;
known_paths&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
False&lt;br /&gt;
{(&#039;demo-graph&#039;, &#039;A&#039;, &#039;B&#039;), (&#039;demo-graph&#039;, &#039;A&#039;, &#039;C&#039;)}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 13: Add the path extension processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Implement the feedback rule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
path(A, B) + edge(B, C) -&amp;gt; path(A, C)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The extension processor listens to edge events and path events.&lt;br /&gt;
* It emits new path events when a known path can be extended.&lt;br /&gt;
* It also listens to its own output because an extended path may be extendable again.&lt;br /&gt;
* This is a controlled feedback loop.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Append this class to:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_reachability.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
class PathExtensionProcessor:&lt;br /&gt;
    _edge_receiver: Receiver&lt;br /&gt;
    _direct_path_receiver: Receiver&lt;br /&gt;
    _extension_path_receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _edges: set[tuple[str, str, str]]&lt;br /&gt;
    _paths: dict[tuple[str, str, str], PathKnownEvent]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._edge_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._direct_path_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._extension_path_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._edges = set()&lt;br /&gt;
        self._paths = {}&lt;br /&gt;
&lt;br /&gt;
    def process_edge_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._edge_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_edge_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def process_direct_path_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._direct_path_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_path_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def process_extension_path_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._extension_path_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_path_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def _process_edge_payload(&lt;br /&gt;
        self,&lt;br /&gt;
        payload: object,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        if not isinstance(payload, EdgeDeclaredEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected EdgeDeclaredEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        edge = payload&lt;br /&gt;
        self._edges.add((edge.graph_id, edge.source, edge.target))&lt;br /&gt;
        paths = self._derive_from_new_edge(edge)&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _process_path_payload(&lt;br /&gt;
        self,&lt;br /&gt;
        payload: object,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        if not isinstance(payload, PathKnownEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected PathKnownEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        path = payload&lt;br /&gt;
        self._paths[path.fact_key()] = path&lt;br /&gt;
        paths = self._derive_from_new_path(path)&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _derive_from_new_edge(&lt;br /&gt;
        self,&lt;br /&gt;
        edge: EdgeDeclaredEvent,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        paths = []&lt;br /&gt;
&lt;br /&gt;
        for known_path in self._paths.values():&lt;br /&gt;
            if known_path.graph_id != edge.graph_id:&lt;br /&gt;
                continue&lt;br /&gt;
            if known_path.target != edge.source:&lt;br /&gt;
                continue&lt;br /&gt;
&lt;br /&gt;
            next_path = PathKnownEvent(&lt;br /&gt;
                graph_id=edge.graph_id,&lt;br /&gt;
                source=known_path.source,&lt;br /&gt;
                target=edge.target,&lt;br /&gt;
                hop_count=known_path.hop_count + 1,&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
            if self._emit_if_new(next_path):&lt;br /&gt;
                paths.append(next_path)&lt;br /&gt;
&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _derive_from_new_path(&lt;br /&gt;
        self,&lt;br /&gt;
        path: PathKnownEvent,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        paths = []&lt;br /&gt;
&lt;br /&gt;
        for graph_id, edge_source, edge_target in self._edges:&lt;br /&gt;
            if graph_id != path.graph_id:&lt;br /&gt;
                continue&lt;br /&gt;
            if edge_source != path.target:&lt;br /&gt;
                continue&lt;br /&gt;
&lt;br /&gt;
            next_path = PathKnownEvent(&lt;br /&gt;
                graph_id=path.graph_id,&lt;br /&gt;
                source=path.source,&lt;br /&gt;
                target=edge_target,&lt;br /&gt;
                hop_count=path.hop_count + 1,&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
            if self._emit_if_new(next_path):&lt;br /&gt;
                paths.append(next_path)&lt;br /&gt;
&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _emit_if_new(self, path: PathKnownEvent) -&amp;gt; bool:&lt;br /&gt;
        if path.fact_key() in self._paths:&lt;br /&gt;
            result = False&lt;br /&gt;
        else:&lt;br /&gt;
            self._paths[path.fact_key()] = path&lt;br /&gt;
            self._emitter.emit(path)&lt;br /&gt;
            result = True&lt;br /&gt;
&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_events import EdgeDeclaredEvent&lt;br /&gt;
from demos.event_processors.graph_reachability import (&lt;br /&gt;
    DirectPathProcessor,&lt;br /&gt;
    PathExtensionProcessor,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
extension_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
direct_processor = DirectPathProcessor(bus)&lt;br /&gt;
extension_processor = PathExtensionProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit two edges in fixed order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;A&amp;quot;,&lt;br /&gt;
        target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;B&amp;quot;,&lt;br /&gt;
        target=&amp;quot;C&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the direct paths:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
direct_processor.process_available()&lt;br /&gt;
direct_processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Let the extension processor learn the edges:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
extension_processor.process_edge_available()&lt;br /&gt;
extension_processor.process_edge_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Let the extension processor learn the direct paths:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
extension_processor.process_direct_path_available()&lt;br /&gt;
extension_processor.process_direct_path_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The second call may emit the extended &amp;lt;code&amp;gt;A -&amp;gt; C&amp;lt;/code&amp;gt; path after the processor has seen both direct paths and both edges.&lt;br /&gt;
&lt;br /&gt;
Receive the extended path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
message = extension_receiver.receive()&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;C&#039;, hop_count=2)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect captured graph path history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
path_messages = history.by_topic(&amp;quot;demo.graph.path&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
for message in path_messages:&lt;br /&gt;
    path = message.payload&lt;br /&gt;
    print(&lt;br /&gt;
        message.bus_sequence,&lt;br /&gt;
        message.msg_producer,&lt;br /&gt;
        path.source,&lt;br /&gt;
        path.target,&lt;br /&gt;
        path.hop_count,&lt;br /&gt;
    )&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
... direct-path A B 1&lt;br /&gt;
... direct-path B C 1&lt;br /&gt;
... path-extension A C 2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 14: Add a randomized processor scheduler ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Simulate concurrency effects without changing source input order.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The source will emit the same edge events in the same order every run.&lt;br /&gt;
* The scheduler varies which processor gets a chance to run first.&lt;br /&gt;
* This simulates uncertainty from processing time, scheduling priority, startup timing, or transmission delay.&lt;br /&gt;
* The graph example should converge to the same final path facts even when processor scheduling, and hence the order of intermediate derived processor events, differs.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/runners.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/runners.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Small processor runners for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from collections.abc import Callable&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
from random import Random&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class ProcessorStep:&lt;br /&gt;
    name: str&lt;br /&gt;
    run: Callable[[], int]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class RandomProcessorRunner:&lt;br /&gt;
    steps: tuple[ProcessorStep, ...]&lt;br /&gt;
    seed: int&lt;br /&gt;
    max_rounds: int = 30&lt;br /&gt;
    trace: list[tuple[int, str, int]] = field(default_factory=list)&lt;br /&gt;
&lt;br /&gt;
    def run_until_quiet(self) -&amp;gt; list[tuple[int, str, int]]:&lt;br /&gt;
        rng = Random(self.seed)&lt;br /&gt;
&lt;br /&gt;
        for round_index in range(self.max_rounds):&lt;br /&gt;
            work_count = self._run_round(rng, round_index)&lt;br /&gt;
&lt;br /&gt;
            if work_count == 0:&lt;br /&gt;
                break&lt;br /&gt;
&lt;br /&gt;
        result = list(self.trace)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
    def _run_round(self, rng: Random, round_index: int) -&amp;gt; int:&lt;br /&gt;
        steps = list(self.steps)&lt;br /&gt;
        rng.shuffle(steps)&lt;br /&gt;
        round_work_count = 0&lt;br /&gt;
&lt;br /&gt;
        for step in steps:&lt;br /&gt;
            step_work_count = step.run()&lt;br /&gt;
            self.trace.append((round_index, step.name, step_work_count))&lt;br /&gt;
            round_work_count += step_work_count&lt;br /&gt;
&lt;br /&gt;
        return round_work_count&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then inspect how a runner can shuffle placeholder steps:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.runners import (&lt;br /&gt;
    ProcessorStep,&lt;br /&gt;
    RandomProcessorRunner,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
calls = []&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def first_step() -&amp;gt; int:&lt;br /&gt;
    calls.append(&amp;quot;first&amp;quot;)&lt;br /&gt;
    return 0&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def second_step() -&amp;gt; int:&lt;br /&gt;
    calls.append(&amp;quot;second&amp;quot;)&lt;br /&gt;
    return 0&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
runner = RandomProcessorRunner(&lt;br /&gt;
    steps=(&lt;br /&gt;
        ProcessorStep(name=&amp;quot;first&amp;quot;, run=first_step),&lt;br /&gt;
        ProcessorStep(name=&amp;quot;second&amp;quot;, run=second_step),&lt;br /&gt;
    ),&lt;br /&gt;
    seed=1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
runner.run_until_quiet()&lt;br /&gt;
calls&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, &#039;...&#039;, 0), (0, &#039;...&#039;, 0)]&lt;br /&gt;
[&#039;...&#039;, &#039;...&#039;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 15: Run the graph example with randomized scheduling ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Run the same graph input under different processor schedules and compare the final path facts.&lt;br /&gt;
&lt;br /&gt;
; Interpreter setup&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_reachability import (&lt;br /&gt;
    DirectPathProcessor,&lt;br /&gt;
    PathExtensionProcessor,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.runners import (&lt;br /&gt;
    ProcessorStep,&lt;br /&gt;
    RandomProcessorRunner,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Define a helper function in the interpreter:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
def run_graph_demo(seed: int) -&amp;gt; tuple[&lt;br /&gt;
    list[tuple[int, str, int]],&lt;br /&gt;
    list[tuple[int, str, str, int]],&lt;br /&gt;
]:&lt;br /&gt;
    bus = DirectMessageBus()&lt;br /&gt;
    sink = AOStoreCaptureSink()&lt;br /&gt;
    bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
    edge_emitter = bus.register_emitter(&lt;br /&gt;
        msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
        msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
        default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    direct_processor = DirectPathProcessor(bus)&lt;br /&gt;
    extension_processor = PathExtensionProcessor(bus)&lt;br /&gt;
&lt;br /&gt;
    edges = (&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;A&amp;quot;,&lt;br /&gt;
            target=&amp;quot;B&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;B&amp;quot;,&lt;br /&gt;
            target=&amp;quot;C&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;C&amp;quot;,&lt;br /&gt;
            target=&amp;quot;D&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    for edge in edges:&lt;br /&gt;
        edge_emitter.emit(edge)&lt;br /&gt;
&lt;br /&gt;
    steps = (&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;direct path from edge&amp;quot;,&lt;br /&gt;
            run=direct_processor.process_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns edge&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_edge_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns direct path&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_direct_path_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns extension path&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_extension_path_available,&lt;br /&gt;
        ),&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    runner = RandomProcessorRunner(&lt;br /&gt;
        steps=steps,&lt;br /&gt;
        seed=seed,&lt;br /&gt;
    )&lt;br /&gt;
    trace = runner.run_until_quiet()&lt;br /&gt;
&lt;br /&gt;
    history = CaptureHistory(sink.all_records())&lt;br /&gt;
    path_facts = []&lt;br /&gt;
&lt;br /&gt;
    for message in history.by_topic(&amp;quot;demo.graph.path&amp;quot;):&lt;br /&gt;
        path = message.payload&lt;br /&gt;
        if isinstance(path, PathKnownEvent):&lt;br /&gt;
            fact = (&lt;br /&gt;
                message.bus_sequence,&lt;br /&gt;
                path.source,&lt;br /&gt;
                path.target,&lt;br /&gt;
                path.hop_count,&lt;br /&gt;
            )&lt;br /&gt;
            path_facts.append(fact)&lt;br /&gt;
&lt;br /&gt;
    result = (trace, path_facts)&lt;br /&gt;
    return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Run one schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_1, paths_1 = run_graph_demo(1)&lt;br /&gt;
&lt;br /&gt;
trace_1&lt;br /&gt;
paths_1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, &#039;...&#039;, ...), (0, &#039;...&#039;, ...), ...]&lt;br /&gt;
[(..., &#039;A&#039;, &#039;B&#039;, 1), (..., &#039;B&#039;, &#039;C&#039;, 1), (..., &#039;C&#039;, &#039;D&#039;, 1), ...]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Run another schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_2, paths_2 = run_graph_demo(2)&lt;br /&gt;
&lt;br /&gt;
trace_2&lt;br /&gt;
paths_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the schedules:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_1 == trace_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
False&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the final path facts without bus sequence numbers:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
final_paths_1 = {&lt;br /&gt;
    (source, target, hop_count)&lt;br /&gt;
    for _, source, target, hop_count in paths_1&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
final_paths_2 = {&lt;br /&gt;
    (source, target, hop_count)&lt;br /&gt;
    for _, source, target, hop_count in paths_2&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
final_paths_1&lt;br /&gt;
final_paths_2&lt;br /&gt;
final_paths_1 == final_paths_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
{(&#039;A&#039;, &#039;B&#039;, 1), (&#039;B&#039;, &#039;C&#039;, 1), (&#039;C&#039;, &#039;D&#039;, 1), (&#039;A&#039;, &#039;C&#039;, 2), (&#039;B&#039;, &#039;D&#039;, 2), (&#039;A&#039;, &#039;D&#039;, 3)}&lt;br /&gt;
{(&#039;A&#039;, &#039;B&#039;, 1), (&#039;B&#039;, &#039;C&#039;, 1), (&#039;C&#039;, &#039;D&#039;, 1), (&#039;A&#039;, &#039;C&#039;, 2), (&#039;B&#039;, &#039;D&#039;, 2), (&#039;A&#039;, &#039;D&#039;, 3)}&lt;br /&gt;
True&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for round_index, step_name, work_count in trace_1:&lt;br /&gt;
    print(round_index, step_name, work_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first captured path order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for bus_sequence, source, target, hop_count in paths_1:&lt;br /&gt;
    print(bus_sequence, source, target, hop_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the second schedule and path order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for round_index, step_name, work_count in trace_2:&lt;br /&gt;
    print(round_index, step_name, work_count)&lt;br /&gt;
&lt;br /&gt;
for bus_sequence, source, target, hop_count in paths_2:&lt;br /&gt;
    print(bus_sequence, source, target, hop_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The source input order did not change. The processor scheduling changed. The captured event order may differ. The final reachability facts should match.&lt;br /&gt;
&lt;br /&gt;
This models an important distributed-systems lesson: event history order and final derived state are related, but they are not the same thing.&lt;br /&gt;
&lt;br /&gt;
== Phase 16: Later refinement: graph index from history ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Introduce restart/recovery thinking only after the basic graph feedback example is familiar.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The first graph processors keep local working state.&lt;br /&gt;
* Local working state is enough for a short demonstration.&lt;br /&gt;
* Longer-lived processors need recovery behavior after restart.&lt;br /&gt;
* Since edge and path facts are captured as events, a graph index can later be rebuilt from history.&lt;br /&gt;
* This points toward projection-style services without changing the basic processor model.&lt;br /&gt;
&lt;br /&gt;
; Suggested later file&lt;br /&gt;
&lt;br /&gt;
Create later:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_index.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Suggested responsibilities:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
read captured history&lt;br /&gt;
collect EdgeDeclaredEvent payloads&lt;br /&gt;
collect PathKnownEvent payloads&lt;br /&gt;
answer whether a path fact exists&lt;br /&gt;
find edges starting at a node&lt;br /&gt;
find paths ending at a node&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This refinement belongs after the team has already run the graph reachability demo successfully.&lt;br /&gt;
&lt;br /&gt;
== Phase 17: Later refinement: shell command reconstruction ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Connect the TTY examples back to the shell command event shape used by regex milestones.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; is the high-level event consumed by the regex milestone processor.&lt;br /&gt;
* &amp;lt;code&amp;gt;TTYReadEvent&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;TTYWriteEvent&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;LineDisciplineEvent&amp;lt;/code&amp;gt; are lower-level observations.&lt;br /&gt;
* A reconstructor can subscribe to line-discipline events and query recent related observations.&lt;br /&gt;
* The reconstructor should emit shell command events; it should not perform regex analysis itself.&lt;br /&gt;
&lt;br /&gt;
; Suggested responsibilities&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
listen for completed line-discipline events&lt;br /&gt;
query history since the previous command boundary for the same session&lt;br /&gt;
collect matching reads, writes, and timing events&lt;br /&gt;
construct ShellCommandEvent&lt;br /&gt;
emit ShellCommandEvent on demo.shell.command&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This is an appropriate follow-up exercise after the team understands both regex milestone processing and history queries.&lt;br /&gt;
&lt;br /&gt;
== Related ==&lt;br /&gt;
&lt;br /&gt;
=== Top-level topic page ===&lt;br /&gt;
[[Message Bus Service]]&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=Message_Bus_Service&amp;diff=930</id>
		<title>Message Bus Service</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=Message_Bus_Service&amp;diff=930"/>
		<updated>2026-06-16T04:07:59Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: Created page with &amp;quot;Module repository and wiki documentation are pending. This is a placeholder topic page to index resources related to the development of the message bus service.  == Related ==  === Project Charter === Message Bus Project Charter  === Examples, Tutorials, Supporting Documents === Message Bus Live Coding Exercise&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Module repository and wiki documentation are pending. This is a placeholder topic page to index resources related to the development of the message bus service.&lt;br /&gt;
&lt;br /&gt;
== Related ==&lt;br /&gt;
&lt;br /&gt;
=== Project Charter ===&lt;br /&gt;
[[Message Bus Project Charter]]&lt;br /&gt;
&lt;br /&gt;
=== Examples, Tutorials, Supporting Documents ===&lt;br /&gt;
[[Message Bus Live Coding Exercise]]&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=Message_Bus_Live_Coding_Exercise&amp;diff=929</id>
		<title>Message Bus Live Coding Exercise</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=Message_Bus_Live_Coding_Exercise&amp;diff=929"/>
		<updated>2026-06-16T04:06:29Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: Created page with &amp;quot;= Introduction =  == Audience and scope ==  This exercise is intended for EDURange developers learning how to write event processors with the message bus.  The packaged &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; modules are treated as read-only for this exercise. All new code belongs under a demonstration directory such as:  &amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt; workspace/     mbus/     aostore/     demos/         __init__.py         event_processors/             __init__.py &amp;lt;/pre&amp;gt;  The exe...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Introduction =&lt;br /&gt;
&lt;br /&gt;
== Audience and scope ==&lt;br /&gt;
&lt;br /&gt;
This exercise is intended for EDURange developers learning how to write event processors with the message bus.&lt;br /&gt;
&lt;br /&gt;
The packaged &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; modules are treated as read-only for this exercise. All new code belongs under a demonstration directory such as:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
workspace/&lt;br /&gt;
    mbus/&lt;br /&gt;
    aostore/&lt;br /&gt;
    demos/&lt;br /&gt;
        __init__.py&lt;br /&gt;
        event_processors/&lt;br /&gt;
            __init__.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The exercise uses two problem families.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;1. Shell and TTY event processing&#039;&#039;&#039;&lt;br /&gt;
** Send and receive messages through the bus.&lt;br /&gt;
** Capture and inspect message history.&lt;br /&gt;
** Represent high-level shell command events.&lt;br /&gt;
** Recreate the regex-based milestone role in a simplified form.&lt;br /&gt;
** Mock up TTY-derived observations such as keystroke timing.&lt;br /&gt;
* &#039;&#039;&#039;2. Graph event processing&#039;&#039;&#039;&lt;br /&gt;
** Represent graph edge relationships as source events.&lt;br /&gt;
** Derive direct path facts from edge relation events.&lt;br /&gt;
** Add feedback by deriving longer paths from known paths.&lt;br /&gt;
** Simulate nondeterministic processor scheduling while keeping source input fixed.&lt;br /&gt;
&lt;br /&gt;
The shell examples draw from EDURange&#039;s institutional knowledge. The graph examples provide a compact computer science model for safe feedback loops.&lt;br /&gt;
&lt;br /&gt;
== Running the examples ==&lt;br /&gt;
&lt;br /&gt;
Adjust paths to match the local checkout layout if necessary. Start an interpreter from the workspace root with project packages available.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
PYTHONPATH=&amp;quot;$PWD/mbus:$PWD/aostore:$PWD&amp;quot; python3.14&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
When module files are changed, restart the interpreter unless the group is deliberately practicing &amp;lt;code&amp;gt;importlib.reload()&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Create the demonstration package ==&lt;br /&gt;
&lt;br /&gt;
Before the first durable file edit, create the demonstration package directories:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir -p demos/event_processors&lt;br /&gt;
touch demos/__init__.py&lt;br /&gt;
touch demos/event_processors/__init__.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
== Important note about capture records ==&lt;br /&gt;
&lt;br /&gt;
The current demonstration works close to the low-level capture layer. As a result, it exposes &amp;lt;code&amp;gt;MessageSymbolRegistration&amp;lt;/code&amp;gt; records while building the temporary history helper.&lt;br /&gt;
&lt;br /&gt;
That is not the intended ordinary user experience. In a more complete release, higher-level capture and history infrastructure should handle symbol registration bookkeeping on behalf of processor authors. In this exercise, symbol registration records are visible because the demonstration history helper reconstructs readable messages from compact captured records directly.&lt;br /&gt;
&lt;br /&gt;
== Suggested group session flow ==&lt;br /&gt;
&lt;br /&gt;
; Session 1 - Bus and capture basics&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 1:&#039;&#039;&#039;&lt;br /&gt;
** Signal over the bus by hand.&lt;br /&gt;
** Inspect received messages.&lt;br /&gt;
** Inspect low-level captured records.&lt;br /&gt;
* &#039;&#039;&#039;Phase 2:&#039;&#039;&#039;&lt;br /&gt;
** Discuss why symbol registration appears in this temporary demo.&lt;br /&gt;
** Build the capture history helper.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1-3 hours (1-2 hours work, 0.5-1 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 2 - Shell command and regex milestones&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 3:&#039;&#039;&#039;&lt;br /&gt;
** Represent a shell command as a dictionary.&lt;br /&gt;
* &#039;&#039;&#039;Phase 4:&#039;&#039;&#039;&lt;br /&gt;
** Replace it with &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Phase 5:&#039;&#039;&#039;&lt;br /&gt;
** Manually evaluate regex milestone expressions.&lt;br /&gt;
* &#039;&#039;&#039;Phase 6:&#039;&#039;&#039;&lt;br /&gt;
** Implement &amp;lt;code&amp;gt;RegexMilestoneProcessor&amp;lt;/code&amp;gt;.&lt;br /&gt;
** Inspect source and analysis events in history.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3 hours (1.5-2.5 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 3 - TTY-derived observations&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 7:&#039;&#039;&#039;&lt;br /&gt;
** Create raw TTY read, write, and line events.&lt;br /&gt;
** Show why regex milestones need reconstructed shell command events.&lt;br /&gt;
* &#039;&#039;&#039;Phase 8:&#039;&#039;&#039;&lt;br /&gt;
** Implement keystroke timing.&lt;br /&gt;
** Discuss shell command reconstruction as the next derived-event problem (to be continued in Session 6 onward).&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1-2.5 hours (1-2 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 4 - Graph-derived facts&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 9:&#039;&#039;&#039;&lt;br /&gt;
** Define graph edges and paths.&lt;br /&gt;
* &#039;&#039;&#039;Phase 10:&#039;&#039;&#039;&lt;br /&gt;
** Add the demo-only nonblocking receive helper.&lt;br /&gt;
* &#039;&#039;&#039;Phase 11:&#039;&#039;&#039;&lt;br /&gt;
** Implement direct path derivation.&lt;br /&gt;
* &#039;&#039;&#039;Phase 12:&#039;&#039;&#039;&lt;br /&gt;
** Explore the extension rule by hand.&lt;br /&gt;
* &#039;&#039;&#039;Phase 13:&#039;&#039;&#039;&lt;br /&gt;
** Add path extension feedback.&lt;br /&gt;
** Discuss duplicate prevention.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3.5 hours (1.5-3 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 5 - Nondeterministic processor scheduling&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 14:&#039;&#039;&#039;&lt;br /&gt;
** Add the randomized processor runner.&lt;br /&gt;
* &#039;&#039;&#039;Phase 15:&#039;&#039;&#039;&lt;br /&gt;
** Keep source events fixed.&lt;br /&gt;
** Vary processor scheduling by seed.&lt;br /&gt;
** Compare final path facts across runs.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-3.5 hours (1.5-3 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
; Session 6 - Follow-up refinements&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Phase 16:&#039;&#039;&#039;&lt;br /&gt;
** Rebuild a graph index from history.&lt;br /&gt;
* &#039;&#039;&#039;Phase 17:&#039;&#039;&#039;&lt;br /&gt;
** Implement shell command reconstruction.&lt;br /&gt;
** Replace local Python payload assumptions with explicit payload projection formats when the bus format layer is ready for the exercise.&lt;br /&gt;
&lt;br /&gt;
Estimated time: 1.5-4.5 hours (1.5-4 hours work, 0-0.5 hours setup)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
= Event Processor Demonstration Plan =&lt;br /&gt;
&lt;br /&gt;
== Phase 1: Signal over the bus by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Send one message through the direct bus and inspect what the receiver sees.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* A bus is useful only when something emits and something receives.&lt;br /&gt;
* Public message setup uses readable strings: topic, producer, and message type.&lt;br /&gt;
* The direct broker delivers local Python payloads in this demonstration.&lt;br /&gt;
* Capture must be attached before delivery in the current prototype.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Import the bus and capture interfaces:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.capture.sink import CaptureSink&lt;br /&gt;
from mbus.message.records import CapturedMessage&lt;br /&gt;
from mbus.message.symbols import MessageSymbolRegistration&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create a temporary capture sink in the interpreter. This sink stores whatever the bus sends to the capture layer:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
CapturedRecord = MessageSymbolRegistration | CapturedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class ListCaptureSink(CaptureSink):&lt;br /&gt;
    records: list[CapturedRecord] = field(default_factory=list)&lt;br /&gt;
&lt;br /&gt;
    def capture_symbol_registration(&lt;br /&gt;
        self,&lt;br /&gt;
        registration: MessageSymbolRegistration,&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self.records.append(registration)&lt;br /&gt;
&lt;br /&gt;
    def capture(self, message: CapturedMessage) -&amp;gt; None:&lt;br /&gt;
        self.records.append(message)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create the bus and attach capture:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = ListCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the empty capture sink:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
sink&lt;br /&gt;
sink.records&lt;br /&gt;
len(sink.records)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ListCaptureSink(records=[])&lt;br /&gt;
[]&lt;br /&gt;
0&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create a receiver. This describes the messages the receiver wants to hear:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the receiver and confirm that capture is still empty:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
type(receiver)&lt;br /&gt;
vars(receiver)&lt;br /&gt;
sink.records&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;class &#039;mbus.broker.direct._BrokerReceiver&#039;&amp;gt;&lt;br /&gt;
{&#039;_queue&#039;: ...}&lt;br /&gt;
[]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create an emitter. Registering the emitter gives the bus enough information to bind readable names to compact internal IDs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the captured registration records:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
sink.records&lt;br /&gt;
[type(record).__name__ for record in sink.records]&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    print(record)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[&#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;]&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;demo.signals&#039; ...)&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;ping&#039; ...)&lt;br /&gt;
MessageSymbolRegistration(... symbol=&#039;demo-source&#039; ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit one message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;hello bus&amp;quot;})&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect capture before receiving:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
len(sink.records)&lt;br /&gt;
[type(record).__name__ for record in sink.records]&lt;br /&gt;
sink.records[-1]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
4&lt;br /&gt;
[&#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;MessageSymbolRegistration&#039;, &#039;CapturedMessage&#039;]&lt;br /&gt;
CapturedMessage(... payload={&#039;text&#039;: &#039;hello bus&#039;} ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
message = receiver.receive()&lt;br /&gt;
&lt;br /&gt;
message&lt;br /&gt;
message.msg_topic&lt;br /&gt;
message.msg_producer&lt;br /&gt;
message.msg_type&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ReceivedMessage(...)&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&#039;demo-source&#039;&lt;br /&gt;
&#039;ping&#039;&lt;br /&gt;
{&#039;text&#039;: &#039;hello bus&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the received message with the captured record:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured = sink.records[-1]&lt;br /&gt;
&lt;br /&gt;
captured&lt;br /&gt;
captured.msg_topic_id&lt;br /&gt;
captured.msg_type_id&lt;br /&gt;
captured.msg_producer_id&lt;br /&gt;
captured.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
CapturedMessage(...)&lt;br /&gt;
TopicID(...)&lt;br /&gt;
MessageTypeID(...)&lt;br /&gt;
ProducerID(...)&lt;br /&gt;
{&#039;text&#039;: &#039;hello bus&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The receiver gets the readable message view. The capture sink sees compact IDs and local payload data. The readable names are still available through symbol registration records, but manually reconstructing them is awkward.&lt;br /&gt;
&lt;br /&gt;
That awkwardness motivates the next phase.&lt;br /&gt;
&lt;br /&gt;
== Phase 2: Build a temporary capture history helper ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a small demonstration helper that turns captured records back into readable history messages.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Capture records are useful, but raw captured messages use compact IDs.&lt;br /&gt;
* A history helper can rebuild readable message history from registration records.&lt;br /&gt;
* This is demonstration infrastructure, not the final persistence/query API.&lt;br /&gt;
* The helper writes captured records into an in-memory &amp;lt;code&amp;gt;aostore&amp;lt;/code&amp;gt; sequential unit.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration before the file edit&lt;br /&gt;
&lt;br /&gt;
List only the captured messages:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured_messages = []&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    if isinstance(record, CapturedMessage):&lt;br /&gt;
        captured_messages.append(record)&lt;br /&gt;
&lt;br /&gt;
captured_messages&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[CapturedMessage(... payload={&#039;text&#039;: &#039;hello bus&#039;} ...)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first captured message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
captured_message = captured_messages[0]&lt;br /&gt;
&lt;br /&gt;
captured_message.msg_topic_id&lt;br /&gt;
captured_message.msg_type_id&lt;br /&gt;
captured_message.msg_producer_id&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
TopicID(...)&lt;br /&gt;
MessageTypeID(...)&lt;br /&gt;
ProducerID(...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Manually reconstruct a topic name:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
topic_names = {}&lt;br /&gt;
&lt;br /&gt;
for record in sink.records:&lt;br /&gt;
    if isinstance(record, MessageSymbolRegistration):&lt;br /&gt;
        if record.symbol_kind.name == &amp;quot;TOPIC&amp;quot;:&lt;br /&gt;
            topic_names[record.symbol_id] = record.symbol&lt;br /&gt;
&lt;br /&gt;
topic_names&lt;br /&gt;
topic_names[captured_message.msg_topic_id]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
{TopicID(...): &#039;demo.signals&#039;}&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This works, but repeating it by hand would distract from writing event processors.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/capture_history.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/capture_history.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Capture and history helpers for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from collections.abc import Iterable&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
&lt;br /&gt;
from aostore.sequentialunit.listsequentialunit import ListSequentialUnit&lt;br /&gt;
from mbus.capture.sink import CaptureSink&lt;br /&gt;
from mbus.message.records import CapturedMessage&lt;br /&gt;
from mbus.message.symbols import (&lt;br /&gt;
    MessageSymbolKind,&lt;br /&gt;
    MessageSymbolRegistration,&lt;br /&gt;
    MessageTypeID,&lt;br /&gt;
    ProducerID,&lt;br /&gt;
    TopicID,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
CapturedRecord = MessageSymbolRegistration | CapturedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class AOStoreCaptureSink(CaptureSink):&lt;br /&gt;
    records: ListSequentialUnit[CapturedRecord] = field(&lt;br /&gt;
        default_factory=ListSequentialUnit&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    def capture_symbol_registration(&lt;br /&gt;
        self,&lt;br /&gt;
        registration: MessageSymbolRegistration,&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self.records.append(registration)&lt;br /&gt;
&lt;br /&gt;
    def capture(self, message: CapturedMessage) -&amp;gt; None:&lt;br /&gt;
        self.records.append(message)&lt;br /&gt;
&lt;br /&gt;
    def all_records(self) -&amp;gt; tuple[CapturedRecord, ...]:&lt;br /&gt;
        records = self.records.sequential_read(0, len(self.records))&lt;br /&gt;
        result = tuple(records)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class HistoryMessage:&lt;br /&gt;
    msg_topic: str&lt;br /&gt;
    msg_type: str&lt;br /&gt;
    msg_producer: str&lt;br /&gt;
    payload: object&lt;br /&gt;
    bus_sequence: int&lt;br /&gt;
    topic_sequence: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class CaptureHistory:&lt;br /&gt;
    _records: tuple[CapturedRecord, ...]&lt;br /&gt;
    _topics: dict[TopicID, str]&lt;br /&gt;
    _types: dict[MessageTypeID, str]&lt;br /&gt;
    _producers: dict[ProducerID, str]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, records: Iterable[CapturedRecord]) -&amp;gt; None:&lt;br /&gt;
        self._records = tuple(records)&lt;br /&gt;
        self._topics = {}&lt;br /&gt;
        self._types = {}&lt;br /&gt;
        self._producers = {}&lt;br /&gt;
        self._index_symbols()&lt;br /&gt;
&lt;br /&gt;
    def _index_symbols(self) -&amp;gt; None:&lt;br /&gt;
        for record in self._records:&lt;br /&gt;
            if isinstance(record, MessageSymbolRegistration):&lt;br /&gt;
                self._record_symbol(record)&lt;br /&gt;
&lt;br /&gt;
    def _record_symbol(self, record: MessageSymbolRegistration) -&amp;gt; None:&lt;br /&gt;
        if record.symbol_kind is MessageSymbolKind.TOPIC:&lt;br /&gt;
            self._topics[record.symbol_id] = record.symbol&lt;br /&gt;
        elif record.symbol_kind is MessageSymbolKind.MSG_TYPE:&lt;br /&gt;
            self._types[record.symbol_id] = record.symbol&lt;br /&gt;
        elif record.symbol_kind is MessageSymbolKind.PRODUCER:&lt;br /&gt;
            self._producers[record.symbol_id] = record.symbol&lt;br /&gt;
&lt;br /&gt;
    def messages(self) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for record in self._records:&lt;br /&gt;
            if isinstance(record, CapturedMessage):&lt;br /&gt;
                message = self._decode_message(record)&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def by_topic(self, msg_topic: str) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for message in self.messages():&lt;br /&gt;
            if message.msg_topic == msg_topic:&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def since(self, bus_sequence: int) -&amp;gt; list[HistoryMessage]:&lt;br /&gt;
        messages = []&lt;br /&gt;
        for message in self.messages():&lt;br /&gt;
            if message.bus_sequence &amp;gt;= bus_sequence:&lt;br /&gt;
                messages.append(message)&lt;br /&gt;
        return messages&lt;br /&gt;
&lt;br /&gt;
    def _decode_message(self, message: CapturedMessage) -&amp;gt; HistoryMessage:&lt;br /&gt;
        history_message = HistoryMessage(&lt;br /&gt;
            msg_topic=self._topics[message.msg_topic_id],&lt;br /&gt;
            msg_type=self._types[message.msg_type_id],&lt;br /&gt;
            msg_producer=self._producers[message.msg_producer_id],&lt;br /&gt;
            payload=message.payload,&lt;br /&gt;
            bus_sequence=message.bus_sequence,&lt;br /&gt;
            topic_sequence=message.topic_sequence,&lt;br /&gt;
        )&lt;br /&gt;
        return history_message&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;history helper&amp;quot;})&lt;br /&gt;
message = receiver.receive()&lt;br /&gt;
&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
history.messages()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[HistoryMessage(msg_topic=&#039;demo.signals&#039;, msg_type=&#039;ping&#039;, msg_producer=&#039;demo-source&#039;, payload={&#039;text&#039;: &#039;history helper&#039;}, ...)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the decoded history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history_message = history.messages()[0]&lt;br /&gt;
&lt;br /&gt;
history_message.msg_topic&lt;br /&gt;
history_message.msg_type&lt;br /&gt;
history_message.msg_producer&lt;br /&gt;
history_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;demo.signals&#039;&lt;br /&gt;
&#039;ping&#039;&lt;br /&gt;
&#039;demo-source&#039;&lt;br /&gt;
{&#039;text&#039;: &#039;history helper&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 3: Explore shell command data by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Identify the high-level event shape needed by a regex milestone processor.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The old milestone strategy operated on command-level records.&lt;br /&gt;
* A command-level record includes context such as host and working directory.&lt;br /&gt;
* It also includes shell input and shell output.&lt;br /&gt;
* Raw TTY events do not directly have this shape; reconstruction comes later.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Represent a shell command as a dictionary:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = {&lt;br /&gt;
    &amp;quot;session_id&amp;quot;: &amp;quot;session-1&amp;quot;,&lt;br /&gt;
    &amp;quot;command_index&amp;quot;: 0,&lt;br /&gt;
    &amp;quot;host&amp;quot;: &amp;quot;alpha&amp;quot;,&lt;br /&gt;
    &amp;quot;cwd&amp;quot;: &amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    &amp;quot;input_text&amp;quot;: &amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    &amp;quot;output_text&amp;quot;: &amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    &amp;quot;started_at_ns&amp;quot;: 1000,&lt;br /&gt;
    &amp;quot;ended_at_ns&amp;quot;: 2000,&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the fields used by regex milestone checks:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command[&amp;quot;host&amp;quot;]&lt;br /&gt;
command[&amp;quot;cwd&amp;quot;]&lt;br /&gt;
command[&amp;quot;input_text&amp;quot;]&lt;br /&gt;
command[&amp;quot;output_text&amp;quot;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;alpha&#039;&lt;br /&gt;
&#039;/home/student&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Try an accidental wrong key:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command[&amp;quot;working_directory&amp;quot;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeyError: &#039;working_directory&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The dictionary contains the information, but it does not document the shape of the event. A typo is discovered only when the field is used. A named event class makes the processor code easier to read and easier to explain.&lt;br /&gt;
&lt;br /&gt;
== Phase 4: Persist the event record classes ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create reusable payload classes for the TTY, shell command, and regex milestone examples.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/events.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/events.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Payload records for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from dataclasses import dataclass&lt;br /&gt;
from typing import Literal&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class TTYReadEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    data: bytes&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class TTYWriteEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    data: bytes&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class LineDisciplineEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    line: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class KeystrokeTimingEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    source_index: int&lt;br /&gt;
    timestamp_ns: int&lt;br /&gt;
    previous_timestamp_ns: int | None&lt;br /&gt;
    delta_ns: int | None&lt;br /&gt;
    text: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class ShellCommandEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    command_index: int&lt;br /&gt;
    host: str&lt;br /&gt;
    cwd: str&lt;br /&gt;
    input_text: str&lt;br /&gt;
    output_text: str&lt;br /&gt;
    started_at_ns: int&lt;br /&gt;
    ended_at_ns: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
RegexMilestoneField = Literal[&lt;br /&gt;
    &amp;quot;host&amp;quot;,&lt;br /&gt;
    &amp;quot;cwd&amp;quot;,&lt;br /&gt;
    &amp;quot;input_text&amp;quot;,&lt;br /&gt;
    &amp;quot;output_text&amp;quot;,&lt;br /&gt;
]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneExpression:&lt;br /&gt;
    name: str&lt;br /&gt;
    field: RegexMilestoneField&lt;br /&gt;
    pattern: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneResult:&lt;br /&gt;
    expression_name: str&lt;br /&gt;
    field: RegexMilestoneField&lt;br /&gt;
    matched: bool&lt;br /&gt;
    match_text: str | None = None&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class RegexMilestoneAnalysisEvent:&lt;br /&gt;
    session_id: str&lt;br /&gt;
    command_index: int&lt;br /&gt;
    results: tuple[RegexMilestoneResult, ...]&lt;br /&gt;
    matched_count: int&lt;br /&gt;
    expression_count: int&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.events import ShellCommandEvent&lt;br /&gt;
&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
command&lt;br /&gt;
command.host&lt;br /&gt;
command.cwd&lt;br /&gt;
command.input_text&lt;br /&gt;
command.output_text&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ShellCommandEvent(session_id=&#039;session-1&#039;, command_index=0, ...)&lt;br /&gt;
&#039;alpha&#039;&lt;br /&gt;
&#039;/home/student&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The event shape is now explicit. Other code can depend on a named &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; instead of assuming that a loose dictionary has the expected fields.&lt;br /&gt;
&lt;br /&gt;
== Phase 5: Evaluate regex milestones manually ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Recreate the core milestone check in a small, transparent form.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* A regex milestone expression names one check against one shell command field.&lt;br /&gt;
* The field may be command input, command output, working directory, or host context.&lt;br /&gt;
* The result records whether the expression matched and, when available, the matched text.&lt;br /&gt;
* Manual evaluation is useful for learning the shape of the problem before writing a processor.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Import regular expressions and the payload classes:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create one command event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create three milestone expressions:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
expressions = (&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;used-ls&amp;quot;,&lt;br /&gt;
        field=&amp;quot;input_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;\bls\b&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;saw-notes&amp;quot;,&lt;br /&gt;
        field=&amp;quot;output_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;notes\.txt&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;home-directory&amp;quot;,&lt;br /&gt;
        field=&amp;quot;cwd&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;^/home/student$&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Evaluate one expression:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
expression = expressions[0]&lt;br /&gt;
text = getattr(command, expression.field)&lt;br /&gt;
match = re.search(expression.pattern, text)&lt;br /&gt;
&lt;br /&gt;
expression.name&lt;br /&gt;
expression.field&lt;br /&gt;
text&lt;br /&gt;
match.group(0)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;used-ls&#039;&lt;br /&gt;
&#039;input_text&#039;&lt;br /&gt;
&#039;ls -la\n&#039;&lt;br /&gt;
&#039;ls&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Evaluate all expressions:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
manual_results = []&lt;br /&gt;
&lt;br /&gt;
for expression in expressions:&lt;br /&gt;
    text = getattr(command, expression.field)&lt;br /&gt;
    match = re.search(expression.pattern, text)&lt;br /&gt;
    manual_results.append((expression.name, match is not None))&lt;br /&gt;
&lt;br /&gt;
manual_results&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(&#039;used-ls&#039;, True), (&#039;saw-notes&#039;, True), (&#039;home-directory&#039;, True)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
Manual evaluation is simple for one command, but the loop is already event processor logic. The next phase preserves the loop in a reusable processor.&lt;br /&gt;
&lt;br /&gt;
== Phase 6: Persist the RegexMilestone processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a processor that receives shell command events and emits regex milestone analysis events.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/regex_milestones.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/regex_milestones.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Regex milestone processor for shell command events.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneAnalysisEvent,&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    RegexMilestoneResult,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class RegexMilestoneProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _expressions: tuple[RegexMilestoneExpression, ...]&lt;br /&gt;
&lt;br /&gt;
    def __init__(&lt;br /&gt;
        self,&lt;br /&gt;
        bus: DirectMessageBus,&lt;br /&gt;
        expressions: tuple[RegexMilestoneExpression, ...],&lt;br /&gt;
    ) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.shell.command&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;shell-reconstructor&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;shell-command&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.shell.regex_milestone&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;regex-milestone-processor&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;regex-milestone-analysis&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._expressions = expressions&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; RegexMilestoneAnalysisEvent:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        command = message.payload&lt;br /&gt;
&lt;br /&gt;
        if not isinstance(command, ShellCommandEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected ShellCommandEvent, got {type(command).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        analysis = analyze_regex_milestones(command, self._expressions)&lt;br /&gt;
        self._emitter.emit(analysis)&lt;br /&gt;
        return analysis&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def analyze_regex_milestones(&lt;br /&gt;
    command: ShellCommandEvent,&lt;br /&gt;
    expressions: tuple[RegexMilestoneExpression, ...],&lt;br /&gt;
) -&amp;gt; RegexMilestoneAnalysisEvent:&lt;br /&gt;
    results = []&lt;br /&gt;
    for expression in expressions:&lt;br /&gt;
        result = evaluate_regex_milestone(command, expression)&lt;br /&gt;
        results.append(result)&lt;br /&gt;
&lt;br /&gt;
    matched_count = sum(result.matched for result in results)&lt;br /&gt;
    analysis = RegexMilestoneAnalysisEvent(&lt;br /&gt;
        session_id=command.session_id,&lt;br /&gt;
        command_index=command.command_index,&lt;br /&gt;
        results=tuple(results),&lt;br /&gt;
        matched_count=matched_count,&lt;br /&gt;
        expression_count=len(expressions),&lt;br /&gt;
    )&lt;br /&gt;
    return analysis&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def evaluate_regex_milestone(&lt;br /&gt;
    command: ShellCommandEvent,&lt;br /&gt;
    expression: RegexMilestoneExpression,&lt;br /&gt;
) -&amp;gt; RegexMilestoneResult:&lt;br /&gt;
    text = getattr(command, expression.field)&lt;br /&gt;
    match = re.search(expression.pattern, text)&lt;br /&gt;
    match_text = None&lt;br /&gt;
&lt;br /&gt;
    if match is not None:&lt;br /&gt;
        match_text = match.group(0)&lt;br /&gt;
&lt;br /&gt;
    result = RegexMilestoneResult(&lt;br /&gt;
        expression_name=expression.name,&lt;br /&gt;
        field=expression.field,&lt;br /&gt;
        matched=match is not None,&lt;br /&gt;
        match_text=match_text,&lt;br /&gt;
    )&lt;br /&gt;
    return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then set up the bus:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    RegexMilestoneExpression,&lt;br /&gt;
    ShellCommandEvent,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.regex_milestones import RegexMilestoneProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Subscribe to the analysis topic before running the processor:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.shell.regex_milestone&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;regex-milestone-processor&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;regex-milestone-analysis&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Create the shell command emitter and processor:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.shell.command&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;shell-reconstructor&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;shell-command&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
expressions = (&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;used-ls&amp;quot;,&lt;br /&gt;
        field=&amp;quot;input_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;\bls\b&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;saw-notes&amp;quot;,&lt;br /&gt;
        field=&amp;quot;output_text&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;notes\.txt&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
    RegexMilestoneExpression(&lt;br /&gt;
        name=&amp;quot;home-directory&amp;quot;,&lt;br /&gt;
        field=&amp;quot;cwd&amp;quot;,&lt;br /&gt;
        pattern=r&amp;quot;^/home/student$&amp;quot;,&lt;br /&gt;
    ),&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = RegexMilestoneProcessor(bus, expressions)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit a shell command event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
command = ShellCommandEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    command_index=0,&lt;br /&gt;
    host=&amp;quot;alpha&amp;quot;,&lt;br /&gt;
    cwd=&amp;quot;/home/student&amp;quot;,&lt;br /&gt;
    input_text=&amp;quot;ls -la\n&amp;quot;,&lt;br /&gt;
    output_text=&amp;quot;total 12\n-rw-r--r-- 1 student student 0 notes.txt\n&amp;quot;,&lt;br /&gt;
    started_at_ns=1000,&lt;br /&gt;
    ended_at_ns=2000,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
command_emitter.emit(command)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the command:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis = processor.process_one()&lt;br /&gt;
analysis&lt;br /&gt;
analysis.results&lt;br /&gt;
analysis.matched_count&lt;br /&gt;
analysis.expression_count&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
RegexMilestoneAnalysisEvent(... matched_count=3, expression_count=3)&lt;br /&gt;
(RegexMilestoneResult(...), RegexMilestoneResult(...), RegexMilestoneResult(...))&lt;br /&gt;
3&lt;br /&gt;
3&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the derived analysis event:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
analysis_message = analysis_receiver.receive()&lt;br /&gt;
&lt;br /&gt;
analysis_message.msg_topic&lt;br /&gt;
analysis_message.msg_type&lt;br /&gt;
analysis_message.msg_producer&lt;br /&gt;
analysis_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;demo.shell.regex_milestone&#039;&lt;br /&gt;
&#039;regex-milestone-analysis&#039;&lt;br /&gt;
&#039;regex-milestone-processor&#039;&lt;br /&gt;
RegexMilestoneAnalysisEvent(... matched_count=3, expression_count=3)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
messages = history.messages()&lt;br /&gt;
&lt;br /&gt;
for message in messages:&lt;br /&gt;
    print(message.bus_sequence, message.msg_topic, message.msg_type)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
0 demo.shell.command shell-command&lt;br /&gt;
1 demo.shell.regex_milestone regex-milestone-analysis&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The regex milestone processor consumes one event and emits another. The derived analysis event is available to any later subscriber. History contains both the source command event and the derived analysis event.&lt;br /&gt;
&lt;br /&gt;
== Phase 7: Explore why shell reconstruction is separate ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Show that raw TTY observations are not the same thing as high-level shell command events.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Regex milestones should inspect high-level command events.&lt;br /&gt;
* TTY reads, TTY writes, and line-discipline lines are lower-level observations.&lt;br /&gt;
* A shell command event is reconstructed by correlating those observations.&lt;br /&gt;
* Keeping reconstruction separate prevents the regex milestone processor from becoming a large TTY parser.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
Create a few low-level observations:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    LineDisciplineEvent,&lt;br /&gt;
    TTYReadEvent,&lt;br /&gt;
    TTYWriteEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_1 = TTYReadEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=0,&lt;br /&gt;
    timestamp_ns=1000,&lt;br /&gt;
    data=b&amp;quot;l&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_2 = TTYReadEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=1,&lt;br /&gt;
    timestamp_ns=1100,&lt;br /&gt;
    data=b&amp;quot;s&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
line = LineDisciplineEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=2,&lt;br /&gt;
    timestamp_ns=1200,&lt;br /&gt;
    line=&amp;quot;ls\n&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
write = TTYWriteEvent(&lt;br /&gt;
    session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
    source_index=3,&lt;br /&gt;
    timestamp_ns=1600,&lt;br /&gt;
    data=b&amp;quot;notes.txt\n&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the raw pieces:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_1.data&lt;br /&gt;
read_2.data&lt;br /&gt;
line.line&lt;br /&gt;
write.data&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
b&#039;l&#039;&lt;br /&gt;
b&#039;s&#039;&lt;br /&gt;
&#039;ls\n&#039;&lt;br /&gt;
b&#039;notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Assemble command fields manually:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
input_text = line.line&lt;br /&gt;
output_text = write.data.decode(&amp;quot;utf-8&amp;quot;, errors=&amp;quot;replace&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
input_text&lt;br /&gt;
output_text&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;ls\n&#039;&lt;br /&gt;
&#039;notes.txt\n&#039;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The useful command event is derived from several lower-level observations. A later shell reconstructor can subscribe to line-discipline events, query recent reads/writes/timing observations, and emit &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; records.&lt;br /&gt;
&lt;br /&gt;
== Phase 8: Add keystroke timing as a smaller TTY-derived processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Create a simple stateful processor before attempting full shell reconstruction.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* Timing requires comparing a TTY read with the previous read in the same session.&lt;br /&gt;
* A derived timing event makes the result explicit.&lt;br /&gt;
* Other processors can consume timing events instead of recalculating deltas.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration before the file edit&lt;br /&gt;
&lt;br /&gt;
Calculate timing manually from the two read events:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_events = [read_1, read_2]&lt;br /&gt;
previous_timestamp = None&lt;br /&gt;
timing_rows = []&lt;br /&gt;
&lt;br /&gt;
for event in read_events:&lt;br /&gt;
    delta = None&lt;br /&gt;
    if previous_timestamp is not None:&lt;br /&gt;
        delta = event.timestamp_ns - previous_timestamp&lt;br /&gt;
    timing_rows.append((event.source_index, previous_timestamp, delta))&lt;br /&gt;
    previous_timestamp = event.timestamp_ns&lt;br /&gt;
&lt;br /&gt;
timing_rows&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, None, None), (1, 1000, 100)]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/keystroke_timing.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/keystroke_timing.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Keystroke timing processor for TTY read events.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.events import (&lt;br /&gt;
    KeystrokeTimingEvent,&lt;br /&gt;
    TTYReadEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class KeystrokeTimingProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _last_timestamp_by_session: dict[str, int]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.tty.read&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;tty-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;tty-read&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.keystroke.timing&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._last_timestamp_by_session = {}&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; KeystrokeTimingEvent:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        event = message.payload&lt;br /&gt;
&lt;br /&gt;
        if not isinstance(event, TTYReadEvent):&lt;br /&gt;
            raise TypeError(f&amp;quot;expected TTYReadEvent, got {type(event).__name__}&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
        previous_timestamp = self._last_timestamp_by_session.get(&lt;br /&gt;
            event.session_id&lt;br /&gt;
        )&lt;br /&gt;
        delta = None&lt;br /&gt;
&lt;br /&gt;
        if previous_timestamp is not None:&lt;br /&gt;
            delta = event.timestamp_ns - previous_timestamp&lt;br /&gt;
&lt;br /&gt;
        self._last_timestamp_by_session[event.session_id] = event.timestamp_ns&lt;br /&gt;
&lt;br /&gt;
        text = event.data.decode(&amp;quot;utf-8&amp;quot;, errors=&amp;quot;replace&amp;quot;)&lt;br /&gt;
        timing = KeystrokeTimingEvent(&lt;br /&gt;
            session_id=event.session_id,&lt;br /&gt;
            source_index=event.source_index,&lt;br /&gt;
            timestamp_ns=event.timestamp_ns,&lt;br /&gt;
            previous_timestamp_ns=previous_timestamp,&lt;br /&gt;
            delta_ns=delta,&lt;br /&gt;
            text=text,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter.emit(timing)&lt;br /&gt;
        return timing&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
from demos.event_processors.events import TTYReadEvent&lt;br /&gt;
from demos.event_processors.keystroke_timing import KeystrokeTimingProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
timing_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.keystroke.timing&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;keystroke-timing&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.tty.read&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;tty-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;tty-read&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = KeystrokeTimingProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit reads and process them:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
read_emitter.emit(&lt;br /&gt;
    TTYReadEvent(&lt;br /&gt;
        session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
        source_index=0,&lt;br /&gt;
        timestamp_ns=1000,&lt;br /&gt;
        data=b&amp;quot;l&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
read_emitter.emit(&lt;br /&gt;
    TTYReadEvent(&lt;br /&gt;
        session_id=&amp;quot;session-1&amp;quot;,&lt;br /&gt;
        source_index=1,&lt;br /&gt;
        timestamp_ns=1100,&lt;br /&gt;
        data=b&amp;quot;s&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor.process_one()&lt;br /&gt;
processor.process_one()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeystrokeTimingEvent(... previous_timestamp_ns=None, delta_ns=None, text=&#039;l&#039;)&lt;br /&gt;
KeystrokeTimingEvent(... previous_timestamp_ns=1000, delta_ns=100, text=&#039;s&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive timing events:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
timing_receiver.receive().payload&lt;br /&gt;
timing_receiver.receive().payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
KeystrokeTimingEvent(... source_index=0 ...)&lt;br /&gt;
KeystrokeTimingEvent(... source_index=1 ...)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 9: Introduce graph events ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Switch to a small graph problem that can demonstrate feedback loops safely.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* An edge is a source fact.&lt;br /&gt;
* A path is a derived fact.&lt;br /&gt;
* A direct edge implies a direct path.&lt;br /&gt;
* Longer paths can be derived later from known paths and edges.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_events.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/graph_events.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Graph event payloads for message bus demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from dataclasses import dataclass&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class EdgeDeclaredEvent:&lt;br /&gt;
    graph_id: str&lt;br /&gt;
    source: str&lt;br /&gt;
    target: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class PathKnownEvent:&lt;br /&gt;
    graph_id: str&lt;br /&gt;
    source: str&lt;br /&gt;
    target: str&lt;br /&gt;
    hop_count: int&lt;br /&gt;
&lt;br /&gt;
    def fact_key(self) -&amp;gt; tuple[str, str, str]:&lt;br /&gt;
        result = (self.graph_id, self.source, self.target)&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge = EdgeDeclaredEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;A&amp;quot;,&lt;br /&gt;
    target=&amp;quot;B&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path = PathKnownEvent(&lt;br /&gt;
    graph_id=edge.graph_id,&lt;br /&gt;
    source=edge.source,&lt;br /&gt;
    target=edge.target,&lt;br /&gt;
    hop_count=1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge&lt;br /&gt;
path&lt;br /&gt;
path.fact_key()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
EdgeDeclaredEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;)&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;, hop_count=1)&lt;br /&gt;
(&#039;demo-graph&#039;, &#039;A&#039;, &#039;B&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 10: Create a demo-only nonblocking receive helper ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Prepare for processor scheduling without editing &amp;lt;code&amp;gt;mbus&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;Receiver.receive()&amp;lt;/code&amp;gt; blocks until a message is available.&lt;br /&gt;
* A scheduler needs to try a processor and move on if no message is waiting.&lt;br /&gt;
* The current package does not expose a public nonblocking receiver method.&lt;br /&gt;
* This helper is local demonstration code. It can be replaced by a real public API later.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/demo_receive.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/demo_receive.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Demo-only helpers for receiver inspection and scheduling.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from queue import Empty&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.endpoints import Receiver&lt;br /&gt;
from mbus.message.records import ReceivedMessage&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class DemoReceiverError(RuntimeError):&lt;br /&gt;
    pass&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def try_receive(receiver: Receiver) -&amp;gt; ReceivedMessage | None:&lt;br /&gt;
    queue = getattr(receiver, &amp;quot;_queue&amp;quot;, None)&lt;br /&gt;
&lt;br /&gt;
    if queue is None:&lt;br /&gt;
        raise DemoReceiverError(&lt;br /&gt;
            &amp;quot;demo try_receive requires the current direct broker receiver&amp;quot;&lt;br /&gt;
        )&lt;br /&gt;
&lt;br /&gt;
    try:&lt;br /&gt;
        message = queue.get_nowait()&lt;br /&gt;
    except Empty:&lt;br /&gt;
        message = None&lt;br /&gt;
&lt;br /&gt;
    return message&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.demo_receive import try_receive&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
&lt;br /&gt;
receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
try_receive(receiver)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
None&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Now emit a message:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.signals&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;demo-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;ping&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
emitter.emit({&amp;quot;text&amp;quot;: &amp;quot;demo nonblocking receive&amp;quot;})&lt;br /&gt;
message = try_receive(receiver)&lt;br /&gt;
&lt;br /&gt;
message&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
ReceivedMessage(...)&lt;br /&gt;
{&#039;text&#039;: &#039;demo nonblocking receive&#039;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 11: Create the direct path processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Implement the graph rule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
edge(A, B) -&amp;gt; path(A, B)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The source emits edge events.&lt;br /&gt;
* The direct path processor emits derived path events.&lt;br /&gt;
* Other processors can subscribe to derived path events.&lt;br /&gt;
* The processor can be run by hand or by a scheduler.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_reachability.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/graph_reachability.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Graph reachability processors for message bus demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from mbus.broker.endpoints import Emitter, Receiver&lt;br /&gt;
&lt;br /&gt;
from demos.event_processors.demo_receive import try_receive&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class DirectPathProcessor:&lt;br /&gt;
    _receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _known_paths: set[tuple[str, str, str]]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._known_paths = set()&lt;br /&gt;
&lt;br /&gt;
    def process_one(self) -&amp;gt; PathKnownEvent | None:&lt;br /&gt;
        message = self._receiver.receive()&lt;br /&gt;
        result = self._process_payload(message.payload)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
    def process_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            self._process_payload(message.payload)&lt;br /&gt;
            work_count = 1&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def _process_payload(self, payload: object) -&amp;gt; PathKnownEvent | None:&lt;br /&gt;
        if not isinstance(payload, EdgeDeclaredEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected EdgeDeclaredEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        path = PathKnownEvent(&lt;br /&gt;
            graph_id=payload.graph_id,&lt;br /&gt;
            source=payload.source,&lt;br /&gt;
            target=payload.target,&lt;br /&gt;
            hop_count=1,&lt;br /&gt;
        )&lt;br /&gt;
&lt;br /&gt;
        if path.fact_key() not in self._known_paths:&lt;br /&gt;
            self._known_paths.add(path.fact_key())&lt;br /&gt;
            self._emitter.emit(path)&lt;br /&gt;
            result = path&lt;br /&gt;
        else:&lt;br /&gt;
            result = None&lt;br /&gt;
&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import AOStoreCaptureSink&lt;br /&gt;
from demos.event_processors.graph_events import EdgeDeclaredEvent&lt;br /&gt;
from demos.event_processors.graph_reachability import DirectPathProcessor&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
path_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
processor = DirectPathProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Call the processor before any edge exists:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
0&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit one edge:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;A&amp;quot;,&lt;br /&gt;
        target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the edge:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Receive the derived path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_message = path_receiver.receive()&lt;br /&gt;
path_message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;B&#039;, hop_count=1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 12: Explore the path extension rule by hand ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Understand the feedback rule before implementing it.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* If &amp;lt;code&amp;gt;A&amp;lt;/code&amp;gt; reaches &amp;lt;code&amp;gt;B&amp;lt;/code&amp;gt;, and there is an edge from &amp;lt;code&amp;gt;B&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;C&amp;lt;/code&amp;gt;, then &amp;lt;code&amp;gt;A&amp;lt;/code&amp;gt; reaches &amp;lt;code&amp;gt;C&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Derived path facts can lead to more derived path facts.&lt;br /&gt;
* Duplicate prevention keeps the feedback loop from repeating the same fact forever.&lt;br /&gt;
&lt;br /&gt;
; Interpreter exploration&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_bc = EdgeDeclaredEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;B&amp;quot;,&lt;br /&gt;
    target=&amp;quot;C&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path_ab = PathKnownEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=&amp;quot;A&amp;quot;,&lt;br /&gt;
    target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    hop_count=1,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Check whether the path and edge connect:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_ab.target&lt;br /&gt;
edge_bc.source&lt;br /&gt;
path_ab.target == edge_bc.source&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
&#039;B&#039;&lt;br /&gt;
&#039;B&#039;&lt;br /&gt;
True&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Derive a new path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
path_ac = PathKnownEvent(&lt;br /&gt;
    graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
    source=path_ab.source,&lt;br /&gt;
    target=edge_bc.target,&lt;br /&gt;
    hop_count=path_ab.hop_count + 1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
path_ac&lt;br /&gt;
path_ac.fact_key()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;C&#039;, hop_count=2)&lt;br /&gt;
(&#039;demo-graph&#039;, &#039;A&#039;, &#039;C&#039;)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Manually prevent duplicate path facts:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
known_paths = set()&lt;br /&gt;
known_paths.add(path_ab.fact_key())&lt;br /&gt;
&lt;br /&gt;
path_ac.fact_key() in known_paths&lt;br /&gt;
&lt;br /&gt;
if path_ac.fact_key() not in known_paths:&lt;br /&gt;
    known_paths.add(path_ac.fact_key())&lt;br /&gt;
&lt;br /&gt;
known_paths&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
False&lt;br /&gt;
{(&#039;demo-graph&#039;, &#039;A&#039;, &#039;B&#039;), (&#039;demo-graph&#039;, &#039;A&#039;, &#039;C&#039;)}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 13: Add the path extension processor ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Implement the feedback rule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
path(A, B) + edge(B, C) -&amp;gt; path(A, C)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The extension processor listens to edge events and path events.&lt;br /&gt;
* It emits new path events when a known path can be extended.&lt;br /&gt;
* It also listens to its own output because an extended path may be extendable again.&lt;br /&gt;
* This is a controlled feedback loop.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Append this class to:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_reachability.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
class PathExtensionProcessor:&lt;br /&gt;
    _edge_receiver: Receiver&lt;br /&gt;
    _direct_path_receiver: Receiver&lt;br /&gt;
    _extension_path_receiver: Receiver&lt;br /&gt;
    _emitter: Emitter&lt;br /&gt;
    _edges: set[tuple[str, str, str]]&lt;br /&gt;
    _paths: dict[tuple[str, str, str], PathKnownEvent]&lt;br /&gt;
&lt;br /&gt;
    def __init__(self, bus: DirectMessageBus) -&amp;gt; None:&lt;br /&gt;
        self._edge_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._direct_path_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;direct-path&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._extension_path_receiver = bus.subscribe(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
            msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._emitter = bus.register_emitter(&lt;br /&gt;
            msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
            msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
            default_msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
        )&lt;br /&gt;
        self._edges = set()&lt;br /&gt;
        self._paths = {}&lt;br /&gt;
&lt;br /&gt;
    def process_edge_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._edge_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_edge_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def process_direct_path_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._direct_path_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_path_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def process_extension_path_available(self) -&amp;gt; int:&lt;br /&gt;
        message = try_receive(self._extension_path_receiver)&lt;br /&gt;
&lt;br /&gt;
        if message is None:&lt;br /&gt;
            work_count = 0&lt;br /&gt;
        else:&lt;br /&gt;
            paths = self._process_path_payload(message.payload)&lt;br /&gt;
            work_count = 1 + len(paths)&lt;br /&gt;
&lt;br /&gt;
        return work_count&lt;br /&gt;
&lt;br /&gt;
    def _process_edge_payload(&lt;br /&gt;
        self,&lt;br /&gt;
        payload: object,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        if not isinstance(payload, EdgeDeclaredEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected EdgeDeclaredEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        edge = payload&lt;br /&gt;
        self._edges.add((edge.graph_id, edge.source, edge.target))&lt;br /&gt;
        paths = self._derive_from_new_edge(edge)&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _process_path_payload(&lt;br /&gt;
        self,&lt;br /&gt;
        payload: object,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        if not isinstance(payload, PathKnownEvent):&lt;br /&gt;
            raise TypeError(&lt;br /&gt;
                f&amp;quot;expected PathKnownEvent, got {type(payload).__name__}&amp;quot;&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
        path = payload&lt;br /&gt;
        self._paths[path.fact_key()] = path&lt;br /&gt;
        paths = self._derive_from_new_path(path)&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _derive_from_new_edge(&lt;br /&gt;
        self,&lt;br /&gt;
        edge: EdgeDeclaredEvent,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        paths = []&lt;br /&gt;
&lt;br /&gt;
        for known_path in self._paths.values():&lt;br /&gt;
            if known_path.graph_id != edge.graph_id:&lt;br /&gt;
                continue&lt;br /&gt;
            if known_path.target != edge.source:&lt;br /&gt;
                continue&lt;br /&gt;
&lt;br /&gt;
            next_path = PathKnownEvent(&lt;br /&gt;
                graph_id=edge.graph_id,&lt;br /&gt;
                source=known_path.source,&lt;br /&gt;
                target=edge.target,&lt;br /&gt;
                hop_count=known_path.hop_count + 1,&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
            if self._emit_if_new(next_path):&lt;br /&gt;
                paths.append(next_path)&lt;br /&gt;
&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _derive_from_new_path(&lt;br /&gt;
        self,&lt;br /&gt;
        path: PathKnownEvent,&lt;br /&gt;
    ) -&amp;gt; list[PathKnownEvent]:&lt;br /&gt;
        paths = []&lt;br /&gt;
&lt;br /&gt;
        for graph_id, edge_source, edge_target in self._edges:&lt;br /&gt;
            if graph_id != path.graph_id:&lt;br /&gt;
                continue&lt;br /&gt;
            if edge_source != path.target:&lt;br /&gt;
                continue&lt;br /&gt;
&lt;br /&gt;
            next_path = PathKnownEvent(&lt;br /&gt;
                graph_id=path.graph_id,&lt;br /&gt;
                source=path.source,&lt;br /&gt;
                target=edge_target,&lt;br /&gt;
                hop_count=path.hop_count + 1,&lt;br /&gt;
            )&lt;br /&gt;
&lt;br /&gt;
            if self._emit_if_new(next_path):&lt;br /&gt;
                paths.append(next_path)&lt;br /&gt;
&lt;br /&gt;
        return paths&lt;br /&gt;
&lt;br /&gt;
    def _emit_if_new(self, path: PathKnownEvent) -&amp;gt; bool:&lt;br /&gt;
        if path.fact_key() in self._paths:&lt;br /&gt;
            result = False&lt;br /&gt;
        else:&lt;br /&gt;
            self._paths[path.fact_key()] = path&lt;br /&gt;
            self._emitter.emit(path)&lt;br /&gt;
            result = True&lt;br /&gt;
&lt;br /&gt;
        return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then run:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_events import EdgeDeclaredEvent&lt;br /&gt;
from demos.event_processors.graph_reachability import (&lt;br /&gt;
    DirectPathProcessor,&lt;br /&gt;
    PathExtensionProcessor,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
bus = DirectMessageBus()&lt;br /&gt;
sink = AOStoreCaptureSink()&lt;br /&gt;
bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
extension_receiver = bus.subscribe(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.path&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;path-extension&amp;quot;,&lt;br /&gt;
    msg_type=&amp;quot;path-known&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter = bus.register_emitter(&lt;br /&gt;
    msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
    msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
    default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
direct_processor = DirectPathProcessor(bus)&lt;br /&gt;
extension_processor = PathExtensionProcessor(bus)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Emit two edges in fixed order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;A&amp;quot;,&lt;br /&gt;
        target=&amp;quot;B&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
edge_emitter.emit(&lt;br /&gt;
    EdgeDeclaredEvent(&lt;br /&gt;
        graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
        source=&amp;quot;B&amp;quot;,&lt;br /&gt;
        target=&amp;quot;C&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Process the direct paths:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
direct_processor.process_available()&lt;br /&gt;
direct_processor.process_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Let the extension processor learn the edges:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
extension_processor.process_edge_available()&lt;br /&gt;
extension_processor.process_edge_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Let the extension processor learn the direct paths:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
extension_processor.process_direct_path_available()&lt;br /&gt;
extension_processor.process_direct_path_available()&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1&lt;br /&gt;
2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The second call may emit the extended &amp;lt;code&amp;gt;A -&amp;gt; C&amp;lt;/code&amp;gt; path after the processor has seen both direct paths and both edges.&lt;br /&gt;
&lt;br /&gt;
Receive the extended path:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
message = extension_receiver.receive()&lt;br /&gt;
message.payload&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
PathKnownEvent(graph_id=&#039;demo-graph&#039;, source=&#039;A&#039;, target=&#039;C&#039;, hop_count=2)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect captured graph path history:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
history = CaptureHistory(sink.all_records())&lt;br /&gt;
path_messages = history.by_topic(&amp;quot;demo.graph.path&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
for message in path_messages:&lt;br /&gt;
    path = message.payload&lt;br /&gt;
    print(&lt;br /&gt;
        message.bus_sequence,&lt;br /&gt;
        message.msg_producer,&lt;br /&gt;
        path.source,&lt;br /&gt;
        path.target,&lt;br /&gt;
        path.hop_count,&lt;br /&gt;
    )&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
... direct-path A B 1&lt;br /&gt;
... direct-path B C 1&lt;br /&gt;
... path-extension A C 2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 14: Add a randomized processor scheduler ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Simulate concurrency effects without changing source input order.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The source will emit the same edge events in the same order every run.&lt;br /&gt;
* The scheduler varies which processor gets a chance to run first.&lt;br /&gt;
* This simulates uncertainty from processing time, scheduling priority, startup timing, or transmission delay.&lt;br /&gt;
* The graph example should converge to the same final path facts even when processor scheduling, and hence the order of intermediate derived processor events, differs.&lt;br /&gt;
&lt;br /&gt;
; File edit&lt;br /&gt;
&lt;br /&gt;
Create:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/runners.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
# demos/event_processors/runners.py&lt;br /&gt;
&lt;br /&gt;
&amp;quot;&amp;quot;&amp;quot;Small processor runners for event processor demonstrations.&amp;quot;&amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
from collections.abc import Callable&lt;br /&gt;
from dataclasses import dataclass, field&lt;br /&gt;
from random import Random&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, kw_only=True)&lt;br /&gt;
class ProcessorStep:&lt;br /&gt;
    name: str&lt;br /&gt;
    run: Callable[[], int]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass&lt;br /&gt;
class RandomProcessorRunner:&lt;br /&gt;
    steps: tuple[ProcessorStep, ...]&lt;br /&gt;
    seed: int&lt;br /&gt;
    max_rounds: int = 30&lt;br /&gt;
    trace: list[tuple[int, str, int]] = field(default_factory=list)&lt;br /&gt;
&lt;br /&gt;
    def run_until_quiet(self) -&amp;gt; list[tuple[int, str, int]]:&lt;br /&gt;
        rng = Random(self.seed)&lt;br /&gt;
&lt;br /&gt;
        for round_index in range(self.max_rounds):&lt;br /&gt;
            work_count = self._run_round(rng, round_index)&lt;br /&gt;
&lt;br /&gt;
            if work_count == 0:&lt;br /&gt;
                break&lt;br /&gt;
&lt;br /&gt;
        result = list(self.trace)&lt;br /&gt;
        return result&lt;br /&gt;
&lt;br /&gt;
    def _run_round(self, rng: Random, round_index: int) -&amp;gt; int:&lt;br /&gt;
        steps = list(self.steps)&lt;br /&gt;
        rng.shuffle(steps)&lt;br /&gt;
        round_work_count = 0&lt;br /&gt;
&lt;br /&gt;
        for step in steps:&lt;br /&gt;
            step_work_count = step.run()&lt;br /&gt;
            self.trace.append((round_index, step.name, step_work_count))&lt;br /&gt;
            round_work_count += step_work_count&lt;br /&gt;
&lt;br /&gt;
        return round_work_count&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; Interpreter check&lt;br /&gt;
&lt;br /&gt;
Restart the interpreter, then inspect how a runner can shuffle placeholder steps:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from demos.event_processors.runners import (&lt;br /&gt;
    ProcessorStep,&lt;br /&gt;
    RandomProcessorRunner,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
calls = []&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def first_step() -&amp;gt; int:&lt;br /&gt;
    calls.append(&amp;quot;first&amp;quot;)&lt;br /&gt;
    return 0&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
def second_step() -&amp;gt; int:&lt;br /&gt;
    calls.append(&amp;quot;second&amp;quot;)&lt;br /&gt;
    return 0&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
runner = RandomProcessorRunner(&lt;br /&gt;
    steps=(&lt;br /&gt;
        ProcessorStep(name=&amp;quot;first&amp;quot;, run=first_step),&lt;br /&gt;
        ProcessorStep(name=&amp;quot;second&amp;quot;, run=second_step),&lt;br /&gt;
    ),&lt;br /&gt;
    seed=1,&lt;br /&gt;
)&lt;br /&gt;
&lt;br /&gt;
runner.run_until_quiet()&lt;br /&gt;
calls&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, &#039;...&#039;, 0), (0, &#039;...&#039;, 0)]&lt;br /&gt;
[&#039;...&#039;, &#039;...&#039;]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Phase 15: Run the graph example with randomized scheduling ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Run the same graph input under different processor schedules and compare the final path facts.&lt;br /&gt;
&lt;br /&gt;
; Interpreter setup&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
from mbus.broker.direct import DirectMessageBus&lt;br /&gt;
from demos.event_processors.capture_history import (&lt;br /&gt;
    AOStoreCaptureSink,&lt;br /&gt;
    CaptureHistory,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_events import (&lt;br /&gt;
    EdgeDeclaredEvent,&lt;br /&gt;
    PathKnownEvent,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.graph_reachability import (&lt;br /&gt;
    DirectPathProcessor,&lt;br /&gt;
    PathExtensionProcessor,&lt;br /&gt;
)&lt;br /&gt;
from demos.event_processors.runners import (&lt;br /&gt;
    ProcessorStep,&lt;br /&gt;
    RandomProcessorRunner,&lt;br /&gt;
)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Define a helper function in the interpreter:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
def run_graph_demo(seed: int) -&amp;gt; tuple[&lt;br /&gt;
    list[tuple[int, str, int]],&lt;br /&gt;
    list[tuple[int, str, str, int]],&lt;br /&gt;
]:&lt;br /&gt;
    bus = DirectMessageBus()&lt;br /&gt;
    sink = AOStoreCaptureSink()&lt;br /&gt;
    bus.set_capture_sink(sink)&lt;br /&gt;
&lt;br /&gt;
    edge_emitter = bus.register_emitter(&lt;br /&gt;
        msg_topic=&amp;quot;demo.graph.edge&amp;quot;,&lt;br /&gt;
        msg_producer=&amp;quot;graph-source&amp;quot;,&lt;br /&gt;
        default_msg_type=&amp;quot;edge-declared&amp;quot;,&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    direct_processor = DirectPathProcessor(bus)&lt;br /&gt;
    extension_processor = PathExtensionProcessor(bus)&lt;br /&gt;
&lt;br /&gt;
    edges = (&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;A&amp;quot;,&lt;br /&gt;
            target=&amp;quot;B&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;B&amp;quot;,&lt;br /&gt;
            target=&amp;quot;C&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
        EdgeDeclaredEvent(&lt;br /&gt;
            graph_id=&amp;quot;demo-graph&amp;quot;,&lt;br /&gt;
            source=&amp;quot;C&amp;quot;,&lt;br /&gt;
            target=&amp;quot;D&amp;quot;,&lt;br /&gt;
        ),&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    for edge in edges:&lt;br /&gt;
        edge_emitter.emit(edge)&lt;br /&gt;
&lt;br /&gt;
    steps = (&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;direct path from edge&amp;quot;,&lt;br /&gt;
            run=direct_processor.process_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns edge&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_edge_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns direct path&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_direct_path_available,&lt;br /&gt;
        ),&lt;br /&gt;
        ProcessorStep(&lt;br /&gt;
            name=&amp;quot;extension learns extension path&amp;quot;,&lt;br /&gt;
            run=extension_processor.process_extension_path_available,&lt;br /&gt;
        ),&lt;br /&gt;
    )&lt;br /&gt;
&lt;br /&gt;
    runner = RandomProcessorRunner(&lt;br /&gt;
        steps=steps,&lt;br /&gt;
        seed=seed,&lt;br /&gt;
    )&lt;br /&gt;
    trace = runner.run_until_quiet()&lt;br /&gt;
&lt;br /&gt;
    history = CaptureHistory(sink.all_records())&lt;br /&gt;
    path_facts = []&lt;br /&gt;
&lt;br /&gt;
    for message in history.by_topic(&amp;quot;demo.graph.path&amp;quot;):&lt;br /&gt;
        path = message.payload&lt;br /&gt;
        if isinstance(path, PathKnownEvent):&lt;br /&gt;
            fact = (&lt;br /&gt;
                message.bus_sequence,&lt;br /&gt;
                path.source,&lt;br /&gt;
                path.target,&lt;br /&gt;
                path.hop_count,&lt;br /&gt;
            )&lt;br /&gt;
            path_facts.append(fact)&lt;br /&gt;
&lt;br /&gt;
    result = (trace, path_facts)&lt;br /&gt;
    return result&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Run one schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_1, paths_1 = run_graph_demo(1)&lt;br /&gt;
&lt;br /&gt;
trace_1&lt;br /&gt;
paths_1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output shape:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[(0, &#039;...&#039;, ...), (0, &#039;...&#039;, ...), ...]&lt;br /&gt;
[(..., &#039;A&#039;, &#039;B&#039;, 1), (..., &#039;B&#039;, &#039;C&#039;, 1), (..., &#039;C&#039;, &#039;D&#039;, 1), ...]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Run another schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_2, paths_2 = run_graph_demo(2)&lt;br /&gt;
&lt;br /&gt;
trace_2&lt;br /&gt;
paths_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the schedules:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
trace_1 == trace_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
False&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compare the final path facts without bus sequence numbers:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
final_paths_1 = {&lt;br /&gt;
    (source, target, hop_count)&lt;br /&gt;
    for _, source, target, hop_count in paths_1&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
final_paths_2 = {&lt;br /&gt;
    (source, target, hop_count)&lt;br /&gt;
    for _, source, target, hop_count in paths_2&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
final_paths_1&lt;br /&gt;
final_paths_2&lt;br /&gt;
final_paths_1 == final_paths_2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected output:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
{(&#039;A&#039;, &#039;B&#039;, 1), (&#039;B&#039;, &#039;C&#039;, 1), (&#039;C&#039;, &#039;D&#039;, 1), (&#039;A&#039;, &#039;C&#039;, 2), (&#039;B&#039;, &#039;D&#039;, 2), (&#039;A&#039;, &#039;D&#039;, 3)}&lt;br /&gt;
{(&#039;A&#039;, &#039;B&#039;, 1), (&#039;B&#039;, &#039;C&#039;, 1), (&#039;C&#039;, &#039;D&#039;, 1), (&#039;A&#039;, &#039;C&#039;, 2), (&#039;B&#039;, &#039;D&#039;, 2), (&#039;A&#039;, &#039;D&#039;, 3)}&lt;br /&gt;
True&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first schedule:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for round_index, step_name, work_count in trace_1:&lt;br /&gt;
    print(round_index, step_name, work_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the first captured path order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for bus_sequence, source, target, hop_count in paths_1:&lt;br /&gt;
    print(bus_sequence, source, target, hop_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Inspect the second schedule and path order:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
for round_index, step_name, work_count in trace_2:&lt;br /&gt;
    print(round_index, step_name, work_count)&lt;br /&gt;
&lt;br /&gt;
for bus_sequence, source, target, hop_count in paths_2:&lt;br /&gt;
    print(bus_sequence, source, target, hop_count)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; What this phase shows&lt;br /&gt;
&lt;br /&gt;
The source input order did not change. The processor scheduling changed. The captured event order may differ. The final reachability facts should match.&lt;br /&gt;
&lt;br /&gt;
This models an important distributed-systems lesson: event history order and final derived state are related, but they are not the same thing.&lt;br /&gt;
&lt;br /&gt;
== Phase 16: Later refinement: graph index from history ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Introduce restart/recovery thinking only after the basic graph feedback example is familiar.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* The first graph processors keep local working state.&lt;br /&gt;
* Local working state is enough for a short demonstration.&lt;br /&gt;
* Longer-lived processors need recovery behavior after restart.&lt;br /&gt;
* Since edge and path facts are captured as events, a graph index can later be rebuilt from history.&lt;br /&gt;
* This points toward projection-style services without changing the basic processor model.&lt;br /&gt;
&lt;br /&gt;
; Suggested later file&lt;br /&gt;
&lt;br /&gt;
Create later:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
demos/event_processors/graph_index.py&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Suggested responsibilities:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
read captured history&lt;br /&gt;
collect EdgeDeclaredEvent payloads&lt;br /&gt;
collect PathKnownEvent payloads&lt;br /&gt;
answer whether a path fact exists&lt;br /&gt;
find edges starting at a node&lt;br /&gt;
find paths ending at a node&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This refinement belongs after the team has already run the graph reachability demo successfully.&lt;br /&gt;
&lt;br /&gt;
== Phase 17: Later refinement: shell command reconstruction ==&lt;br /&gt;
&lt;br /&gt;
; Goal&lt;br /&gt;
&lt;br /&gt;
Connect the TTY examples back to the shell command event shape used by regex milestones.&lt;br /&gt;
&lt;br /&gt;
; Discussion points&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;ShellCommandEvent&amp;lt;/code&amp;gt; is the high-level event consumed by the regex milestone processor.&lt;br /&gt;
* &amp;lt;code&amp;gt;TTYReadEvent&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;TTYWriteEvent&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;LineDisciplineEvent&amp;lt;/code&amp;gt; are lower-level observations.&lt;br /&gt;
* A reconstructor can subscribe to line-discipline events and query recent related observations.&lt;br /&gt;
* The reconstructor should emit shell command events; it should not perform regex analysis itself.&lt;br /&gt;
&lt;br /&gt;
; Suggested responsibilities&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
listen for completed line-discipline events&lt;br /&gt;
query history since the previous command boundary for the same session&lt;br /&gt;
collect matching reads, writes, and timing events&lt;br /&gt;
construct ShellCommandEvent&lt;br /&gt;
emit ShellCommandEvent on demo.shell.command&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This is an appropriate follow-up exercise after the team understands both regex milestone processing and history queries.&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=TTY_BPF_Instrument&amp;diff=928</id>
		<title>TTY BPF Instrument</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=TTY_BPF_Instrument&amp;diff=928"/>
		<updated>2026-06-06T01:31:01Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Demonstrations and preliminary documentation can be found at https://github.com/edurange/demo-bpf-tty-logger and https://github.com/edurange/prototype-tty-bpf-instrument. Wiki documentation is pending. Preliminary draft files are available in the Discord &amp;lt;code&amp;gt;#fileshare&amp;lt;/code&amp;gt; channel and will be linked here once Wiki configuration is adjusted to support attached non-image documents.&lt;br /&gt;
&lt;br /&gt;
== Summary ==&lt;br /&gt;
The TTY instrument is a BPF-based kernel observation tool for capturing terminal activity across a host system. Its purpose is to observe TTY byte activity at the kernel boundary and emit records that downstream components can reconstruct, validate, and analyze. The current design treats terminal activity as factual kernel observations first, and reserves higher-level ideas like sessions, users, commands, and learning activity for later interpretation rather than things the probe is responsible for determining. &lt;br /&gt;
&lt;br /&gt;
Although the immediate target is TTY activity, the design is also meant to serve as a template for future host-level instruments. It separates kernel observation, bounded transport, user-space draining, reconstruction, and downstream interpretation in a way that can be reused for other event sources. The TTY instrument is intentionally demanding: it involves raw byte payloads, ambiguous identity, ordering challenges, fragmentation, loss accounting, and privacy-sensitive data. If this pattern works for TTY activity, it provides a strong example for building other instruments with similar fidelity and reliability requirements - for the kernel-level services like the filesystem or network interfaces, common user-level processes like `bash`, etc.&lt;br /&gt;
&lt;br /&gt;
=== Why TTY Capture Is Difficult ===&lt;br /&gt;
This project is difficult because TTY activity is not a single clean stream. A user’s apparent terminal session can pass through SSH, shells, subprocesses, pseudoterminals, terminal echo, line discipline behavior, containers, namespaces, and reconnects. The instrument therefore preserves multiple identity axes, including process IDs, user IDs, cgroups, namespaces, TTY device numbers, inode context, and command/process names. No one field is treated as “the session” or “the user.” Streams or sessions may later be reconstructed from these facts, but the raw record should keep the original evidence intact.&lt;br /&gt;
&lt;br /&gt;
=== Tracepoints, BPF, and Kernel Boundaries ===&lt;br /&gt;
The current implementation is tracepoint-based. Kernel tracepoints provide minimal, upstream-plausible attachment surfaces; BPF probes attach to those tracepoints, read bounded payload buffers, enrich records with available identity/timing/ordering context, and emit records through BPF ring buffers. The tracepoint is not the logging schema. The BPF observation record is the instrument-facing schema.&lt;br /&gt;
&lt;br /&gt;
The current prototype includes kernel modifications because the existing kernel does not expose all of the stable attachment surfaces needed for this instrument. Those modifications add narrowly scoped TTY tracepoints so BPF programs can attach at the relevant read, write, and line-discipline receive paths. The goal is not to move logging policy into the kernel or to make the kernel produce the full instrument schema.&lt;br /&gt;
&lt;br /&gt;
The kernel changes must remain minimal, reviewable, and justifiable as tracepoint support; the BPF probe and user-space components remain responsible for the instrument-specific observation record, enrichment, transport, and reconstruction behavior. This distinction matters because tracepoints need to be acceptable to upstream kernel maintainers as general kernel instrumentation, while the richer record schema is specific to this project’s BPF instrument.&lt;br /&gt;
&lt;br /&gt;
=== Pipeline and Component Responsibilities ===&lt;br /&gt;
The TTY instrument is also important because it represents a characteristic high-pressure workload for the larger event pipeline. Human terminal input can produce many small payloads with high metadata overhead, while command output can produce sudden bursts of larger byte streams. Multi-user SSH workloads combine both patterns. This makes the instrument a useful benchmark for the event bus and downstream data store: it exercises throughput, buffering, ordering, loss reporting, payload preservation, and reconstruction under conditions that are demanding yet meaningful with a minimal number of tracepoints and probe sites. (Contrast with the filesystem, where roughly a dozen such sites are needed to capture the wider variety of filesystem operations.) The TTY instrument does not define the bus or storage policy, but its output should help reveal whether those downstream systems can handle realistic observational pressure without hiding loss or collapsing important context.&lt;br /&gt;
&lt;br /&gt;
The basic pipeline is:&amp;lt;pre&amp;gt;&lt;br /&gt;
TTY kernel paths&lt;br /&gt;
    -&amp;gt; kernel tracepoints&lt;br /&gt;
    -&amp;gt; BPF probes&lt;br /&gt;
    -&amp;gt; primary observation channel + auxiliary/status channel&lt;br /&gt;
    -&amp;gt; user-space spool&lt;br /&gt;
    -&amp;gt; collator&lt;br /&gt;
    -&amp;gt; downstream storage, analysis, policy, or reporting&lt;br /&gt;
&amp;lt;/pre&amp;gt;The probe’s job is bounded observation: copy raw bytes, attach factual context, assign ordering metadata, and emit records. The spool’s job is to drain kernel output quickly and forward it onward, relieving pressure on the kernel ring buffer channels. The collator’s job is reconstruction: reassemble fragments, check sequence continuity, place loss, resolve safe aggregations, and prepare externally useful event representations. &lt;br /&gt;
&lt;br /&gt;
=== Core Fidelity Invariants ===&lt;br /&gt;
A central invariant is that captured bytes are preserved as bytes. The probe does not decode Unicode, detect commands, infer prompts, identify student intent, or decide where lines begin and end. Payload may contain terminal control sequences, partial multi-byte characters, shell output, pasted text, or fragments of larger observations - the data is handled the same at the instrument level regardless of its nature. Decoding and semantic interpretation happen downstream. &lt;br /&gt;
&lt;br /&gt;
Another central invariant is that fragmentation must be explicit. A single observed TTY occurrence may require multiple emitted records if the payload is too large for the bounded BPF record size. Fragmentation is not loss; it is just the transport representation of one observation. If bytes cannot be emitted, the missing portion must remain visible through loss/status reporting rather than being hidden by truncation or best-effort concatenation. &lt;br /&gt;
&lt;br /&gt;
Ordering is based on sequence accounting, not timestamps alone. CPU-local sequence numbers are useful now for validating local emission order, fragmentation behavior, and probe diagnostics. The target design adds monotonic per-stream sequence numbers for authoritative stream-level ordering and loss placement. Timestamps are still important, but they support wall-clock calibration and approximate correlation rather than serving as the primary proof of order. &lt;br /&gt;
&lt;br /&gt;
Loss accounting is a first-class part of the instrument. The design explicitly prefers capturing less data with accurate loss reporting over capturing more data whose completeness cannot be trusted. Loss can occur in the kernel probe, spool, collator, or downstream ingestion path; where possible, loss reports should identify the origin, affected sequence range, affected identity context, and lost record or byte counts. &lt;br /&gt;
&lt;br /&gt;
The auxiliary/status channel exists because status records are needed to interpret captured data, but must not contend for transport resources with observational records. Health/status records may describe loss, backpressure, saturation, calibration, drift, lifecycle transitions, verifier failures, spool pressure, collator pressure, or reconstruction ambiguity. These records describe the behavior and reliability of the instrument, not additional user activity. &lt;br /&gt;
&lt;br /&gt;
=== Prototype Lineage ===&lt;br /&gt;
The instrument’s proof of concept is https://github.com/edurange/demo-bpf-tty-logger/tree/main, which demonstrates host-wide TTY capture using BPF kernel probes. That demo shows the basic idea: instead of attaching to one TTY device, it hooks kernel activity system-wide, captures raw buffers and context, and warns about feedback loops if the logger prints to a TTY it is also observing.&lt;br /&gt;
&lt;br /&gt;
The newer prototype, reflected in https://github.com/edurange/prototype-tty-bpf-instrument, moves beyond the early BCC/kprobe sketch toward the current tracepoint/CO-RE direction. It contains a kernel patch for tracepoints, BPF probe code, shared observation ABI, constants, event maps, CPU-local sequence state, and a user-space dump tool. That archive should be treated as the current implementation reference, while the formal design document remains the higher-level specification.&lt;br /&gt;
&lt;br /&gt;
=== Design Boundaries and Non-Goals ===&lt;br /&gt;
The instrument is not meant to be a simple keylogger, even though it necessarily captures raw terminal bytes. It is an instrument for producing trustworthy observational records from kernel TTY activity under realistic multi-user workloads. The important work is preserving factual byte activity, identity context, ordering evidence, timing calibration, fragmentation metadata, and loss visibility without letting the kernel probe become an analyzer. The probe observes; the spool drains; and the collator reconstructs. Interpretation is the domain of downstream systems that consume the instrument’s output.&lt;br /&gt;
&lt;br /&gt;
To keep the components individually maintainable, it is crucial to preserve these boundaries. Tracepoints should stay minimal and justifiable to kernel maintainers. BPF code should stay bounded and observational. The spool should reduce pressure without inferring meaning. The collator should only aggregate when identity, sequence continuity, fragment completeness, and loss boundaries make aggregation safe. Storage policy, privacy enforcement, governance, and final analysis are outside the instrument itself unless a later design explicitly brings them in.&lt;br /&gt;
&lt;br /&gt;
== Related ==&lt;br /&gt;
&lt;br /&gt;
=== Project Charter ===&lt;br /&gt;
[[TTY BPF Instrument Project Charter]]&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=TTY_BPF_Instrument_Project_Charter&amp;diff=927</id>
		<title>TTY BPF Instrument Project Charter</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=TTY_BPF_Instrument_Project_Charter&amp;diff=927"/>
		<updated>2026-06-06T01:29:09Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: Created page with &amp;quot;== Scoping Outline: ==  === Challenges: ===  * &amp;#039;&amp;#039;&amp;#039;What:&amp;#039;&amp;#039;&amp;#039; &amp;#039;&amp;#039;&amp;quot;Create ways to...&amp;quot; / &amp;quot;Redesign the...&amp;quot;&amp;#039;&amp;#039; ** Host-level observation instrument that can capture terminal byte activity at the kernel boundary without proxying, replacing, or modifying individual TTY devices. ** Modular BPF-based instrumentation pattern that preserves raw observations, identity context, ordering evidence, fragmentation metadata, timing calibration, and visible loss/status information, while keep...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Scoping Outline: ==&lt;br /&gt;
&lt;br /&gt;
=== Challenges: ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;What:&#039;&#039;&#039; &#039;&#039;&amp;quot;Create ways to...&amp;quot; / &amp;quot;Redesign the...&amp;quot;&#039;&#039;&lt;br /&gt;
** Host-level observation instrument that can capture terminal byte activity at the kernel boundary without proxying, replacing, or modifying individual TTY devices.&lt;br /&gt;
** Modular BPF-based instrumentation pattern that preserves raw observations, identity context, ordering evidence, fragmentation metadata, timing calibration, and visible loss/status information, while keeping semantic interpretation outside the probe.&lt;br /&gt;
** Tracepoint strategy that is narrow enough to be plausible for upstream kernel acceptance, so long-term use of the instrument does not depend on a small team maintaining private kernel patches indefinitely.&lt;br /&gt;
* &#039;&#039;&#039;For Whom:&#039;&#039;&#039; &#039;&#039;&amp;quot;For &amp;lt;user&amp;gt;... (considering &amp;lt;other stakeholders&amp;gt;)...&amp;quot;&#039;&#039;&lt;br /&gt;
** Developers, researchers, and infrastructure maintainers who need trustworthy records of terminal activity from multi-user Linux hosts, while preserving enough context for later reconstruction, validation, audit, or analysis.&lt;br /&gt;
** Small research and instructional infrastructure teams that can use BPF-based host instrumentation, but may not have enough durable kernel expertise to maintain private patch sets indefinitely.&lt;br /&gt;
** Future contributors and outside adopters who need the instrument to be understandable, modular, and useful outside its original project setting.&lt;br /&gt;
** Considering kernel maintainers as critical design stakeholders: the tracepoint surface must remain minimal, general-purpose, and reviewable as kernel instrumentation, rather than treating the kernel as a project-specific logging subsystem.&lt;br /&gt;
** Considering downstream consumers such as storage, analysis, privacy, governance, and reporting systems: the instrument should emit factual records with enough context to support those systems, without owning their policies or interpretations.&lt;br /&gt;
* &#039;&#039;&#039;Context:&#039;&#039;&#039; &#039;&#039;&amp;quot;In a world where...&amp;quot; / &amp;quot;Keeping in mind that...&amp;quot;&#039;&#039;&lt;br /&gt;
** Terminal activity is transient: if TTY byte activity is not observed as it occurs, the exact bytes, timing, ordering context, and kernel-side identity context cannot be reconstructed later from ordinary application state.&lt;br /&gt;
** TTY activity is structurally ambiguous: an apparent terminal session may involve shells, subprocesses, pseudoterminals, process privilege changes, etc. The instrument therefore needs to preserve raw identity axes rather than prematurely deciding what counts as a user, session, stream, or command.&lt;br /&gt;
** Raw terminal bytes are sensitive: the API consumers should not treat capture as endorsement of unrestricted use. Privacy classification, redaction, retention, consent, access control, and governance belong to deployment policy and downstream systems - the instrument&#039;s role is to expose system state, so governance is an explicit responsibility of the user.&lt;br /&gt;
** Private kernel patch maintenance is costly for a small research/infrastructure team: the tracepoint design should remain narrow, reviewable, and plausible for upstream acceptance wherever possible.&lt;br /&gt;
** The module should remain application-neutral: EDURange motivates the first deployment and validation workload, but the instrument should be useful to outside users who need careful host-level TTY observation. As an open-source module, EDURange benefits from the instrument&#039;s broader circulation and adoption.&lt;br /&gt;
&lt;br /&gt;
=== Considerations: ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Goals:&#039;&#039;&#039; &#039;&#039;&amp;quot;We aim to...&amp;quot;&#039;&#039;&lt;br /&gt;
** Provide an application-neutral TTY observation instrument that can be used by both EDURange and by outside teams without embedding EDURange-specific assumptions into the core design.&lt;br /&gt;
** Preserve captured terminal activity as raw bytes, not decoded commands, prompts, lines, or student actions.&lt;br /&gt;
** Keep kernel-space behavior bounded, observational, and non-interpretive: the probe should copy bytes, attach factual context, assign ordering metadata, and emit records, not analyze terminal content.&lt;br /&gt;
** Make loss visible. Capturing less data with accurate loss/status reporting is preferable to capturing more data whose completeness cannot be trusted.&lt;br /&gt;
** Make sequence accounting, not timestamps alone, the primary basis for ordering and loss placement.&lt;br /&gt;
** Preserve identity as an orthogonal vector of observed facts, not collapsed into a single inferred session identity.&lt;br /&gt;
** Keep the architectural responsibilities clear: tracepoints expose attachment surfaces; BPF probes observe; the spool drains and forwards; and the collator reconstructs. Only downstream consumers interpret, store, redact, govern, or analyze.&lt;br /&gt;
** Make the TTY instrument a reusable model for future host-level instruments, while avoiding premature generalization before the TTY capture path is empirically validated.&lt;br /&gt;
* &#039;&#039;&#039;Crux:&#039;&#039;&#039; &#039;&#039;&amp;quot;We really need to...&amp;quot;&#039;&#039;&lt;br /&gt;
** Produce trustworthy host-wide TTY observations without turning the kernel into a private logging subsystem.&lt;br /&gt;
** Determine whether the instrument can preserve enough byte, identity, ordering, timing, fragmentation, and loss context for downstream reconstruction while staying non-disruptive under realistic multi-user load.&lt;br /&gt;
** Find the smallest maintainable MVP that demonstrates the full path from kernel observation to user-space reconstruction, while keeping open the long-term path toward upstream tracepoints and broader adoption.&lt;br /&gt;
&lt;br /&gt;
== Framing Checklist: ==&lt;br /&gt;
We should discuss these qualities:&lt;br /&gt;
&lt;br /&gt;
* You tried on some behaviors (like empathy and rapid prototyping) on a current project before launching a new one.&lt;br /&gt;
* Project is a human, subjective challenge (understanding people is key to the project success).&lt;br /&gt;
* Project is geared toward discovery (not optimization).&lt;br /&gt;
* Challenge can be solved with a product, service, or event, not a strategy or system (for your first project).&lt;br /&gt;
* Those most affected by the work are acknowledged as actors with agency, not simply receivers of outcomes.&lt;br /&gt;
* Framing doesn’t embed a solution.&lt;br /&gt;
* Framing doesn’t (unintentionally) assume the form of the solution.&lt;br /&gt;
* Framing doesn’t (unintentionally) presume people’s needs.&lt;br /&gt;
* Goal of the project work is clear without dictating the specific solution outcome.&lt;br /&gt;
* You and your team actually care about this challenge. (If not, why are you doing it?)&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=926</id>
		<title>New Version Planning</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=926"/>
		<updated>2026-06-05T23:55:32Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* Links to Subprojects/Related Topics */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As we&#039;re still getting started, please feel free to insert notes and comments where you like - just try to avoid changing or deleting things you yourself didn&#039;t write. We&#039;ll come up with a shared structure as we go. Also consider using the &amp;quot;discussion&amp;quot; tab at the top if you aren&#039;t sure where to comment or want to have a dialogue.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
These items at the top are on the agenda for discussion, but don&#039;t have a clear place they belong yet:&lt;br /&gt;
* POST handler/API reachability&lt;br /&gt;
* Chat capability&lt;br /&gt;
* Containers&lt;br /&gt;
* NSJail, chroot?&lt;br /&gt;
* Physical systems: controlled via ordinary SSH, provisioned with PXE or other net boot methods?&lt;br /&gt;
* How to model shared resources and contention?&lt;br /&gt;
* Staged/prerequisite content in guide/scenario&lt;br /&gt;
* How to refer to observation/logging instruments&lt;br /&gt;
* How do we assess correctness/domain truth in a model of learning objectives?&lt;br /&gt;
** Navigating a filesystem:&lt;br /&gt;
*** &amp;quot;Can a student navigate the filesystem?&amp;quot;&lt;br /&gt;
**** &amp;quot;Can the student compose an absolute path?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student use / to begin an absolute path?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student refer to directories present at the root of the filesystem?&amp;quot;&lt;br /&gt;
**** &amp;quot;Do they correctly use find?&amp;quot;&lt;br /&gt;
**** &amp;quot;Does the student map correctly between paths and the purposes of the file hierarchy?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student look for binaries in /bin/?&amp;quot;&lt;br /&gt;
**** &amp;quot;Does the student use ~ to refer to their home directory?&amp;quot;&lt;br /&gt;
*** What artifacts help to assess this?&lt;br /&gt;
*Sample learning objectives:&lt;br /&gt;
**Hierarchically: course/domain; module/unit; learning objective; concept/skill cluster; knowledge components; tasks&lt;br /&gt;
**Scenario (course): Getting Started&lt;br /&gt;
***A learning objective: Students will be able to navigate the filesystem&lt;br /&gt;
****Compose paths&lt;br /&gt;
****Navigate directories&lt;br /&gt;
****Aware of working directory&lt;br /&gt;
****Understand path substitutions (~, ., etc.)&lt;br /&gt;
****etc...&lt;br /&gt;
***A more specific learning objective (or, alternately, a skill cluster): Student will be able to recognize (name) and locate a hidden file in a known directory&lt;br /&gt;
****Give filename of the file in question&lt;br /&gt;
****Identify the path of the file in question&lt;br /&gt;
****Source and provide information about the file, content in the file&lt;br /&gt;
****Differentiate between a file that is and isn&#039;t hidden&lt;br /&gt;
***Task (independent, but linked to learning objectives and knowledge components):&lt;br /&gt;
****Find all the files that end in &amp;quot;.gif&amp;quot; using the find command&lt;br /&gt;
***Knowledge component (independent, linked to tasks):&lt;br /&gt;
****Student uses &#039;find&amp;quot; when searching files&lt;br /&gt;
****Student uses &#039;ls&#039; to list and examine available files&lt;br /&gt;
****Student appropriately applies path prefix (absolute or relative)&lt;br /&gt;
****Student composes valid pattern for file extension&lt;br /&gt;
*Quality of life:&lt;br /&gt;
**Authentication/account naming is a system-wide standard/service&lt;br /&gt;
***How does this behave in designs where students are meant to seize other identities?&lt;br /&gt;
&lt;br /&gt;
=== Functional requirements: ===&lt;br /&gt;
&lt;br /&gt;
* 20-300 students simultaneously? Semi-simultaneously? ~150 concurrently, 500+ at large institutions?&lt;br /&gt;
* Telemetry layer for instrument/orchestration health&lt;br /&gt;
* Students are provided a learning environment that is safe to explore/attack without threatening the application host infrastructure&lt;br /&gt;
* Two weeks unattended uptime&lt;br /&gt;
* Per-user resource caps configurable per host (if not per-scenario or finer-grained)&lt;br /&gt;
&lt;br /&gt;
=== Ubiquitous language: ===&lt;br /&gt;
(See [https://agilealliance.org/glossary/ubiquitous-language/ What is Ubiquitous Language? | Agile Alliance], [https://www.cosmicpython.com/book/preface.html Cosmic Python] or [https://www.domainlanguage.com/ddd/ DDD Resources - Domain Language])&lt;br /&gt;
* Scenario&lt;br /&gt;
* Learning objectives&lt;br /&gt;
** e.g.&lt;br /&gt;
*** &amp;quot;Students will be able to use ls to list the contents of a directory&amp;quot;&lt;br /&gt;
*** &amp;quot;Students will be able to log in to the exercise via SSH&amp;quot;&lt;br /&gt;
** Milestone&lt;br /&gt;
*** Do these have sufficient resolution to see why/how students arrived at a correct answer?&lt;br /&gt;
** What are indicators of student progress on learning objectives?&lt;br /&gt;
*** How do events (such as those matched by milestones) correlate with questions?&lt;br /&gt;
**** What are students asking about in help requests?&lt;br /&gt;
**** Can we select semantics from log data (commands, chat, questions)?&lt;br /&gt;
** How do we know tangibly that a student is completing learning-related tasks?&lt;br /&gt;
* Questions (are exercises distinct?)&lt;br /&gt;
* Guide&lt;br /&gt;
** Guide section&lt;br /&gt;
* Student&lt;br /&gt;
* Instructor&lt;br /&gt;
* Hint&lt;br /&gt;
* Intervention&lt;br /&gt;
* Prediction&lt;br /&gt;
* Learning environment&lt;br /&gt;
** Underlying system state?&lt;br /&gt;
*** What denotes/distinguishes a command?&lt;br /&gt;
* Observability/instrumentation? (Name?)&lt;br /&gt;
&lt;br /&gt;
=== User stories: ===&lt;br /&gt;
(Supported use cases, written in terms of the ubiquitous language above)&lt;br /&gt;
* &#039;&#039;&#039;Instructors&#039;&#039;&#039; make &#039;&#039;&#039;Scenarios&#039;&#039;&#039; available to &#039;&#039;&#039;Students&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Scenarios&#039;&#039;&#039; configure the &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; to support &#039;&#039;&#039;Learning Objectives&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Scenarios&#039;&#039;&#039; may include a &#039;&#039;&#039;Guide&#039;&#039;&#039; to support exercises and tasks in the &#039;&#039;&#039;Learning Objectives&#039;&#039;&#039;&lt;br /&gt;
* The &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; can offer &#039;&#039;&#039;Interventions&#039;&#039;&#039; based on &#039;&#039;&#039;Student&#039;&#039;&#039; performance&lt;br /&gt;
* &#039;&#039;&#039;Students&#039;&#039;&#039; start and stop the &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; of &#039;&#039;&#039;Scenarios&#039;&#039;&#039; made accessible to them (the &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; does not consume resources when not in use)&lt;br /&gt;
&lt;br /&gt;
== Links to Subprojects/Related Topics ==&lt;br /&gt;
This section is temporary until we settle for a multi-topic organization for new version planning.&lt;br /&gt;
&lt;br /&gt;
* [[Data Store Project Charter]] - just the charter; pending a top-level topic page&lt;br /&gt;
* [[Message Bus Project Charter]] - also pending top-level page&lt;br /&gt;
* [[TTY BPF Instrument]] - mostly a placeholder topic page; links to repos/docs&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=Message_Bus_Project_Charter&amp;diff=925</id>
		<title>Message Bus Project Charter</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=Message_Bus_Project_Charter&amp;diff=925"/>
		<updated>2026-06-05T23:54:48Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: Created page with &amp;quot;== Scoping Outline: ==  === Challenges: ===  * &amp;#039;&amp;#039;&amp;#039;What:&amp;#039;&amp;#039;&amp;#039; &amp;#039;&amp;#039;&amp;quot;Create ways to...&amp;quot; / &amp;quot;Redesign the...&amp;quot;&amp;#039;&amp;#039; ** Design a local message bus that allows independently developed software components to communicate through typed broadcast topics without tightly coupling producers to consumers. ** Create a communication substrate whose traffic can be captured, replayed, and inspected, so that system behavior can be reconstructed after the fact. ** Support early-stage teams in which...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Scoping Outline: ==&lt;br /&gt;
&lt;br /&gt;
=== Challenges: ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;What:&#039;&#039;&#039; &#039;&#039;&amp;quot;Create ways to...&amp;quot; / &amp;quot;Redesign the...&amp;quot;&#039;&#039;&lt;br /&gt;
** Design a local message bus that allows independently developed software components to communicate through typed broadcast topics without tightly coupling producers to consumers.&lt;br /&gt;
** Create a communication substrate whose traffic can be captured, replayed, and inspected, so that system behavior can be reconstructed after the fact.&lt;br /&gt;
** Support early-stage teams in which contributors may be junior developers, by making the ordinary publishing and subscribing interface simple while centralizing transport, provenance, and capture policy.&lt;br /&gt;
* &#039;&#039;&#039;For Whom:&#039;&#039;&#039; &#039;&#039;&amp;quot;For &amp;lt;user&amp;gt;... (considering &amp;lt;other stakeholders&amp;gt;)...&amp;quot;&#039;&#039;&lt;br /&gt;
** For those building modular, event-driven experimental applications, especially teams where independently authored processors, interfaces, and services need to cooperate through stable contracts.&lt;br /&gt;
** For maintainers, researchers, and outside collaborators who need replayable records of component interaction sufficient for debugging, validation, and post hoc analysis.&lt;br /&gt;
* &#039;&#039;&#039;Context:&#039;&#039;&#039; &#039;&#039;&amp;quot;In a world where...&amp;quot; / &amp;quot;Keeping in mind that...&amp;quot;&#039;&#039;&lt;br /&gt;
** Keeping in mind that small research and academic software teams often need many contributors to work in parallel before all production infrastructure is complete.&lt;br /&gt;
** Keeping in mind that junior contributors should be able to write useful processors and application components without personally implementing persistence, provenance, or transport policy.&lt;br /&gt;
** Keeping in mind that event streams and request/reply interactions between components may become important evidence when reconstructing system behavior.&lt;br /&gt;
&lt;br /&gt;
=== Considerations: ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Goals:&#039;&#039;&#039; &#039;&#039;&amp;quot;We aim to...&amp;quot;&#039;&#039;&lt;br /&gt;
** Provide a local publish-subscribe API with minimal friction for ordinary component authors.&lt;br /&gt;
** Provide transport-level message identity, topic identity, writer identity, sequence numbering, and bus ingress timing.&lt;br /&gt;
** Support broad capture of messages crossing component boundaries so runs can be replayed or inspected.&lt;br /&gt;
** Support request/reply interactions without requiring direct component coupling.&lt;br /&gt;
** Support fixture playback and mock services so processors can be developed before production instruments, stores, or external integrations are ready.&lt;br /&gt;
** Allow multiple processors or interfaces to operate in parallel for comparison, A/B testing, and fault isolation.&lt;br /&gt;
** Remain self-contained enough to be used outside the initial use case application that motivated it.&lt;br /&gt;
* &#039;&#039;&#039;Crux:&#039;&#039;&#039; &#039;&#039;&amp;quot;We really need to...&amp;quot;&#039;&#039;&lt;br /&gt;
** Make component communication simple for contributors while making message flow accountable enough for replay, debugging, and later research use.&lt;br /&gt;
** Centralize transport discipline and capture-friendly metadata without forcing every component author to understand the full governance model.&lt;br /&gt;
&lt;br /&gt;
== Framing Checklist: ==&lt;br /&gt;
We should discuss these qualities:&lt;br /&gt;
&lt;br /&gt;
* You tried on some behaviors (like empathy and rapid prototyping) on a current project before launching a new one.&lt;br /&gt;
* Project is a human, subjective challenge (understanding people is key to the project success).&lt;br /&gt;
* Project is geared toward discovery (not optimization).&lt;br /&gt;
* Challenge can be solved with a product, service, or event, not a strategy or system (for your first project).&lt;br /&gt;
* Those most affected by the work are acknowledged as actors with agency, not simply receivers of outcomes.&lt;br /&gt;
* Framing doesn’t embed a solution.&lt;br /&gt;
* Framing doesn’t (unintentionally) assume the form of the solution.&lt;br /&gt;
* Framing doesn’t (unintentionally) presume people’s needs.&lt;br /&gt;
* Goal of the project work is clear without dictating the specific solution outcome.&lt;br /&gt;
* You and your team actually care about this challenge. (If not, why are you doing it?)&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=TTY_BPF_Instrument&amp;diff=924</id>
		<title>TTY BPF Instrument</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=TTY_BPF_Instrument&amp;diff=924"/>
		<updated>2026-06-04T16:44:04Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* Prototype Lineage */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Demonstrations and preliminary documentation can be found at https://github.com/edurange/demo-bpf-tty-logger and https://github.com/edurange/prototype-tty-bpf-instrument. Wiki documentation is pending. Preliminary draft files are available in the Discord &amp;lt;code&amp;gt;#fileshare&amp;lt;/code&amp;gt; channel and will be linked here once Wiki configuration is adjusted to support attached non-image documents.&lt;br /&gt;
&lt;br /&gt;
== Summary ==&lt;br /&gt;
The TTY instrument is a BPF-based kernel observation tool for capturing terminal activity across a host system. Its purpose is to observe TTY byte activity at the kernel boundary and emit records that downstream components can reconstruct, validate, and analyze. The current design treats terminal activity as factual kernel observations first, and reserves higher-level ideas like sessions, users, commands, and learning activity for later interpretation rather than things the probe is responsible for determining. &lt;br /&gt;
&lt;br /&gt;
Although the immediate target is TTY activity, the design is also meant to serve as a template for future host-level instruments. It separates kernel observation, bounded transport, user-space draining, reconstruction, and downstream interpretation in a way that can be reused for other event sources. The TTY instrument is intentionally demanding: it involves raw byte payloads, ambiguous identity, ordering challenges, fragmentation, loss accounting, and privacy-sensitive data. If this pattern works for TTY activity, it provides a strong example for building other instruments with similar fidelity and reliability requirements - for the kernel-level services like the filesystem or network interfaces, common user-level processes like `bash`, etc.&lt;br /&gt;
&lt;br /&gt;
=== Why TTY Capture Is Difficult ===&lt;br /&gt;
This project is difficult because TTY activity is not a single clean stream. A user’s apparent terminal session can pass through SSH, shells, subprocesses, pseudoterminals, terminal echo, line discipline behavior, containers, namespaces, and reconnects. The instrument therefore preserves multiple identity axes, including process IDs, user IDs, cgroups, namespaces, TTY device numbers, inode context, and command/process names. No one field is treated as “the session” or “the user.” Streams or sessions may later be reconstructed from these facts, but the raw record should keep the original evidence intact.&lt;br /&gt;
&lt;br /&gt;
=== Tracepoints, BPF, and Kernel Boundaries ===&lt;br /&gt;
The current implementation is tracepoint-based. Kernel tracepoints provide minimal, upstream-plausible attachment surfaces; BPF probes attach to those tracepoints, read bounded payload buffers, enrich records with available identity/timing/ordering context, and emit records through BPF ring buffers. The tracepoint is not the logging schema. The BPF observation record is the instrument-facing schema.&lt;br /&gt;
&lt;br /&gt;
The current prototype includes kernel modifications because the existing kernel does not expose all of the stable attachment surfaces needed for this instrument. Those modifications add narrowly scoped TTY tracepoints so BPF programs can attach at the relevant read, write, and line-discipline receive paths. The goal is not to move logging policy into the kernel or to make the kernel produce the full instrument schema.&lt;br /&gt;
&lt;br /&gt;
The kernel changes must remain minimal, reviewable, and justifiable as tracepoint support; the BPF probe and user-space components remain responsible for the instrument-specific observation record, enrichment, transport, and reconstruction behavior. This distinction matters because tracepoints need to be acceptable to upstream kernel maintainers as general kernel instrumentation, while the richer record schema is specific to this project’s BPF instrument.&lt;br /&gt;
&lt;br /&gt;
=== Pipeline and Component Responsibilities ===&lt;br /&gt;
The TTY instrument is also important because it represents a characteristic high-pressure workload for the larger event pipeline. Human terminal input can produce many small payloads with high metadata overhead, while command output can produce sudden bursts of larger byte streams. Multi-user SSH workloads combine both patterns. This makes the instrument a useful benchmark for the event bus and downstream data store: it exercises throughput, buffering, ordering, loss reporting, payload preservation, and reconstruction under conditions that are demanding yet meaningful with a minimal number of tracepoints and probe sites. (Contrast with the filesystem, where roughly a dozen such sites are needed to capture the wider variety of filesystem operations.) The TTY instrument does not define the bus or storage policy, but its output should help reveal whether those downstream systems can handle realistic observational pressure without hiding loss or collapsing important context.&lt;br /&gt;
&lt;br /&gt;
The basic pipeline is:&amp;lt;pre&amp;gt;&lt;br /&gt;
TTY kernel paths&lt;br /&gt;
    -&amp;gt; kernel tracepoints&lt;br /&gt;
    -&amp;gt; BPF probes&lt;br /&gt;
    -&amp;gt; primary observation channel + auxiliary/status channel&lt;br /&gt;
    -&amp;gt; user-space spool&lt;br /&gt;
    -&amp;gt; collator&lt;br /&gt;
    -&amp;gt; downstream storage, analysis, policy, or reporting&lt;br /&gt;
&amp;lt;/pre&amp;gt;The probe’s job is bounded observation: copy raw bytes, attach factual context, assign ordering metadata, and emit records. The spool’s job is to drain kernel output quickly and forward it onward, relieving pressure on the kernel ring buffer channels. The collator’s job is reconstruction: reassemble fragments, check sequence continuity, place loss, resolve safe aggregations, and prepare externally useful event representations. &lt;br /&gt;
&lt;br /&gt;
=== Core Fidelity Invariants ===&lt;br /&gt;
A central invariant is that captured bytes are preserved as bytes. The probe does not decode Unicode, detect commands, infer prompts, identify student intent, or decide where lines begin and end. Payload may contain terminal control sequences, partial multi-byte characters, shell output, pasted text, or fragments of larger observations - the data is handled the same at the instrument level regardless of its nature. Decoding and semantic interpretation happen downstream. &lt;br /&gt;
&lt;br /&gt;
Another central invariant is that fragmentation must be explicit. A single observed TTY occurrence may require multiple emitted records if the payload is too large for the bounded BPF record size. Fragmentation is not loss; it is just the transport representation of one observation. If bytes cannot be emitted, the missing portion must remain visible through loss/status reporting rather than being hidden by truncation or best-effort concatenation. &lt;br /&gt;
&lt;br /&gt;
Ordering is based on sequence accounting, not timestamps alone. CPU-local sequence numbers are useful now for validating local emission order, fragmentation behavior, and probe diagnostics. The target design adds monotonic per-stream sequence numbers for authoritative stream-level ordering and loss placement. Timestamps are still important, but they support wall-clock calibration and approximate correlation rather than serving as the primary proof of order. &lt;br /&gt;
&lt;br /&gt;
Loss accounting is a first-class part of the instrument. The design explicitly prefers capturing less data with accurate loss reporting over capturing more data whose completeness cannot be trusted. Loss can occur in the kernel probe, spool, collator, or downstream ingestion path; where possible, loss reports should identify the origin, affected sequence range, affected identity context, and lost record or byte counts. &lt;br /&gt;
&lt;br /&gt;
The auxiliary/status channel exists because status records are needed to interpret captured data, but must not contend for transport resources with observational records. Health/status records may describe loss, backpressure, saturation, calibration, drift, lifecycle transitions, verifier failures, spool pressure, collator pressure, or reconstruction ambiguity. These records describe the behavior and reliability of the instrument, not additional user activity. &lt;br /&gt;
&lt;br /&gt;
=== Prototype Lineage ===&lt;br /&gt;
The instrument’s proof of concept is https://github.com/edurange/demo-bpf-tty-logger/tree/main, which demonstrates host-wide TTY capture using BPF kernel probes. That demo shows the basic idea: instead of attaching to one TTY device, it hooks kernel activity system-wide, captures raw buffers and context, and warns about feedback loops if the logger prints to a TTY it is also observing.&lt;br /&gt;
&lt;br /&gt;
The newer prototype, reflected in https://github.com/edurange/prototype-tty-bpf-instrument, moves beyond the early BCC/kprobe sketch toward the current tracepoint/CO-RE direction. It contains a kernel patch for tracepoints, BPF probe code, shared observation ABI, constants, event maps, CPU-local sequence state, and a user-space dump tool. That archive should be treated as the current implementation reference, while the formal design document remains the higher-level specification.&lt;br /&gt;
&lt;br /&gt;
=== Design Boundaries and Non-Goals ===&lt;br /&gt;
The instrument is not meant to be a simple keylogger, even though it necessarily captures raw terminal bytes. It is an instrument for producing trustworthy observational records from kernel TTY activity under realistic multi-user workloads. The important work is preserving factual byte activity, identity context, ordering evidence, timing calibration, fragmentation metadata, and loss visibility without letting the kernel probe become an analyzer. The probe observes; the spool drains; and the collator reconstructs. Interpretation is the domain of downstream systems that consume the instrument’s output.&lt;br /&gt;
&lt;br /&gt;
To keep the components individually maintainable, it is crucial to preserve these boundaries. Tracepoints should stay minimal and justifiable to kernel maintainers. BPF code should stay bounded and observational. The spool should reduce pressure without inferring meaning. The collator should only aggregate when identity, sequence continuity, fragment completeness, and loss boundaries make aggregation safe. Storage policy, privacy enforcement, governance, and final analysis are outside the instrument itself unless a later design explicitly brings them in.&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=TTY_BPF_Instrument&amp;diff=923</id>
		<title>TTY BPF Instrument</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=TTY_BPF_Instrument&amp;diff=923"/>
		<updated>2026-06-04T16:42:06Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* Core Fidelity Invariants */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Demonstrations and preliminary documentation can be found at https://github.com/edurange/demo-bpf-tty-logger and https://github.com/edurange/prototype-tty-bpf-instrument. Wiki documentation is pending. Preliminary draft files are available in the Discord &amp;lt;code&amp;gt;#fileshare&amp;lt;/code&amp;gt; channel and will be linked here once Wiki configuration is adjusted to support attached non-image documents.&lt;br /&gt;
&lt;br /&gt;
== Summary ==&lt;br /&gt;
The TTY instrument is a BPF-based kernel observation tool for capturing terminal activity across a host system. Its purpose is to observe TTY byte activity at the kernel boundary and emit records that downstream components can reconstruct, validate, and analyze. The current design treats terminal activity as factual kernel observations first, and reserves higher-level ideas like sessions, users, commands, and learning activity for later interpretation rather than things the probe is responsible for determining. &lt;br /&gt;
&lt;br /&gt;
Although the immediate target is TTY activity, the design is also meant to serve as a template for future host-level instruments. It separates kernel observation, bounded transport, user-space draining, reconstruction, and downstream interpretation in a way that can be reused for other event sources. The TTY instrument is intentionally demanding: it involves raw byte payloads, ambiguous identity, ordering challenges, fragmentation, loss accounting, and privacy-sensitive data. If this pattern works for TTY activity, it provides a strong example for building other instruments with similar fidelity and reliability requirements - for the kernel-level services like the filesystem or network interfaces, common user-level processes like `bash`, etc.&lt;br /&gt;
&lt;br /&gt;
=== Why TTY Capture Is Difficult ===&lt;br /&gt;
This project is difficult because TTY activity is not a single clean stream. A user’s apparent terminal session can pass through SSH, shells, subprocesses, pseudoterminals, terminal echo, line discipline behavior, containers, namespaces, and reconnects. The instrument therefore preserves multiple identity axes, including process IDs, user IDs, cgroups, namespaces, TTY device numbers, inode context, and command/process names. No one field is treated as “the session” or “the user.” Streams or sessions may later be reconstructed from these facts, but the raw record should keep the original evidence intact.&lt;br /&gt;
&lt;br /&gt;
=== Tracepoints, BPF, and Kernel Boundaries ===&lt;br /&gt;
The current implementation is tracepoint-based. Kernel tracepoints provide minimal, upstream-plausible attachment surfaces; BPF probes attach to those tracepoints, read bounded payload buffers, enrich records with available identity/timing/ordering context, and emit records through BPF ring buffers. The tracepoint is not the logging schema. The BPF observation record is the instrument-facing schema.&lt;br /&gt;
&lt;br /&gt;
The current prototype includes kernel modifications because the existing kernel does not expose all of the stable attachment surfaces needed for this instrument. Those modifications add narrowly scoped TTY tracepoints so BPF programs can attach at the relevant read, write, and line-discipline receive paths. The goal is not to move logging policy into the kernel or to make the kernel produce the full instrument schema.&lt;br /&gt;
&lt;br /&gt;
The kernel changes must remain minimal, reviewable, and justifiable as tracepoint support; the BPF probe and user-space components remain responsible for the instrument-specific observation record, enrichment, transport, and reconstruction behavior. This distinction matters because tracepoints need to be acceptable to upstream kernel maintainers as general kernel instrumentation, while the richer record schema is specific to this project’s BPF instrument.&lt;br /&gt;
&lt;br /&gt;
=== Pipeline and Component Responsibilities ===&lt;br /&gt;
The TTY instrument is also important because it represents a characteristic high-pressure workload for the larger event pipeline. Human terminal input can produce many small payloads with high metadata overhead, while command output can produce sudden bursts of larger byte streams. Multi-user SSH workloads combine both patterns. This makes the instrument a useful benchmark for the event bus and downstream data store: it exercises throughput, buffering, ordering, loss reporting, payload preservation, and reconstruction under conditions that are demanding yet meaningful with a minimal number of tracepoints and probe sites. (Contrast with the filesystem, where roughly a dozen such sites are needed to capture the wider variety of filesystem operations.) The TTY instrument does not define the bus or storage policy, but its output should help reveal whether those downstream systems can handle realistic observational pressure without hiding loss or collapsing important context.&lt;br /&gt;
&lt;br /&gt;
The basic pipeline is:&amp;lt;pre&amp;gt;&lt;br /&gt;
TTY kernel paths&lt;br /&gt;
    -&amp;gt; kernel tracepoints&lt;br /&gt;
    -&amp;gt; BPF probes&lt;br /&gt;
    -&amp;gt; primary observation channel + auxiliary/status channel&lt;br /&gt;
    -&amp;gt; user-space spool&lt;br /&gt;
    -&amp;gt; collator&lt;br /&gt;
    -&amp;gt; downstream storage, analysis, policy, or reporting&lt;br /&gt;
&amp;lt;/pre&amp;gt;The probe’s job is bounded observation: copy raw bytes, attach factual context, assign ordering metadata, and emit records. The spool’s job is to drain kernel output quickly and forward it onward, relieving pressure on the kernel ring buffer channels. The collator’s job is reconstruction: reassemble fragments, check sequence continuity, place loss, resolve safe aggregations, and prepare externally useful event representations. &lt;br /&gt;
&lt;br /&gt;
=== Core Fidelity Invariants ===&lt;br /&gt;
A central invariant is that captured bytes are preserved as bytes. The probe does not decode Unicode, detect commands, infer prompts, identify student intent, or decide where lines begin and end. Payload may contain terminal control sequences, partial multi-byte characters, shell output, pasted text, or fragments of larger observations - the data is handled the same at the instrument level regardless of its nature. Decoding and semantic interpretation happen downstream. &lt;br /&gt;
&lt;br /&gt;
Another central invariant is that fragmentation must be explicit. A single observed TTY occurrence may require multiple emitted records if the payload is too large for the bounded BPF record size. Fragmentation is not loss; it is just the transport representation of one observation. If bytes cannot be emitted, the missing portion must remain visible through loss/status reporting rather than being hidden by truncation or best-effort concatenation. &lt;br /&gt;
&lt;br /&gt;
Ordering is based on sequence accounting, not timestamps alone. CPU-local sequence numbers are useful now for validating local emission order, fragmentation behavior, and probe diagnostics. The target design adds monotonic per-stream sequence numbers for authoritative stream-level ordering and loss placement. Timestamps are still important, but they support wall-clock calibration and approximate correlation rather than serving as the primary proof of order. &lt;br /&gt;
&lt;br /&gt;
Loss accounting is a first-class part of the instrument. The design explicitly prefers capturing less data with accurate loss reporting over capturing more data whose completeness cannot be trusted. Loss can occur in the kernel probe, spool, collator, or downstream ingestion path; where possible, loss reports should identify the origin, affected sequence range, affected identity context, and lost record or byte counts. &lt;br /&gt;
&lt;br /&gt;
The auxiliary/status channel exists because status records are needed to interpret captured data, but must not contend for transport resources with observational records. Health/status records may describe loss, backpressure, saturation, calibration, drift, lifecycle transitions, verifier failures, spool pressure, collator pressure, or reconstruction ambiguity. These records describe the behavior and reliability of the instrument, not additional user activity. &lt;br /&gt;
&lt;br /&gt;
=== Prototype Lineage ===&lt;br /&gt;
The instrument’s proof of concept is &amp;lt;nowiki&amp;gt;https://github.com/edurange/demo-bpf-tty-logger/tree/main&amp;lt;/nowiki&amp;gt;, which demonstrates host-wide TTY capture using BPF kernel probes. That demo shows the basic idea: instead of attaching to one TTY device, it hooks kernel activity system-wide, captures raw buffers and context, and warns about feedback loops if the logger prints to a TTY it is also observing.&lt;br /&gt;
&lt;br /&gt;
The newer prototype, reflected in &amp;lt;nowiki&amp;gt;https://github.com/edurange/prototype-tty-bpf-instrument&amp;lt;/nowiki&amp;gt;, moves beyond the early BCC/kprobe sketch toward the current tracepoint/CO-RE direction. It contains a kernel patch for tracepoints, BPF probe code, shared observation ABI, constants, event maps, CPU-local sequence state, and a user-space dump tool. That archive should be treated as the current implementation reference, while the formal design document remains the higher-level specification.&lt;br /&gt;
&lt;br /&gt;
=== Design Boundaries and Non-Goals ===&lt;br /&gt;
The instrument is not meant to be a simple keylogger, even though it necessarily captures raw terminal bytes. It is an instrument for producing trustworthy observational records from kernel TTY activity under realistic multi-user workloads. The important work is preserving factual byte activity, identity context, ordering evidence, timing calibration, fragmentation metadata, and loss visibility without letting the kernel probe become an analyzer. The probe observes; the spool drains; and the collator reconstructs. Interpretation is the domain of downstream systems that consume the instrument’s output.&lt;br /&gt;
&lt;br /&gt;
To keep the components individually maintainable, it is crucial to preserve these boundaries. Tracepoints should stay minimal and justifiable to kernel maintainers. BPF code should stay bounded and observational. The spool should reduce pressure without inferring meaning. The collator should only aggregate when identity, sequence continuity, fragment completeness, and loss boundaries make aggregation safe. Storage policy, privacy enforcement, governance, and final analysis are outside the instrument itself unless a later design explicitly brings them in.&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=TTY_BPF_Instrument&amp;diff=922</id>
		<title>TTY BPF Instrument</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=TTY_BPF_Instrument&amp;diff=922"/>
		<updated>2026-06-04T16:17:09Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: Created page with &amp;quot;Demonstrations and preliminary documentation can be found at https://github.com/edurange/demo-bpf-tty-logger and https://github.com/edurange/prototype-tty-bpf-instrument. Wiki documentation is pending. Preliminary draft files are available in the Discord &amp;lt;code&amp;gt;#fileshare&amp;lt;/code&amp;gt; channel and will be linked here once Wiki configuration is adjusted to support attached non-image documents.  == Summary == The TTY instrument is a BPF-based kernel observation tool for capturing...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Demonstrations and preliminary documentation can be found at https://github.com/edurange/demo-bpf-tty-logger and https://github.com/edurange/prototype-tty-bpf-instrument. Wiki documentation is pending. Preliminary draft files are available in the Discord &amp;lt;code&amp;gt;#fileshare&amp;lt;/code&amp;gt; channel and will be linked here once Wiki configuration is adjusted to support attached non-image documents.&lt;br /&gt;
&lt;br /&gt;
== Summary ==&lt;br /&gt;
The TTY instrument is a BPF-based kernel observation tool for capturing terminal activity across a host system. Its purpose is to observe TTY byte activity at the kernel boundary and emit records that downstream components can reconstruct, validate, and analyze. The current design treats terminal activity as factual kernel observations first, and reserves higher-level ideas like sessions, users, commands, and learning activity for later interpretation rather than things the probe is responsible for determining. &lt;br /&gt;
&lt;br /&gt;
Although the immediate target is TTY activity, the design is also meant to serve as a template for future host-level instruments. It separates kernel observation, bounded transport, user-space draining, reconstruction, and downstream interpretation in a way that can be reused for other event sources. The TTY instrument is intentionally demanding: it involves raw byte payloads, ambiguous identity, ordering challenges, fragmentation, loss accounting, and privacy-sensitive data. If this pattern works for TTY activity, it provides a strong example for building other instruments with similar fidelity and reliability requirements - for the kernel-level services like the filesystem or network interfaces, common user-level processes like `bash`, etc.&lt;br /&gt;
&lt;br /&gt;
=== Why TTY Capture Is Difficult ===&lt;br /&gt;
This project is difficult because TTY activity is not a single clean stream. A user’s apparent terminal session can pass through SSH, shells, subprocesses, pseudoterminals, terminal echo, line discipline behavior, containers, namespaces, and reconnects. The instrument therefore preserves multiple identity axes, including process IDs, user IDs, cgroups, namespaces, TTY device numbers, inode context, and command/process names. No one field is treated as “the session” or “the user.” Streams or sessions may later be reconstructed from these facts, but the raw record should keep the original evidence intact.&lt;br /&gt;
&lt;br /&gt;
=== Tracepoints, BPF, and Kernel Boundaries ===&lt;br /&gt;
The current implementation is tracepoint-based. Kernel tracepoints provide minimal, upstream-plausible attachment surfaces; BPF probes attach to those tracepoints, read bounded payload buffers, enrich records with available identity/timing/ordering context, and emit records through BPF ring buffers. The tracepoint is not the logging schema. The BPF observation record is the instrument-facing schema.&lt;br /&gt;
&lt;br /&gt;
The current prototype includes kernel modifications because the existing kernel does not expose all of the stable attachment surfaces needed for this instrument. Those modifications add narrowly scoped TTY tracepoints so BPF programs can attach at the relevant read, write, and line-discipline receive paths. The goal is not to move logging policy into the kernel or to make the kernel produce the full instrument schema.&lt;br /&gt;
&lt;br /&gt;
The kernel changes must remain minimal, reviewable, and justifiable as tracepoint support; the BPF probe and user-space components remain responsible for the instrument-specific observation record, enrichment, transport, and reconstruction behavior. This distinction matters because tracepoints need to be acceptable to upstream kernel maintainers as general kernel instrumentation, while the richer record schema is specific to this project’s BPF instrument.&lt;br /&gt;
&lt;br /&gt;
=== Pipeline and Component Responsibilities ===&lt;br /&gt;
The TTY instrument is also important because it represents a characteristic high-pressure workload for the larger event pipeline. Human terminal input can produce many small payloads with high metadata overhead, while command output can produce sudden bursts of larger byte streams. Multi-user SSH workloads combine both patterns. This makes the instrument a useful benchmark for the event bus and downstream data store: it exercises throughput, buffering, ordering, loss reporting, payload preservation, and reconstruction under conditions that are demanding yet meaningful with a minimal number of tracepoints and probe sites. (Contrast with the filesystem, where roughly a dozen such sites are needed to capture the wider variety of filesystem operations.) The TTY instrument does not define the bus or storage policy, but its output should help reveal whether those downstream systems can handle realistic observational pressure without hiding loss or collapsing important context.&lt;br /&gt;
&lt;br /&gt;
The basic pipeline is:&amp;lt;pre&amp;gt;&lt;br /&gt;
TTY kernel paths&lt;br /&gt;
    -&amp;gt; kernel tracepoints&lt;br /&gt;
    -&amp;gt; BPF probes&lt;br /&gt;
    -&amp;gt; primary observation channel + auxiliary/status channel&lt;br /&gt;
    -&amp;gt; user-space spool&lt;br /&gt;
    -&amp;gt; collator&lt;br /&gt;
    -&amp;gt; downstream storage, analysis, policy, or reporting&lt;br /&gt;
&amp;lt;/pre&amp;gt;The probe’s job is bounded observation: copy raw bytes, attach factual context, assign ordering metadata, and emit records. The spool’s job is to drain kernel output quickly and forward it onward, relieving pressure on the kernel ring buffer channels. The collator’s job is reconstruction: reassemble fragments, check sequence continuity, place loss, resolve safe aggregations, and prepare externally useful event representations. &lt;br /&gt;
&lt;br /&gt;
=== Core Fidelity Invariants ===&lt;br /&gt;
A central invariant is that captured bytes are preserved as bytes. The probe does not decode Unicode, detect commands, infer prompts, identify student intent, or decide where lines begin and end. Payload may contain terminal control sequences, partial multi-byte characters, shell output, pasted text, or fragments of larger observations - the data is handled the same at the instrument level regardless of its nature. Decoding and semantic interpretation happen downstream. &lt;br /&gt;
&lt;br /&gt;
Another central invariant is that fragmentation must be explicit. A single observed TTY occurrence may require multiple emitted records if the payload is too large for the bounded BPF record size. Fragmentation is not loss; it is just the transport representation of one observation. If bytes cannot be emitted, the missing portion must remain visible through loss/status reporting rather than being hidden by truncation or best-effort concatenation. &lt;br /&gt;
&lt;br /&gt;
Ordering is based on sequence accounting, not timestamps alone. CPU-local sequence numbers are useful now for validating local emission order, fragmentation behavior, and probe diagnostics. The target design adds monotonic per-stream sequence numbers for authoritative stream-level ordering and loss placement. Timestamps are still important, but they support wall-clock calibration and approximate correlation rather than serving as the primary proof of order. &lt;br /&gt;
&lt;br /&gt;
Loss accounting is a first-class part of the instrument. The design explicitly prefers capturing less data with accurate loss reporting over capturing more data whose completeness cannot be trusted. Loss can occur in the kernel probe, spool, collator, or downstream ingestion path; where possible, loss reports should identify the origin, affected sequence range, affected identity context, and lost record or byte counts. &lt;br /&gt;
&lt;br /&gt;
The auxiliary/status channel exists because status records are needed to interpret captured data. Health/status records may describe loss, backpressure, saturation, calibration, drift, lifecycle transitions, verifier failures, spool pressure, collator pressure, or reconstruction ambiguity. These records describe the behavior and reliability of the instrument, not additional user activity. &lt;br /&gt;
&lt;br /&gt;
=== Prototype Lineage ===&lt;br /&gt;
The instrument’s proof of concept is &amp;lt;nowiki&amp;gt;https://github.com/edurange/demo-bpf-tty-logger/tree/main&amp;lt;/nowiki&amp;gt;, which demonstrates host-wide TTY capture using BPF kernel probes. That demo shows the basic idea: instead of attaching to one TTY device, it hooks kernel activity system-wide, captures raw buffers and context, and warns about feedback loops if the logger prints to a TTY it is also observing.&lt;br /&gt;
&lt;br /&gt;
The newer prototype, reflected in &amp;lt;nowiki&amp;gt;https://github.com/edurange/prototype-tty-bpf-instrument&amp;lt;/nowiki&amp;gt;, moves beyond the early BCC/kprobe sketch toward the current tracepoint/CO-RE direction. It contains a kernel patch for tracepoints, BPF probe code, shared observation ABI, constants, event maps, CPU-local sequence state, and a user-space dump tool. That archive should be treated as the current implementation reference, while the formal design document remains the higher-level specification.&lt;br /&gt;
&lt;br /&gt;
=== Design Boundaries and Non-Goals ===&lt;br /&gt;
The instrument is not meant to be a simple keylogger, even though it necessarily captures raw terminal bytes. It is an instrument for producing trustworthy observational records from kernel TTY activity under realistic multi-user workloads. The important work is preserving factual byte activity, identity context, ordering evidence, timing calibration, fragmentation metadata, and loss visibility without letting the kernel probe become an analyzer. The probe observes; the spool drains; and the collator reconstructs. Interpretation is the domain of downstream systems that consume the instrument’s output.&lt;br /&gt;
&lt;br /&gt;
To keep the components individually maintainable, it is crucial to preserve these boundaries. Tracepoints should stay minimal and justifiable to kernel maintainers. BPF code should stay bounded and observational. The spool should reduce pressure without inferring meaning. The collator should only aggregate when identity, sequence continuity, fragment completeness, and loss boundaries make aggregation safe. Storage policy, privacy enforcement, governance, and final analysis are outside the instrument itself unless a later design explicitly brings them in.&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=921</id>
		<title>New Version Planning</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=921"/>
		<updated>2026-06-04T15:56:44Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As we&#039;re still getting started, please feel free to insert notes and comments where you like - just try to avoid changing or deleting things you yourself didn&#039;t write. We&#039;ll come up with a shared structure as we go. Also consider using the &amp;quot;discussion&amp;quot; tab at the top if you aren&#039;t sure where to comment or want to have a dialogue.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
These items at the top are on the agenda for discussion, but don&#039;t have a clear place they belong yet:&lt;br /&gt;
* POST handler/API reachability&lt;br /&gt;
* Chat capability&lt;br /&gt;
* Containers&lt;br /&gt;
* NSJail, chroot?&lt;br /&gt;
* Physical systems: controlled via ordinary SSH, provisioned with PXE or other net boot methods?&lt;br /&gt;
* How to model shared resources and contention?&lt;br /&gt;
* Staged/prerequisite content in guide/scenario&lt;br /&gt;
* How to refer to observation/logging instruments&lt;br /&gt;
* How do we assess correctness/domain truth in a model of learning objectives?&lt;br /&gt;
** Navigating a filesystem:&lt;br /&gt;
*** &amp;quot;Can a student navigate the filesystem?&amp;quot;&lt;br /&gt;
**** &amp;quot;Can the student compose an absolute path?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student use / to begin an absolute path?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student refer to directories present at the root of the filesystem?&amp;quot;&lt;br /&gt;
**** &amp;quot;Do they correctly use find?&amp;quot;&lt;br /&gt;
**** &amp;quot;Does the student map correctly between paths and the purposes of the file hierarchy?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student look for binaries in /bin/?&amp;quot;&lt;br /&gt;
**** &amp;quot;Does the student use ~ to refer to their home directory?&amp;quot;&lt;br /&gt;
*** What artifacts help to assess this?&lt;br /&gt;
*Sample learning objectives:&lt;br /&gt;
**Hierarchically: course/domain; module/unit; learning objective; concept/skill cluster; knowledge components; tasks&lt;br /&gt;
**Scenario (course): Getting Started&lt;br /&gt;
***A learning objective: Students will be able to navigate the filesystem&lt;br /&gt;
****Compose paths&lt;br /&gt;
****Navigate directories&lt;br /&gt;
****Aware of working directory&lt;br /&gt;
****Understand path substitutions (~, ., etc.)&lt;br /&gt;
****etc...&lt;br /&gt;
***A more specific learning objective (or, alternately, a skill cluster): Student will be able to recognize (name) and locate a hidden file in a known directory&lt;br /&gt;
****Give filename of the file in question&lt;br /&gt;
****Identify the path of the file in question&lt;br /&gt;
****Source and provide information about the file, content in the file&lt;br /&gt;
****Differentiate between a file that is and isn&#039;t hidden&lt;br /&gt;
***Task (independent, but linked to learning objectives and knowledge components):&lt;br /&gt;
****Find all the files that end in &amp;quot;.gif&amp;quot; using the find command&lt;br /&gt;
***Knowledge component (independent, linked to tasks):&lt;br /&gt;
****Student uses &#039;find&amp;quot; when searching files&lt;br /&gt;
****Student uses &#039;ls&#039; to list and examine available files&lt;br /&gt;
****Student appropriately applies path prefix (absolute or relative)&lt;br /&gt;
****Student composes valid pattern for file extension&lt;br /&gt;
*Quality of life:&lt;br /&gt;
**Authentication/account naming is a system-wide standard/service&lt;br /&gt;
***How does this behave in designs where students are meant to seize other identities?&lt;br /&gt;
&lt;br /&gt;
=== Functional requirements: ===&lt;br /&gt;
&lt;br /&gt;
* 20-300 students simultaneously? Semi-simultaneously? ~150 concurrently, 500+ at large institutions?&lt;br /&gt;
* Telemetry layer for instrument/orchestration health&lt;br /&gt;
* Students are provided a learning environment that is safe to explore/attack without threatening the application host infrastructure&lt;br /&gt;
* Two weeks unattended uptime&lt;br /&gt;
* Per-user resource caps configurable per host (if not per-scenario or finer-grained)&lt;br /&gt;
&lt;br /&gt;
=== Ubiquitous language: ===&lt;br /&gt;
(See [https://agilealliance.org/glossary/ubiquitous-language/ What is Ubiquitous Language? | Agile Alliance], [https://www.cosmicpython.com/book/preface.html Cosmic Python] or [https://www.domainlanguage.com/ddd/ DDD Resources - Domain Language])&lt;br /&gt;
* Scenario&lt;br /&gt;
* Learning objectives&lt;br /&gt;
** e.g.&lt;br /&gt;
*** &amp;quot;Students will be able to use ls to list the contents of a directory&amp;quot;&lt;br /&gt;
*** &amp;quot;Students will be able to log in to the exercise via SSH&amp;quot;&lt;br /&gt;
** Milestone&lt;br /&gt;
*** Do these have sufficient resolution to see why/how students arrived at a correct answer?&lt;br /&gt;
** What are indicators of student progress on learning objectives?&lt;br /&gt;
*** How do events (such as those matched by milestones) correlate with questions?&lt;br /&gt;
**** What are students asking about in help requests?&lt;br /&gt;
**** Can we select semantics from log data (commands, chat, questions)?&lt;br /&gt;
** How do we know tangibly that a student is completing learning-related tasks?&lt;br /&gt;
* Questions (are exercises distinct?)&lt;br /&gt;
* Guide&lt;br /&gt;
** Guide section&lt;br /&gt;
* Student&lt;br /&gt;
* Instructor&lt;br /&gt;
* Hint&lt;br /&gt;
* Intervention&lt;br /&gt;
* Prediction&lt;br /&gt;
* Learning environment&lt;br /&gt;
** Underlying system state?&lt;br /&gt;
*** What denotes/distinguishes a command?&lt;br /&gt;
* Observability/instrumentation? (Name?)&lt;br /&gt;
&lt;br /&gt;
=== User stories: ===&lt;br /&gt;
(Supported use cases, written in terms of the ubiquitous language above)&lt;br /&gt;
* &#039;&#039;&#039;Instructors&#039;&#039;&#039; make &#039;&#039;&#039;Scenarios&#039;&#039;&#039; available to &#039;&#039;&#039;Students&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Scenarios&#039;&#039;&#039; configure the &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; to support &#039;&#039;&#039;Learning Objectives&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Scenarios&#039;&#039;&#039; may include a &#039;&#039;&#039;Guide&#039;&#039;&#039; to support exercises and tasks in the &#039;&#039;&#039;Learning Objectives&#039;&#039;&#039;&lt;br /&gt;
* The &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; can offer &#039;&#039;&#039;Interventions&#039;&#039;&#039; based on &#039;&#039;&#039;Student&#039;&#039;&#039; performance&lt;br /&gt;
* &#039;&#039;&#039;Students&#039;&#039;&#039; start and stop the &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; of &#039;&#039;&#039;Scenarios&#039;&#039;&#039; made accessible to them (the &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; does not consume resources when not in use)&lt;br /&gt;
&lt;br /&gt;
== Links to Subprojects/Related Topics ==&lt;br /&gt;
This section is temporary until we settle for a multi-topic organization for new version planning.&lt;br /&gt;
&lt;br /&gt;
* [[Data Store Project Charter]] - just the charter; pending a top-level topic page&lt;br /&gt;
* [[TTY BPF Instrument]] - mostly a placeholder topic page; links to repos/docs&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=Data_Store_Project_Charter&amp;diff=920</id>
		<title>Data Store Project Charter</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=Data_Store_Project_Charter&amp;diff=920"/>
		<updated>2026-04-27T20:30:27Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Scoping Outline: ==&lt;br /&gt;
&lt;br /&gt;
=== Challenges: ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;What:&#039;&#039;&#039; &#039;&#039;&amp;quot;Create ways to...&amp;quot; / &amp;quot;Redesign the...&amp;quot;&#039;&#039;&lt;br /&gt;
** Design a data governance process that does not require general users (often junior developers) to actively comply with and knowingly implement team-wide policy.&lt;br /&gt;
** Create a cohesive way for internal API consumers to specify their access, organization and persistence policies for application/experiment data.&lt;br /&gt;
* &#039;&#039;&#039;For Whom:&#039;&#039;&#039; &#039;&#039;&amp;quot;For &amp;lt;user&amp;gt;... (considering &amp;lt;other stakeholders&amp;gt;)...&amp;quot;&#039;&#039;&lt;br /&gt;
** For developers of our platform, considering that future contributors or peers seeking to replicate our work need forward-compatible data sufficient for re-evaluation post hoc.&lt;br /&gt;
** ...And also considering that our own contributors are often junior developers, whose responsibility is to implement working application code on their individual projects, not to bear the burdens of policy compliance.&lt;br /&gt;
* &#039;&#039;&#039;Context:&#039;&#039;&#039; &#039;&#039;&amp;quot;In a world where...&amp;quot; / &amp;quot;Keeping in mind that...&amp;quot;&#039;&#039;&lt;br /&gt;
** Keeping in mind that observations of student behavior - and generative/derived content delivered to students - are not repeatable/recoverable if unsaved, such that we need a storage method which always preserves original observations and their context/provenance.&lt;br /&gt;
** Keeping in mind that future contributors are often facing a heavy cognitive load when learning our codebase.&lt;br /&gt;
&lt;br /&gt;
=== Considerations: ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Goals:&#039;&#039;&#039; &#039;&#039;&amp;quot;We aim to...&amp;quot;&#039;&#039;&lt;br /&gt;
** Provide storage that supports arbitrary data manifestation and projection.&lt;br /&gt;
** Provide storage that can reconstruct/retrieve/replay all observations and their influence on manifestations/projections; storage is lossless.&lt;br /&gt;
** Provide storage that is inseparable; all persistent manifestations are packaged within one cohesive container, such that clients of the data store do not need to micromanage where their data is persisted - the application bundles all data related to a run and the storage module is responsible for ensuring persistent representations are available to API clients.&lt;br /&gt;
** Provide storage that supports first-class representation of experimental/software version provenance and other metadata (extensible by, but not the responsibility of, API clients).&lt;br /&gt;
** Provide a storage API which is consistent across developer/client API needs, yet supple and accommodating to diverse record types, manifestations and projection strategies.&lt;br /&gt;
* &#039;&#039;&#039;Crux:&#039;&#039;&#039; &#039;&#039;&amp;quot;We really need to...&amp;quot;&#039;&#039;&lt;br /&gt;
** Support reproducibility and total recall as a first-class feature/responsibility of the data store service.&lt;br /&gt;
** Make data provenance and append-only features invisible to API consumers whose only need is to access a projected storage interface.&lt;br /&gt;
&lt;br /&gt;
== Framing Checklist: ==&lt;br /&gt;
We should discuss these qualities:&lt;br /&gt;
&lt;br /&gt;
* You tried on some behaviors (like empathy and rapid prototyping) on a current project before launching a new one.&lt;br /&gt;
* Project is a human, subjective challenge (understanding people is key to the project success).&lt;br /&gt;
* Project is geared toward discovery (not optimization).&lt;br /&gt;
* Challenge can be solved with a product, service, or event, not a strategy or system (for your first project).&lt;br /&gt;
* Those most affected by the work are acknowledged as actors with agency, not simply receivers of outcomes.&lt;br /&gt;
* Framing doesn’t embed a solution.&lt;br /&gt;
* Framing doesn’t (unintentionally) assume the form of the solution.&lt;br /&gt;
* Framing doesn’t (unintentionally) presume people’s needs.&lt;br /&gt;
* Goal of the project work is clear without dictating the specific solution outcome.&lt;br /&gt;
* You and your team actually care about this challenge. (If not, why are you doing it?)&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=Data_Store_Project_Charter&amp;diff=919</id>
		<title>Data Store Project Charter</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=Data_Store_Project_Charter&amp;diff=919"/>
		<updated>2026-04-27T20:20:16Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Scoping Outline: ==&lt;br /&gt;
&lt;br /&gt;
=== Challenges: ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;What:&#039;&#039;&#039; &#039;&#039;&amp;quot;Create ways to...&amp;quot; / &amp;quot;Redesign the...&amp;quot;&#039;&#039;&lt;br /&gt;
** Design a data governance process that does not require general users (often junior developers) to actively comply with and knowingly implement team-wide policy.&lt;br /&gt;
** Create a cohesive way for internal API consumers to specify their access, organization and persistence policies for application/experiment data.&lt;br /&gt;
* &#039;&#039;&#039;For Whom:&#039;&#039;&#039; &#039;&#039;&amp;quot;For &amp;lt;user&amp;gt;... (considering &amp;lt;other stakeholders&amp;gt;)...&amp;quot;&#039;&#039;&lt;br /&gt;
** For developers of our platform, considering that future contributors or peers seeking to replicate our work need forward-compatible data sufficient for re-evaluation post hoc.&lt;br /&gt;
** ...And also considering that our own contributors are often junior developers, whose responsibility is to implement working application code on their individual projects, not to bear the burdens of policy compliance.&lt;br /&gt;
* &#039;&#039;&#039;Context:&#039;&#039;&#039; &#039;&#039;&amp;quot;In a world where...&amp;quot; / &amp;quot;Keeping in mind that...&amp;quot;&#039;&#039;&lt;br /&gt;
** Keeping in mind that observations of student behavior - and generative/derived content delivered to students - are not repeatable/recoverable if unsaved, such that we need a storage method which always preserves original observations and their context/provenance.&lt;br /&gt;
** Keeping in mind that future contributors are often facing a heavy cognitive load when learning our codebase.&lt;br /&gt;
&lt;br /&gt;
=== Considerations: ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Goals:&#039;&#039;&#039; &#039;&#039;&amp;quot;We aim to...&amp;quot;&#039;&#039;&lt;br /&gt;
** Provide storage that supports arbitrary data manifestation and projection.&lt;br /&gt;
** Provide storage that can reconstruct/retrieve/replay all observations and their influence on manifestations/projections; storage is lossless.&lt;br /&gt;
** Provide storage that is inseparable; all persistent manifestations are packaged within one cohesive container, such that clients of the data store do not need to micromanage where their data is persisted - the application bundles all data related to a run and the storage module is responsible for ensuring persistent representations are available to API clients.&lt;br /&gt;
** Provide storage that supports first-class representation of experimental/software version provenance and other metadata (extensible by, but not the responsibility of, API clients).&lt;br /&gt;
** Provide a storage API which is consistent across developer/client API needs, yet supple and accommodating to diverse record types, manifestations and projection strategies.&lt;br /&gt;
* &#039;&#039;&#039;Crux:&#039;&#039;&#039; &#039;&#039;&amp;quot;We really need to...&amp;quot;&#039;&#039;&lt;br /&gt;
** Support reproducibility and total recall as a first-class feature/responsibility of the data store service.&lt;br /&gt;
** Make data provenance and append-only features invisible to API consumers whose only need access to a projected storage interface.&lt;br /&gt;
&lt;br /&gt;
== Framing Checklist: ==&lt;br /&gt;
We should discuss these qualities:&lt;br /&gt;
&lt;br /&gt;
* You tried on some behaviors (like empathy and rapid prototyping) on a current project before launching a new one.&lt;br /&gt;
* Project is a human, subjective challenge (understanding people is key to the project success).&lt;br /&gt;
* Project is geared toward discovery (not optimization).&lt;br /&gt;
* Challenge can be solved with a product, service, or event, not a strategy or system (for your first project).&lt;br /&gt;
* Those most affected by the work are acknowledged as actors with agency, not simply receivers of outcomes.&lt;br /&gt;
* Framing doesn’t embed a solution.&lt;br /&gt;
* Framing doesn’t (unintentionally) assume the form of the solution.&lt;br /&gt;
* Framing doesn’t (unintentionally) presume people’s needs.&lt;br /&gt;
* Goal of the project work is clear without dictating the specific solution outcome.&lt;br /&gt;
* You and your team actually care about this challenge. (If not, why are you doing it?)&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=Data_Store_Project_Charter&amp;diff=918</id>
		<title>Data Store Project Charter</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=Data_Store_Project_Charter&amp;diff=918"/>
		<updated>2026-04-27T18:51:34Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: Created page with &amp;quot;== Scoping Outline: ==  === Challenges: ===  * &amp;#039;&amp;#039;&amp;#039;What:&amp;#039;&amp;#039;&amp;#039; &amp;#039;&amp;#039;&amp;quot;Create ways to...&amp;quot; / &amp;quot;Redesign the...&amp;quot;&amp;#039;&amp;#039; ** Design a data governance process that does not require general users (often junior developers) to actively comply with and knowingly implement team-wide policy. ** Create a cohesive way for internal API consumers to specify their access, organization and persistence policies for application/experiment data. * &amp;#039;&amp;#039;&amp;#039;For Whom:&amp;#039;&amp;#039;&amp;#039; &amp;#039;&amp;#039;&amp;quot;For &amp;lt;user&amp;gt;... (considering &amp;lt;other stake...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Scoping Outline: ==&lt;br /&gt;
&lt;br /&gt;
=== Challenges: ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;What:&#039;&#039;&#039; &#039;&#039;&amp;quot;Create ways to...&amp;quot; / &amp;quot;Redesign the...&amp;quot;&#039;&#039;&lt;br /&gt;
** Design a data governance process that does not require general users (often junior developers) to actively comply with and knowingly implement team-wide policy.&lt;br /&gt;
** Create a cohesive way for internal API consumers to specify their access, organization and persistence policies for application/experiment data.&lt;br /&gt;
* &#039;&#039;&#039;For Whom:&#039;&#039;&#039; &#039;&#039;&amp;quot;For &amp;lt;user&amp;gt;... (considering &amp;lt;other stakeholders&amp;gt;)...&amp;quot;&#039;&#039;&lt;br /&gt;
** For developers of our platform, considering that future contributors or peers seeking to replicate our work need forward-compatible data sufficient for re-evaluation post hoc.&lt;br /&gt;
** ...And also considering that our own contributors are often junior developers, whose responsibility is to implement working application code on their individual projects, not to bear the burdens of policy compliance.&lt;br /&gt;
* &#039;&#039;&#039;Context:&#039;&#039;&#039; &#039;&#039;&amp;quot;In a world where...&amp;quot; / &amp;quot;Keeping in mind that...&amp;quot;&#039;&#039;&lt;br /&gt;
** Keeping in mind that observations of student behavior - and generative/derived content delivered to students - are not repeatable/recoverable if unsaved, such that we need a storage method which always preserves original observations and their context/provenance.&lt;br /&gt;
** Keeping in mind that future contributors are often facing a heavy cognitive load when learning our codebase.&lt;br /&gt;
&lt;br /&gt;
=== Considerations: ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Goals:&#039;&#039;&#039; &#039;&#039;&amp;quot;We aim to...&amp;quot;&#039;&#039;&lt;br /&gt;
** Provide storage that supports arbitrary data manifestation and projection.&lt;br /&gt;
** Provide storage that can reconstruct/retrieve/replay all observations and their influence on manifestations/projections; storage is lossless.&lt;br /&gt;
** Provide storage that is inseparable; all persistent manifestations are packaged within one cohesive container, such that clients of the data store do not need to micromanage where their data is persisted - the application bundles all data related to a run and the storage module is responsible for ensuring persistent representations are available to API clients.&lt;br /&gt;
** Provide storage that supports first-class representation of experimental/software version provenance and other metadata (extensibly by, but not the responsibility of, API clients).&lt;br /&gt;
** Provide a storage API which is consistent across developer/client API needs, yet supple and accommodating to diverse record types, manifestations and projection strategies.&lt;br /&gt;
* &#039;&#039;&#039;Crux:&#039;&#039;&#039; &#039;&#039;&amp;quot;We really need to...&amp;quot;&#039;&#039;&lt;br /&gt;
** Support reproducibility and total recall as a first-class feature/responsibility of the data store service.&lt;br /&gt;
** Make data provenance and append-only features invisible to API consumers whose only need access to a projected storage interface.&lt;br /&gt;
&lt;br /&gt;
== Framing Checklist: ==&lt;br /&gt;
We should discuss these qualities:&lt;br /&gt;
&lt;br /&gt;
* You tried on some behaviors (like empathy and rapid prototyping) on a current project before launching a new one.&lt;br /&gt;
* Project is a human, subjective challenge (understanding people is key to the project success).&lt;br /&gt;
* Project is geared toward discovery (not optimization).&lt;br /&gt;
* Challenge can be solved with a product, service, or event, not a strategy or system (for your first project).&lt;br /&gt;
* Those most affected by the work are acknowledged as actors with agency, not simply receivers of outcomes.&lt;br /&gt;
* Framing doesn’t embed a solution.&lt;br /&gt;
* Framing doesn’t (unintentionally) assume the form of the solution.&lt;br /&gt;
* Framing doesn’t (unintentionally) presume people’s needs.&lt;br /&gt;
* Goal of the project work is clear without dictating the specific solution outcome.&lt;br /&gt;
* You and your team actually care about this challenge. (If not, why are you doing it?)&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=Team_Charter&amp;diff=917</id>
		<title>Team Charter</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=Team_Charter&amp;diff=917"/>
		<updated>2026-04-27T17:00:38Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Scoping Outline: ==&lt;br /&gt;
&lt;br /&gt;
=== Challenges: ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;What:&#039;&#039;&#039; &#039;&#039;&amp;quot;Create ways to...&amp;quot; / &amp;quot;Redesign the...&amp;quot;&#039;&#039;&lt;br /&gt;
** Provide a product that supports creating and delivering custom computer lab exercises.&lt;br /&gt;
* &#039;&#039;&#039;For Whom:&#039;&#039;&#039; &#039;&#039;&amp;quot;For &amp;lt;user&amp;gt;... (considering &amp;lt;other stakeholders&amp;gt;)...&amp;quot;&#039;&#039;&lt;br /&gt;
** Anyone who wants to learn cybersecurity and other computer science topics, especially instructors administering labs for class.&lt;br /&gt;
* &#039;&#039;&#039;Context:&#039;&#039;&#039; &#039;&#039;&amp;quot;In a world where...&amp;quot; / &amp;quot;Keeping in mind that...&amp;quot;&#039;&#039;&lt;br /&gt;
** It is difficult to set up a computer lab, it can be tedious and repetitive.&lt;br /&gt;
&lt;br /&gt;
=== Considerations: ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Goals:&#039;&#039;&#039; &#039;&#039;&amp;quot;We aim to...&amp;quot;&#039;&#039;&lt;br /&gt;
** Automate it [What is it? Orchestration? Authoring? Administration? Managing student interaction?].&lt;br /&gt;
** Improve student outcomes [In or outside our own system? Do we care?].&lt;br /&gt;
** Provide building blocks for others to create content.&lt;br /&gt;
* &#039;&#039;&#039;Crux:&#039;&#039;&#039; &#039;&#039;&amp;quot;We really need to...&amp;quot;&#039;&#039;&lt;br /&gt;
** Understand how students learn.&lt;br /&gt;
&lt;br /&gt;
== Framing Checklist: ==&lt;br /&gt;
We should discuss these qualities:&lt;br /&gt;
&lt;br /&gt;
* You tried on some behaviors (like empathy and rapid prototyping) on a current project before launching a new one.&lt;br /&gt;
* Project is a human, subjective challenge (understanding people is key to the project success).&lt;br /&gt;
* Project is geared toward discovery (not optimization).&lt;br /&gt;
* Challenge can be solved with a product, service, or event, not a strategy or system (for your first project).&lt;br /&gt;
** &#039;&#039;Not our first project, and we can practice concrete solutions in more narrowly scoped projects to begin immediately.&#039;&#039;&lt;br /&gt;
* Those most affected by the work are acknowledged as actors with agency, not simply receivers of outcomes.&lt;br /&gt;
* Framing doesn’t embed a solution.&lt;br /&gt;
* Framing doesn’t (unintentionally) assume the form of the solution.&lt;br /&gt;
* Framing doesn’t (unintentionally) presume people’s needs.&lt;br /&gt;
* Goal of the project work is clear without dictating the specific solution outcome.&lt;br /&gt;
* You and your team actually care about this challenge. (If not, why are you doing it?)&lt;br /&gt;
&lt;br /&gt;
== Note: Teams/Products Are Long-Lived ==&lt;br /&gt;
Many of the links below, and the template used for this charter, are for project scoping. Projects have limited, definite lifecycles - the project ends when the goal is accomplished, and the result is handed off to an operational team to run it.&lt;br /&gt;
&lt;br /&gt;
This top-level team charter has an indefinite lifespan. It&#039;s meant to be revisited and revised (though it can also be replaced). The team charter helps us have a shared understanding of not just what we&#039;re making, but what we&#039;re maintaining and providing to our &amp;quot;customer&amp;quot; (even if we aren&#039;t necessarily selling our product/service).&lt;br /&gt;
&lt;br /&gt;
== External Resources ==&lt;br /&gt;
See https://dschool.sfo3.digitaloceanspaces.com/documents/Design-Project-Scoping-Guide_V7.pdf!&lt;br /&gt;
&lt;br /&gt;
Also:&lt;br /&gt;
&lt;br /&gt;
* https://www.pmi.org/learning/library/team-charter-development-5128&lt;br /&gt;
* https://www.atlassian.com/work-management/project-collaboration/team-charter&lt;br /&gt;
* https://dschool.stanford.edu/tools/design-project-scoping-guide&lt;br /&gt;
* https://ocw.mit.edu/courses/1-040-project-management-spring-2009/&lt;br /&gt;
* https://www.nasa.gov/reference/4-1-stakeholder-expectations-definition/&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=Team_Charter&amp;diff=914</id>
		<title>Team Charter</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=Team_Charter&amp;diff=914"/>
		<updated>2026-04-03T21:21:40Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;change me!&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
See https://www.pmi.org/learning/library/team-charter-development-5128 !&lt;br /&gt;
&lt;br /&gt;
Also:&lt;br /&gt;
&lt;br /&gt;
* https://www.atlassian.com/work-management/project-collaboration/team-charter&lt;br /&gt;
* https://dschool.stanford.edu/tools/design-project-scoping-guide&lt;br /&gt;
* https://ocw.mit.edu/courses/1-040-project-management-spring-2009/&lt;br /&gt;
* https://www.nasa.gov/reference/4-1-stakeholder-expectations-definition/&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=Main_Page&amp;diff=913</id>
		<title>Main Page</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=Main_Page&amp;diff=913"/>
		<updated>2026-04-03T20:42:34Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* The EDURange Project */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Welcome to the EDURange Wiki. This is less of a formal documentation as it is a living document.&lt;br /&gt;
&lt;br /&gt;
I think that if it&#039;s a choice between losing track of people&#039;s contributions and having a messy wiki, I&#039;d much prefer a messy wiki.  It&#039;s my hope that we can gather all of our notes, thoughts, and loosely related ephemera here, rather than having it scattered across correspondence and various sharing platforms.&lt;br /&gt;
&lt;br /&gt;
We don&#039;t have many conventions yet. Please follow the Golden Rule and treat others as you would be treated. Put your name on your changes. Consider posting to the discussion tab or appending to a page rather than overwriting it. Try not to overwrite the personal work of others.&lt;br /&gt;
&lt;br /&gt;
If it&#039;s installable/runnable/code of any sort, put it on GitHub as well - preferably under the edurange organization so that our future contributors will have seamless access to it too.&lt;br /&gt;
&lt;br /&gt;
Thanks for your time.&lt;br /&gt;
&lt;br /&gt;
~Joe G&lt;br /&gt;
&lt;br /&gt;
== The EDURange Project ==&lt;br /&gt;
[[Team Charter]] - What&#039;s EDURange? What do we do on the EDURange team?&lt;br /&gt;
&lt;br /&gt;
[[:Category:Project History and Roadmaps|Project History and Roadmaps]]&lt;br /&gt;
&lt;br /&gt;
== Developer Links ==&lt;br /&gt;
!!! [[New Version Planning]] !!!&lt;br /&gt;
&lt;br /&gt;
[[Style Guidelines and Developer Tools]]&lt;br /&gt;
&lt;br /&gt;
[[Reference Materials]]&lt;br /&gt;
&lt;br /&gt;
[[:Category:Demonstrations|Demonstrations]]&lt;br /&gt;
&lt;br /&gt;
[[Design Stories (OLD)]]&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=Team_Charter&amp;diff=912</id>
		<title>Team Charter</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=Team_Charter&amp;diff=912"/>
		<updated>2026-03-31T19:24:54Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;change me!&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
See https://www.pmi.org/learning/library/team-charter-development-5128 !&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=Team_Charter&amp;diff=911</id>
		<title>Team Charter</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=Team_Charter&amp;diff=911"/>
		<updated>2026-03-31T19:23:51Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: Created page with &amp;quot;change me!&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;change me!&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=910</id>
		<title>New Version Planning</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=910"/>
		<updated>2026-03-28T16:54:48Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* Functional requirements: */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As we&#039;re still getting started, please feel free to insert notes and comments where you like - just try to avoid changing or deleting things you yourself didn&#039;t write. We&#039;ll come up with a shared structure as we go. Also consider using the &amp;quot;discussion&amp;quot; tab at the top if you aren&#039;t sure where to comment or want to have a dialogue.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
These items at the top are on the agenda for discussion, but don&#039;t have a clear place they belong yet:&lt;br /&gt;
* POST handler/API reachability&lt;br /&gt;
* Chat capability&lt;br /&gt;
* Containers&lt;br /&gt;
* NSJail, chroot?&lt;br /&gt;
* Physical systems: controlled via ordinary SSH, provisioned with PXE or other net boot methods?&lt;br /&gt;
* How to model shared resources and contention?&lt;br /&gt;
* Staged/prerequisite content in guide/scenario&lt;br /&gt;
* How to refer to observation/logging instruments&lt;br /&gt;
* How do we assess correctness/domain truth in a model of learning objectives?&lt;br /&gt;
** Navigating a filesystem:&lt;br /&gt;
*** &amp;quot;Can a student navigate the filesystem?&amp;quot;&lt;br /&gt;
**** &amp;quot;Can the student compose an absolute path?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student use / to begin an absolute path?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student refer to directories present at the root of the filesystem?&amp;quot;&lt;br /&gt;
**** &amp;quot;Do they correctly use find?&amp;quot;&lt;br /&gt;
**** &amp;quot;Does the student map correctly between paths and the purposes of the file hierarchy?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student look for binaries in /bin/?&amp;quot;&lt;br /&gt;
**** &amp;quot;Does the student use ~ to refer to their home directory?&amp;quot;&lt;br /&gt;
*** What artifacts help to assess this?&lt;br /&gt;
*Sample learning objectives:&lt;br /&gt;
**Hierarchically: course/domain; module/unit; learning objective; concept/skill cluster; knowledge components; tasks&lt;br /&gt;
**Scenario (course): Getting Started&lt;br /&gt;
***A learning objective: Students will be able to navigate the filesystem&lt;br /&gt;
****Compose paths&lt;br /&gt;
****Navigate directories&lt;br /&gt;
****Aware of working directory&lt;br /&gt;
****Understand path substitutions (~, ., etc.)&lt;br /&gt;
****etc...&lt;br /&gt;
***A more specific learning objective (or, alternately, a skill cluster): Student will be able to recognize (name) and locate a hidden file in a known directory&lt;br /&gt;
****Give filename of the file in question&lt;br /&gt;
****Identify the path of the file in question&lt;br /&gt;
****Source and provide information about the file, content in the file&lt;br /&gt;
****Differentiate between a file that is and isn&#039;t hidden&lt;br /&gt;
***Task (independent, but linked to learning objectives and knowledge components):&lt;br /&gt;
****Find all the files that end in &amp;quot;.gif&amp;quot; using the find command&lt;br /&gt;
***Knowledge component (independent, linked to tasks):&lt;br /&gt;
****Student uses &#039;find&amp;quot; when searching files&lt;br /&gt;
****Student uses &#039;ls&#039; to list and examine available files&lt;br /&gt;
****Student appropriately applies path prefix (absolute or relative)&lt;br /&gt;
****Student composes valid pattern for file extension&lt;br /&gt;
*Quality of life:&lt;br /&gt;
**Authentication/account naming is a system-wide standard/service&lt;br /&gt;
***How does this behave in designs where students are meant to seize other identities?&lt;br /&gt;
&lt;br /&gt;
=== Functional requirements: ===&lt;br /&gt;
&lt;br /&gt;
* 20-300 students simultaneously? Semi-simultaneously? ~150 concurrently, 500+ at large institutions?&lt;br /&gt;
* Telemetry layer for instrument/orchestration health&lt;br /&gt;
* Students are provided a learning environment that is safe to explore/attack without threatening the application host infrastructure&lt;br /&gt;
* Two weeks unattended uptime&lt;br /&gt;
* Per-user resource caps configurable per host (if not per-scenario or finer-grained)&lt;br /&gt;
&lt;br /&gt;
=== Ubiquitous language: ===&lt;br /&gt;
(See [https://agilealliance.org/glossary/ubiquitous-language/ What is Ubiquitous Language? | Agile Alliance], [https://www.cosmicpython.com/book/preface.html Cosmic Python] or [https://www.domainlanguage.com/ddd/ DDD Resources - Domain Language])&lt;br /&gt;
* Scenario&lt;br /&gt;
* Learning objectives&lt;br /&gt;
** e.g.&lt;br /&gt;
*** &amp;quot;Students will be able to use ls to list the contents of a directory&amp;quot;&lt;br /&gt;
*** &amp;quot;Students will be able to log in to the exercise via SSH&amp;quot;&lt;br /&gt;
** Milestone&lt;br /&gt;
*** Do these have sufficient resolution to see why/how students arrived at a correct answer?&lt;br /&gt;
** What are indicators of student progress on learning objectives?&lt;br /&gt;
*** How do events (such as those matched by milestones) correlate with questions?&lt;br /&gt;
**** What are students asking about in help requests?&lt;br /&gt;
**** Can we select semantics from log data (commands, chat, questions)?&lt;br /&gt;
** How do we know tangibly that a student is completing learning-related tasks?&lt;br /&gt;
* Questions (are exercises distinct?)&lt;br /&gt;
* Guide&lt;br /&gt;
** Guide section&lt;br /&gt;
* Student&lt;br /&gt;
* Instructor&lt;br /&gt;
* Hint&lt;br /&gt;
* Intervention&lt;br /&gt;
* Prediction&lt;br /&gt;
* Learning environment&lt;br /&gt;
** Underlying system state?&lt;br /&gt;
*** What denotes/distinguishes a command?&lt;br /&gt;
* Observability/instrumentation? (Name?)&lt;br /&gt;
&lt;br /&gt;
=== User stories: ===&lt;br /&gt;
(Supported use cases, written in terms of the ubiquitous language above)&lt;br /&gt;
* &#039;&#039;&#039;Instructors&#039;&#039;&#039; make &#039;&#039;&#039;Scenarios&#039;&#039;&#039; available to &#039;&#039;&#039;Students&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Scenarios&#039;&#039;&#039; configure the &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; to support &#039;&#039;&#039;Learning Objectives&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Scenarios&#039;&#039;&#039; may include a &#039;&#039;&#039;Guide&#039;&#039;&#039; to support exercises and tasks in the &#039;&#039;&#039;Learning Objectives&#039;&#039;&#039;&lt;br /&gt;
* The &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; can offer &#039;&#039;&#039;Interventions&#039;&#039;&#039; based on &#039;&#039;&#039;Student&#039;&#039;&#039; performance&lt;br /&gt;
* &#039;&#039;&#039;Students&#039;&#039;&#039; start and stop the &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; of &#039;&#039;&#039;Scenarios&#039;&#039;&#039; made accessible to them (the &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; does not consume resources when not in use)&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=909</id>
		<title>New Version Planning</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=909"/>
		<updated>2026-03-28T16:52:24Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* User stories: */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As we&#039;re still getting started, please feel free to insert notes and comments where you like - just try to avoid changing or deleting things you yourself didn&#039;t write. We&#039;ll come up with a shared structure as we go. Also consider using the &amp;quot;discussion&amp;quot; tab at the top if you aren&#039;t sure where to comment or want to have a dialogue.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
These items at the top are on the agenda for discussion, but don&#039;t have a clear place they belong yet:&lt;br /&gt;
* POST handler/API reachability&lt;br /&gt;
* Chat capability&lt;br /&gt;
* Containers&lt;br /&gt;
* NSJail, chroot?&lt;br /&gt;
* Physical systems: controlled via ordinary SSH, provisioned with PXE or other net boot methods?&lt;br /&gt;
* How to model shared resources and contention?&lt;br /&gt;
* Staged/prerequisite content in guide/scenario&lt;br /&gt;
* How to refer to observation/logging instruments&lt;br /&gt;
* How do we assess correctness/domain truth in a model of learning objectives?&lt;br /&gt;
** Navigating a filesystem:&lt;br /&gt;
*** &amp;quot;Can a student navigate the filesystem?&amp;quot;&lt;br /&gt;
**** &amp;quot;Can the student compose an absolute path?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student use / to begin an absolute path?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student refer to directories present at the root of the filesystem?&amp;quot;&lt;br /&gt;
**** &amp;quot;Do they correctly use find?&amp;quot;&lt;br /&gt;
**** &amp;quot;Does the student map correctly between paths and the purposes of the file hierarchy?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student look for binaries in /bin/?&amp;quot;&lt;br /&gt;
**** &amp;quot;Does the student use ~ to refer to their home directory?&amp;quot;&lt;br /&gt;
*** What artifacts help to assess this?&lt;br /&gt;
*Sample learning objectives:&lt;br /&gt;
**Hierarchically: course/domain; module/unit; learning objective; concept/skill cluster; knowledge components; tasks&lt;br /&gt;
**Scenario (course): Getting Started&lt;br /&gt;
***A learning objective: Students will be able to navigate the filesystem&lt;br /&gt;
****Compose paths&lt;br /&gt;
****Navigate directories&lt;br /&gt;
****Aware of working directory&lt;br /&gt;
****Understand path substitutions (~, ., etc.)&lt;br /&gt;
****etc...&lt;br /&gt;
***A more specific learning objective (or, alternately, a skill cluster): Student will be able to recognize (name) and locate a hidden file in a known directory&lt;br /&gt;
****Give filename of the file in question&lt;br /&gt;
****Identify the path of the file in question&lt;br /&gt;
****Source and provide information about the file, content in the file&lt;br /&gt;
****Differentiate between a file that is and isn&#039;t hidden&lt;br /&gt;
***Task (independent, but linked to learning objectives and knowledge components):&lt;br /&gt;
****Find all the files that end in &amp;quot;.gif&amp;quot; using the find command&lt;br /&gt;
***Knowledge component (independent, linked to tasks):&lt;br /&gt;
****Student uses &#039;find&amp;quot; when searching files&lt;br /&gt;
****Student uses &#039;ls&#039; to list and examine available files&lt;br /&gt;
****Student appropriately applies path prefix (absolute or relative)&lt;br /&gt;
****Student composes valid pattern for file extension&lt;br /&gt;
*Quality of life:&lt;br /&gt;
**Authentication/account naming is a system-wide standard/service&lt;br /&gt;
***How does this behave in designs where students are meant to seize other identities?&lt;br /&gt;
&lt;br /&gt;
=== Functional requirements: ===&lt;br /&gt;
&lt;br /&gt;
* 20-300 students simultaneously? Semi-simultaneously? ~150 concurrently, 500+ at large institutions?&lt;br /&gt;
* Telemetry layer for instrument/orchestration health&lt;br /&gt;
* Students are provided a learning environment that is safe to explore/attack without threatening the application host infrastructure&lt;br /&gt;
* Two weeks unattended uptime&lt;br /&gt;
* Per-user resource caps configurable per host (if not scenario/finer-grained)&lt;br /&gt;
&lt;br /&gt;
=== Ubiquitous language: ===&lt;br /&gt;
(See [https://agilealliance.org/glossary/ubiquitous-language/ What is Ubiquitous Language? | Agile Alliance], [https://www.cosmicpython.com/book/preface.html Cosmic Python] or [https://www.domainlanguage.com/ddd/ DDD Resources - Domain Language])&lt;br /&gt;
* Scenario&lt;br /&gt;
* Learning objectives&lt;br /&gt;
** e.g.&lt;br /&gt;
*** &amp;quot;Students will be able to use ls to list the contents of a directory&amp;quot;&lt;br /&gt;
*** &amp;quot;Students will be able to log in to the exercise via SSH&amp;quot;&lt;br /&gt;
** Milestone&lt;br /&gt;
*** Do these have sufficient resolution to see why/how students arrived at a correct answer?&lt;br /&gt;
** What are indicators of student progress on learning objectives?&lt;br /&gt;
*** How do events (such as those matched by milestones) correlate with questions?&lt;br /&gt;
**** What are students asking about in help requests?&lt;br /&gt;
**** Can we select semantics from log data (commands, chat, questions)?&lt;br /&gt;
** How do we know tangibly that a student is completing learning-related tasks?&lt;br /&gt;
* Questions (are exercises distinct?)&lt;br /&gt;
* Guide&lt;br /&gt;
** Guide section&lt;br /&gt;
* Student&lt;br /&gt;
* Instructor&lt;br /&gt;
* Hint&lt;br /&gt;
* Intervention&lt;br /&gt;
* Prediction&lt;br /&gt;
* Learning environment&lt;br /&gt;
** Underlying system state?&lt;br /&gt;
*** What denotes/distinguishes a command?&lt;br /&gt;
* Observability/instrumentation? (Name?)&lt;br /&gt;
&lt;br /&gt;
=== User stories: ===&lt;br /&gt;
(Supported use cases, written in terms of the ubiquitous language above)&lt;br /&gt;
* &#039;&#039;&#039;Instructors&#039;&#039;&#039; make &#039;&#039;&#039;Scenarios&#039;&#039;&#039; available to &#039;&#039;&#039;Students&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Scenarios&#039;&#039;&#039; configure the &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; to support &#039;&#039;&#039;Learning Objectives&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Scenarios&#039;&#039;&#039; may include a &#039;&#039;&#039;Guide&#039;&#039;&#039; to support exercises and tasks in the &#039;&#039;&#039;Learning Objectives&#039;&#039;&#039;&lt;br /&gt;
* The &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; can offer &#039;&#039;&#039;Interventions&#039;&#039;&#039; based on &#039;&#039;&#039;Student&#039;&#039;&#039; performance&lt;br /&gt;
* &#039;&#039;&#039;Students&#039;&#039;&#039; start and stop the &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; of &#039;&#039;&#039;Scenarios&#039;&#039;&#039; made accessible to them (the &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; does not consume resources when not in use)&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=908</id>
		<title>New Version Planning</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=908"/>
		<updated>2026-03-28T16:46:22Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* User stores: */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As we&#039;re still getting started, please feel free to insert notes and comments where you like - just try to avoid changing or deleting things you yourself didn&#039;t write. We&#039;ll come up with a shared structure as we go. Also consider using the &amp;quot;discussion&amp;quot; tab at the top if you aren&#039;t sure where to comment or want to have a dialogue.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
These items at the top are on the agenda for discussion, but don&#039;t have a clear place they belong yet:&lt;br /&gt;
* POST handler/API reachability&lt;br /&gt;
* Chat capability&lt;br /&gt;
* Containers&lt;br /&gt;
* NSJail, chroot?&lt;br /&gt;
* Physical systems: controlled via ordinary SSH, provisioned with PXE or other net boot methods?&lt;br /&gt;
* How to model shared resources and contention?&lt;br /&gt;
* Staged/prerequisite content in guide/scenario&lt;br /&gt;
* How to refer to observation/logging instruments&lt;br /&gt;
* How do we assess correctness/domain truth in a model of learning objectives?&lt;br /&gt;
** Navigating a filesystem:&lt;br /&gt;
*** &amp;quot;Can a student navigate the filesystem?&amp;quot;&lt;br /&gt;
**** &amp;quot;Can the student compose an absolute path?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student use / to begin an absolute path?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student refer to directories present at the root of the filesystem?&amp;quot;&lt;br /&gt;
**** &amp;quot;Do they correctly use find?&amp;quot;&lt;br /&gt;
**** &amp;quot;Does the student map correctly between paths and the purposes of the file hierarchy?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student look for binaries in /bin/?&amp;quot;&lt;br /&gt;
**** &amp;quot;Does the student use ~ to refer to their home directory?&amp;quot;&lt;br /&gt;
*** What artifacts help to assess this?&lt;br /&gt;
*Sample learning objectives:&lt;br /&gt;
**Hierarchically: course/domain; module/unit; learning objective; concept/skill cluster; knowledge components; tasks&lt;br /&gt;
**Scenario (course): Getting Started&lt;br /&gt;
***A learning objective: Students will be able to navigate the filesystem&lt;br /&gt;
****Compose paths&lt;br /&gt;
****Navigate directories&lt;br /&gt;
****Aware of working directory&lt;br /&gt;
****Understand path substitutions (~, ., etc.)&lt;br /&gt;
****etc...&lt;br /&gt;
***A more specific learning objective (or, alternately, a skill cluster): Student will be able to recognize (name) and locate a hidden file in a known directory&lt;br /&gt;
****Give filename of the file in question&lt;br /&gt;
****Identify the path of the file in question&lt;br /&gt;
****Source and provide information about the file, content in the file&lt;br /&gt;
****Differentiate between a file that is and isn&#039;t hidden&lt;br /&gt;
***Task (independent, but linked to learning objectives and knowledge components):&lt;br /&gt;
****Find all the files that end in &amp;quot;.gif&amp;quot; using the find command&lt;br /&gt;
***Knowledge component (independent, linked to tasks):&lt;br /&gt;
****Student uses &#039;find&amp;quot; when searching files&lt;br /&gt;
****Student uses &#039;ls&#039; to list and examine available files&lt;br /&gt;
****Student appropriately applies path prefix (absolute or relative)&lt;br /&gt;
****Student composes valid pattern for file extension&lt;br /&gt;
*Quality of life:&lt;br /&gt;
**Authentication/account naming is a system-wide standard/service&lt;br /&gt;
***How does this behave in designs where students are meant to seize other identities?&lt;br /&gt;
&lt;br /&gt;
=== Functional requirements: ===&lt;br /&gt;
&lt;br /&gt;
* 20-300 students simultaneously? Semi-simultaneously? ~150 concurrently, 500+ at large institutions?&lt;br /&gt;
* Telemetry layer for instrument/orchestration health&lt;br /&gt;
* Students are provided a learning environment that is safe to explore/attack without threatening the application host infrastructure&lt;br /&gt;
* Two weeks unattended uptime&lt;br /&gt;
* Per-user resource caps configurable per host (if not scenario/finer-grained)&lt;br /&gt;
&lt;br /&gt;
=== Ubiquitous language: ===&lt;br /&gt;
(See [https://agilealliance.org/glossary/ubiquitous-language/ What is Ubiquitous Language? | Agile Alliance], [https://www.cosmicpython.com/book/preface.html Cosmic Python] or [https://www.domainlanguage.com/ddd/ DDD Resources - Domain Language])&lt;br /&gt;
* Scenario&lt;br /&gt;
* Learning objectives&lt;br /&gt;
** e.g.&lt;br /&gt;
*** &amp;quot;Students will be able to use ls to list the contents of a directory&amp;quot;&lt;br /&gt;
*** &amp;quot;Students will be able to log in to the exercise via SSH&amp;quot;&lt;br /&gt;
** Milestone&lt;br /&gt;
*** Do these have sufficient resolution to see why/how students arrived at a correct answer?&lt;br /&gt;
** What are indicators of student progress on learning objectives?&lt;br /&gt;
*** How do events (such as those matched by milestones) correlate with questions?&lt;br /&gt;
**** What are students asking about in help requests?&lt;br /&gt;
**** Can we select semantics from log data (commands, chat, questions)?&lt;br /&gt;
** How do we know tangibly that a student is completing learning-related tasks?&lt;br /&gt;
* Questions (are exercises distinct?)&lt;br /&gt;
* Guide&lt;br /&gt;
** Guide section&lt;br /&gt;
* Student&lt;br /&gt;
* Instructor&lt;br /&gt;
* Hint&lt;br /&gt;
* Intervention&lt;br /&gt;
* Prediction&lt;br /&gt;
* Learning environment&lt;br /&gt;
** Underlying system state?&lt;br /&gt;
*** What denotes/distinguishes a command?&lt;br /&gt;
* Observability/instrumentation? (Name?)&lt;br /&gt;
&lt;br /&gt;
=== User stories: ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Instructors&#039;&#039;&#039; make &#039;&#039;&#039;Scenarios&#039;&#039;&#039; available to &#039;&#039;&#039;Students&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Scenarios&#039;&#039;&#039; configure the &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; to support &#039;&#039;&#039;Learning Objectives&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Scenarios&#039;&#039;&#039; may include a &#039;&#039;&#039;Guide&#039;&#039;&#039; to support exercises and tasks in the &#039;&#039;&#039;Learning Objectives&#039;&#039;&#039;&lt;br /&gt;
* The &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; can offer &#039;&#039;&#039;Interventions&#039;&#039;&#039; based on &#039;&#039;&#039;Student&#039;&#039;&#039; performance&lt;br /&gt;
* &#039;&#039;&#039;Students&#039;&#039;&#039; start and stop the &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; of &#039;&#039;&#039;Scenarios&#039;&#039;&#039; made accessible to them (the &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; does not consume resources when not in use)&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=907</id>
		<title>New Version Planning</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=907"/>
		<updated>2026-03-27T22:01:33Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As we&#039;re still getting started, please feel free to insert notes and comments where you like - just try to avoid changing or deleting things you yourself didn&#039;t write. We&#039;ll come up with a shared structure as we go. Also consider using the &amp;quot;discussion&amp;quot; tab at the top if you aren&#039;t sure where to comment or want to have a dialogue.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
These items at the top are on the agenda for discussion, but don&#039;t have a clear place they belong yet:&lt;br /&gt;
* POST handler/API reachability&lt;br /&gt;
* Chat capability&lt;br /&gt;
* Containers&lt;br /&gt;
* NSJail, chroot?&lt;br /&gt;
* Physical systems: controlled via ordinary SSH, provisioned with PXE or other net boot methods?&lt;br /&gt;
* How to model shared resources and contention?&lt;br /&gt;
* Staged/prerequisite content in guide/scenario&lt;br /&gt;
* How to refer to observation/logging instruments&lt;br /&gt;
* How do we assess correctness/domain truth in a model of learning objectives?&lt;br /&gt;
** Navigating a filesystem:&lt;br /&gt;
*** &amp;quot;Can a student navigate the filesystem?&amp;quot;&lt;br /&gt;
**** &amp;quot;Can the student compose an absolute path?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student use / to begin an absolute path?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student refer to directories present at the root of the filesystem?&amp;quot;&lt;br /&gt;
**** &amp;quot;Do they correctly use find?&amp;quot;&lt;br /&gt;
**** &amp;quot;Does the student map correctly between paths and the purposes of the file hierarchy?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student look for binaries in /bin/?&amp;quot;&lt;br /&gt;
**** &amp;quot;Does the student use ~ to refer to their home directory?&amp;quot;&lt;br /&gt;
*** What artifacts help to assess this?&lt;br /&gt;
*Sample learning objectives:&lt;br /&gt;
**Hierarchically: course/domain; module/unit; learning objective; concept/skill cluster; knowledge components; tasks&lt;br /&gt;
**Scenario (course): Getting Started&lt;br /&gt;
***A learning objective: Students will be able to navigate the filesystem&lt;br /&gt;
****Compose paths&lt;br /&gt;
****Navigate directories&lt;br /&gt;
****Aware of working directory&lt;br /&gt;
****Understand path substitutions (~, ., etc.)&lt;br /&gt;
****etc...&lt;br /&gt;
***A more specific learning objective (or, alternately, a skill cluster): Student will be able to recognize (name) and locate a hidden file in a known directory&lt;br /&gt;
****Give filename of the file in question&lt;br /&gt;
****Identify the path of the file in question&lt;br /&gt;
****Source and provide information about the file, content in the file&lt;br /&gt;
****Differentiate between a file that is and isn&#039;t hidden&lt;br /&gt;
***Task (independent, but linked to learning objectives and knowledge components):&lt;br /&gt;
****Find all the files that end in &amp;quot;.gif&amp;quot; using the find command&lt;br /&gt;
***Knowledge component (independent, linked to tasks):&lt;br /&gt;
****Student uses &#039;find&amp;quot; when searching files&lt;br /&gt;
****Student uses &#039;ls&#039; to list and examine available files&lt;br /&gt;
****Student appropriately applies path prefix (absolute or relative)&lt;br /&gt;
****Student composes valid pattern for file extension&lt;br /&gt;
*Quality of life:&lt;br /&gt;
**Authentication/account naming is a system-wide standard/service&lt;br /&gt;
***How does this behave in designs where students are meant to seize other identities?&lt;br /&gt;
&lt;br /&gt;
=== Functional requirements: ===&lt;br /&gt;
&lt;br /&gt;
* 20-300 students simultaneously? Semi-simultaneously? ~150 concurrently, 500+ at large institutions?&lt;br /&gt;
* Telemetry layer for instrument/orchestration health&lt;br /&gt;
* Students are provided a learning environment that is safe to explore/attack without threatening the application host infrastructure&lt;br /&gt;
* Two weeks unattended uptime&lt;br /&gt;
* Per-user resource caps configurable per host (if not scenario/finer-grained)&lt;br /&gt;
&lt;br /&gt;
=== Ubiquitous language: ===&lt;br /&gt;
(See [https://agilealliance.org/glossary/ubiquitous-language/ What is Ubiquitous Language? | Agile Alliance], [https://www.cosmicpython.com/book/preface.html Cosmic Python] or [https://www.domainlanguage.com/ddd/ DDD Resources - Domain Language])&lt;br /&gt;
* Scenario&lt;br /&gt;
* Learning objectives&lt;br /&gt;
** e.g.&lt;br /&gt;
*** &amp;quot;Students will be able to use ls to list the contents of a directory&amp;quot;&lt;br /&gt;
*** &amp;quot;Students will be able to log in to the exercise via SSH&amp;quot;&lt;br /&gt;
** Milestone&lt;br /&gt;
*** Do these have sufficient resolution to see why/how students arrived at a correct answer?&lt;br /&gt;
** What are indicators of student progress on learning objectives?&lt;br /&gt;
*** How do events (such as those matched by milestones) correlate with questions?&lt;br /&gt;
**** What are students asking about in help requests?&lt;br /&gt;
**** Can we select semantics from log data (commands, chat, questions)?&lt;br /&gt;
** How do we know tangibly that a student is completing learning-related tasks?&lt;br /&gt;
* Questions (are exercises distinct?)&lt;br /&gt;
* Guide&lt;br /&gt;
** Guide section&lt;br /&gt;
* Student&lt;br /&gt;
* Instructor&lt;br /&gt;
* Hint&lt;br /&gt;
* Intervention&lt;br /&gt;
* Prediction&lt;br /&gt;
* Learning environment&lt;br /&gt;
** Underlying system state?&lt;br /&gt;
*** What denotes/distinguishes a command?&lt;br /&gt;
* Observability/instrumentation? (Name?)&lt;br /&gt;
&lt;br /&gt;
=== User stores: ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Instructors&#039;&#039;&#039; make &#039;&#039;&#039;Scenarios&#039;&#039;&#039; available to &#039;&#039;&#039;Students&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Scenarios&#039;&#039;&#039; configure the &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; to support &#039;&#039;&#039;Learning Objectives&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Scenarios&#039;&#039;&#039; may include a &#039;&#039;&#039;Guide&#039;&#039;&#039; to support exercises and tasks in the &#039;&#039;&#039;Learning Objectives&#039;&#039;&#039;&lt;br /&gt;
* The &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; can offer &#039;&#039;&#039;Interventions&#039;&#039;&#039; based on &#039;&#039;&#039;Student&#039;&#039;&#039; performance&lt;br /&gt;
* &#039;&#039;&#039;Students&#039;&#039;&#039; start and stop the &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; of &#039;&#039;&#039;Scenarios&#039;&#039;&#039; made accessible to them (the &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; does not consume resources when not in use)&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=906</id>
		<title>New Version Planning</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=906"/>
		<updated>2026-03-27T22:00:35Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As we&#039;re still getting started, please feel free to insert notes and comments where you like - just try to avoid changing or deleting things you yourself didn&#039;t write. We&#039;ll come up with a shared structure as we go. Also consider using the &amp;quot;discussion&amp;quot; tab at the top if you aren&#039;t sure where to comment or want to have a dialogue.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
These items at the top are on the agenda for discussion, but don&#039;t have a clear place they belong yet:&lt;br /&gt;
* POST handler/API reachability&lt;br /&gt;
* Chat capability&lt;br /&gt;
* Containers&lt;br /&gt;
* NSJail, chroot?&lt;br /&gt;
* Physical systems: controlled via ordinary SSH, provisioned with PXE or other net boot methods?&lt;br /&gt;
* How to model shared resources and contention?&lt;br /&gt;
* Staged/prerequisite content in guide/scenario&lt;br /&gt;
* How to refer to observation/logging instruments&lt;br /&gt;
* How do we assess correctness/domain truth in a model of learning objectives?&lt;br /&gt;
** Navigating a filesystem:&lt;br /&gt;
*** &amp;quot;Can a student navigate the filesystem?&amp;quot;&lt;br /&gt;
**** &amp;quot;Can the student compose an absolute path?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student use / to begin an absolute path?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student refer to directories present at the root of the filesystem?&amp;quot;&lt;br /&gt;
**** &amp;quot;Do they correctly use find?&amp;quot;&lt;br /&gt;
**** &amp;quot;Does the student map correctly between paths and the purposes of the file hierarchy?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student look for binaries in /bin/?&amp;quot;&lt;br /&gt;
**** &amp;quot;Does the student use ~ to refer to their home directory?&amp;quot;&lt;br /&gt;
*** What artifacts help to assess this?&lt;br /&gt;
*Sample learning objectives:&lt;br /&gt;
**Hierarchically: course/domain; module/unit; learning objective; concept/skill cluster; knowledge components; tasks&lt;br /&gt;
**Scenario (course): Getting Started&lt;br /&gt;
***A learning objective: Students will be able to navigate the filesystem&lt;br /&gt;
****Compose paths&lt;br /&gt;
****Navigate directories&lt;br /&gt;
****Aware of working directory&lt;br /&gt;
****Understand path substitutions (~, ., etc.)&lt;br /&gt;
****etc...&lt;br /&gt;
***A more specific learning objective: Student will be able to recognize (name) and locate a hidden file in a known directory&lt;br /&gt;
****Give filename of the file in question&lt;br /&gt;
****Identify the path of the file in question&lt;br /&gt;
****Source and provide information about the file, content in the file&lt;br /&gt;
****Differentiate between a file that is and isn&#039;t hidden&lt;br /&gt;
***Task (independent, but linked to learning objectives and knowledge components):&lt;br /&gt;
****Find all the files that end in &amp;quot;.gif&amp;quot; using the find command&lt;br /&gt;
***Knowledge component (independent, linked to tasks):&lt;br /&gt;
****Student uses &#039;find&amp;quot; when searching files&lt;br /&gt;
****Student uses &#039;ls&#039; to list and examine available files&lt;br /&gt;
****Student appropriately applies path prefix (absolute or relative)&lt;br /&gt;
****Student composes valid pattern for file extension&lt;br /&gt;
*Quality of life:&lt;br /&gt;
**Authentication/account naming is a system-wide standard/service&lt;br /&gt;
***How does this behave in designs where students are meant to seize other identities?&lt;br /&gt;
&lt;br /&gt;
=== Functional requirements: ===&lt;br /&gt;
&lt;br /&gt;
* 20-300 students simultaneously? Semi-simultaneously? ~150 concurrently, 500+ at large institutions?&lt;br /&gt;
* Telemetry layer for instrument/orchestration health&lt;br /&gt;
* Students are provided a learning environment that is safe to explore/attack without threatening the application host infrastructure&lt;br /&gt;
* Two weeks unattended uptime&lt;br /&gt;
* Per-user resource caps configurable per host (if not scenario/finer-grained)&lt;br /&gt;
&lt;br /&gt;
=== Ubiquitous language: ===&lt;br /&gt;
(See [https://agilealliance.org/glossary/ubiquitous-language/ What is Ubiquitous Language? | Agile Alliance], [https://www.cosmicpython.com/book/preface.html Cosmic Python] or [https://www.domainlanguage.com/ddd/ DDD Resources - Domain Language])&lt;br /&gt;
* Scenario&lt;br /&gt;
* Learning objectives&lt;br /&gt;
** e.g.&lt;br /&gt;
*** &amp;quot;Students will be able to use ls to list the contents of a directory&amp;quot;&lt;br /&gt;
*** &amp;quot;Students will be able to log in to the exercise via SSH&amp;quot;&lt;br /&gt;
** Milestone&lt;br /&gt;
*** Do these have sufficient resolution to see why/how students arrived at a correct answer?&lt;br /&gt;
** What are indicators of student progress on learning objectives?&lt;br /&gt;
*** How do events (such as those matched by milestones) correlate with questions?&lt;br /&gt;
**** What are students asking about in help requests?&lt;br /&gt;
**** Can we select semantics from log data (commands, chat, questions)?&lt;br /&gt;
** How do we know tangibly that a student is completing learning-related tasks?&lt;br /&gt;
* Questions (are exercises distinct?)&lt;br /&gt;
* Guide&lt;br /&gt;
** Guide section&lt;br /&gt;
* Student&lt;br /&gt;
* Instructor&lt;br /&gt;
* Hint&lt;br /&gt;
* Intervention&lt;br /&gt;
* Prediction&lt;br /&gt;
* Learning environment&lt;br /&gt;
** Underlying system state?&lt;br /&gt;
*** What denotes/distinguishes a command?&lt;br /&gt;
* Observability/instrumentation? (Name?)&lt;br /&gt;
&lt;br /&gt;
=== User stores: ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Instructors&#039;&#039;&#039; make &#039;&#039;&#039;Scenarios&#039;&#039;&#039; available to &#039;&#039;&#039;Students&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Scenarios&#039;&#039;&#039; configure the &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; to support &#039;&#039;&#039;Learning Objectives&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Scenarios&#039;&#039;&#039; may include a &#039;&#039;&#039;Guide&#039;&#039;&#039; to support exercises and tasks in the &#039;&#039;&#039;Learning Objectives&#039;&#039;&#039;&lt;br /&gt;
* The &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; can offer &#039;&#039;&#039;Interventions&#039;&#039;&#039; based on &#039;&#039;&#039;Student&#039;&#039;&#039; performance&lt;br /&gt;
* &#039;&#039;&#039;Students&#039;&#039;&#039; start and stop the &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; of &#039;&#039;&#039;Scenarios&#039;&#039;&#039; made accessible to them (the &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; does not consume resources when not in use)&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=905</id>
		<title>New Version Planning</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=905"/>
		<updated>2026-03-24T20:04:25Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* User stores: */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As we&#039;re still getting started, please feel free to insert notes and comments where you like - just try to avoid changing or deleting things you yourself didn&#039;t write. We&#039;ll come up with a shared structure as we go. Also consider using the &amp;quot;discussion&amp;quot; tab at the top if you aren&#039;t sure where to comment or want to have a dialogue.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
These items at the top are on the agenda for discussion, but don&#039;t have a clear place they belong yet:&lt;br /&gt;
* POST handler/API reachability&lt;br /&gt;
* Chat capability&lt;br /&gt;
* Containers&lt;br /&gt;
* NSJail, chroot?&lt;br /&gt;
* Physical systems: controlled via ordinary SSH, provisioned with PXE or other net boot methods?&lt;br /&gt;
* How to model shared resources and contention?&lt;br /&gt;
* Staged/prerequisite content in guide/scenario&lt;br /&gt;
* How to refer to observation/logging instruments&lt;br /&gt;
* How do we assess correctness/domain truth in a model of learning objectives?&lt;br /&gt;
** Navigating a filesystem:&lt;br /&gt;
*** &amp;quot;Can a student navigate the filesystem?&amp;quot;&lt;br /&gt;
**** &amp;quot;Can the student compose an absolute path?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student use / to begin an absolute path?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student refer to directories present at the root of the filesystem?&amp;quot;&lt;br /&gt;
**** &amp;quot;Do they correctly use find?&amp;quot;&lt;br /&gt;
**** &amp;quot;Does the student map correctly between paths and the purposes of the file hierarchy?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student look for binaries in /bin/?&amp;quot;&lt;br /&gt;
**** &amp;quot;Does the student use ~ to refer to their home directory?&amp;quot;&lt;br /&gt;
*** What artifacts help to assess this?&lt;br /&gt;
&lt;br /&gt;
=== Functional requirements: ===&lt;br /&gt;
&lt;br /&gt;
* 20-300 students simultaneously? Semi-simultaneously? ~150 concurrently, 500+ at large institutions?&lt;br /&gt;
* Telemetry layer for instrument/orchestration health&lt;br /&gt;
* Students are provided a learning environment that is safe to explore/attack without threatening the application host infrastructure&lt;br /&gt;
&lt;br /&gt;
=== Ubiquitous language: ===&lt;br /&gt;
(See [https://agilealliance.org/glossary/ubiquitous-language/ What is Ubiquitous Language? | Agile Alliance], [https://www.cosmicpython.com/book/preface.html Cosmic Python] or [https://www.domainlanguage.com/ddd/ DDD Resources - Domain Language])&lt;br /&gt;
* Scenario&lt;br /&gt;
* Learning objectives&lt;br /&gt;
** e.g.&lt;br /&gt;
*** &amp;quot;Students will be able to use ls to list the contents of a directory&amp;quot;&lt;br /&gt;
*** &amp;quot;Students will be able to log in to the exercise via SSH&amp;quot;&lt;br /&gt;
** Milestone&lt;br /&gt;
*** Do these have sufficient resolution to see why/how students arrived at a correct answer?&lt;br /&gt;
** What are indicators of student progress on learning objectives?&lt;br /&gt;
*** How do events (such as those matched by milestones) correlate with questions?&lt;br /&gt;
**** What are students asking about in help requests?&lt;br /&gt;
**** Can we select semantics from log data (commands, chat, questions)?&lt;br /&gt;
** How do we know tangibly that a student is completing learning-related tasks?&lt;br /&gt;
* Questions (are exercises distinct?)&lt;br /&gt;
* Guide&lt;br /&gt;
** Guide section&lt;br /&gt;
* Student&lt;br /&gt;
* Instructor&lt;br /&gt;
* Hint&lt;br /&gt;
* Intervention&lt;br /&gt;
* Prediction&lt;br /&gt;
* Learning environment&lt;br /&gt;
** Underlying system state?&lt;br /&gt;
*** What denotes/distinguishes a command?&lt;br /&gt;
* Observability/instrumentation? (Name?)&lt;br /&gt;
&lt;br /&gt;
=== User stores: ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Instructors&#039;&#039;&#039; make &#039;&#039;&#039;Scenarios&#039;&#039;&#039; available to &#039;&#039;&#039;Students&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Scenarios&#039;&#039;&#039; configure the &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; to support &#039;&#039;&#039;Learning Objectives&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Scenarios&#039;&#039;&#039; may include a &#039;&#039;&#039;Guide&#039;&#039;&#039; to support exercises and tasks in the &#039;&#039;&#039;Learning Objectives&#039;&#039;&#039;&lt;br /&gt;
* The &#039;&#039;&#039;Learning Environment&#039;&#039;&#039; can offer &#039;&#039;&#039;Interventions&#039;&#039;&#039; based on &#039;&#039;&#039;Student&#039;&#039;&#039; performance&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=904</id>
		<title>New Version Planning</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=904"/>
		<updated>2026-03-24T20:01:53Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* User stores: */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As we&#039;re still getting started, please feel free to insert notes and comments where you like - just try to avoid changing or deleting things you yourself didn&#039;t write. We&#039;ll come up with a shared structure as we go. Also consider using the &amp;quot;discussion&amp;quot; tab at the top if you aren&#039;t sure where to comment or want to have a dialogue.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
These items at the top are on the agenda for discussion, but don&#039;t have a clear place they belong yet:&lt;br /&gt;
* POST handler/API reachability&lt;br /&gt;
* Chat capability&lt;br /&gt;
* Containers&lt;br /&gt;
* NSJail, chroot?&lt;br /&gt;
* Physical systems: controlled via ordinary SSH, provisioned with PXE or other net boot methods?&lt;br /&gt;
* How to model shared resources and contention?&lt;br /&gt;
* Staged/prerequisite content in guide/scenario&lt;br /&gt;
* How to refer to observation/logging instruments&lt;br /&gt;
* How do we assess correctness/domain truth in a model of learning objectives?&lt;br /&gt;
** Navigating a filesystem:&lt;br /&gt;
*** &amp;quot;Can a student navigate the filesystem?&amp;quot;&lt;br /&gt;
**** &amp;quot;Can the student compose an absolute path?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student use / to begin an absolute path?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student refer to directories present at the root of the filesystem?&amp;quot;&lt;br /&gt;
**** &amp;quot;Do they correctly use find?&amp;quot;&lt;br /&gt;
**** &amp;quot;Does the student map correctly between paths and the purposes of the file hierarchy?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student look for binaries in /bin/?&amp;quot;&lt;br /&gt;
**** &amp;quot;Does the student use ~ to refer to their home directory?&amp;quot;&lt;br /&gt;
*** What artifacts help to assess this?&lt;br /&gt;
&lt;br /&gt;
=== Functional requirements: ===&lt;br /&gt;
&lt;br /&gt;
* 20-300 students simultaneously? Semi-simultaneously? ~150 concurrently, 500+ at large institutions?&lt;br /&gt;
* Telemetry layer for instrument/orchestration health&lt;br /&gt;
* Students are provided a learning environment that is safe to explore/attack without threatening the application host infrastructure&lt;br /&gt;
&lt;br /&gt;
=== Ubiquitous language: ===&lt;br /&gt;
(See [https://agilealliance.org/glossary/ubiquitous-language/ What is Ubiquitous Language? | Agile Alliance], [https://www.cosmicpython.com/book/preface.html Cosmic Python] or [https://www.domainlanguage.com/ddd/ DDD Resources - Domain Language])&lt;br /&gt;
* Scenario&lt;br /&gt;
* Learning objectives&lt;br /&gt;
** e.g.&lt;br /&gt;
*** &amp;quot;Students will be able to use ls to list the contents of a directory&amp;quot;&lt;br /&gt;
*** &amp;quot;Students will be able to log in to the exercise via SSH&amp;quot;&lt;br /&gt;
** Milestone&lt;br /&gt;
*** Do these have sufficient resolution to see why/how students arrived at a correct answer?&lt;br /&gt;
** What are indicators of student progress on learning objectives?&lt;br /&gt;
*** How do events (such as those matched by milestones) correlate with questions?&lt;br /&gt;
**** What are students asking about in help requests?&lt;br /&gt;
**** Can we select semantics from log data (commands, chat, questions)?&lt;br /&gt;
** How do we know tangibly that a student is completing learning-related tasks?&lt;br /&gt;
* Questions (are exercises distinct?)&lt;br /&gt;
* Guide&lt;br /&gt;
** Guide section&lt;br /&gt;
* Student&lt;br /&gt;
* Instructor&lt;br /&gt;
* Hint&lt;br /&gt;
* Intervention&lt;br /&gt;
* Prediction&lt;br /&gt;
* Learning environment&lt;br /&gt;
** Underlying system state?&lt;br /&gt;
*** What denotes/distinguishes a command?&lt;br /&gt;
* Observability/instrumentation? (Name?)&lt;br /&gt;
&lt;br /&gt;
=== User stores: ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Instructors&#039;&#039;&#039; make &#039;&#039;&#039;Scenarios&#039;&#039;&#039; available to &#039;&#039;&#039;Students&#039;&#039;&#039;&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=903</id>
		<title>New Version Planning</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=903"/>
		<updated>2026-03-24T20:01:17Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* User stores: */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As we&#039;re still getting started, please feel free to insert notes and comments where you like - just try to avoid changing or deleting things you yourself didn&#039;t write. We&#039;ll come up with a shared structure as we go. Also consider using the &amp;quot;discussion&amp;quot; tab at the top if you aren&#039;t sure where to comment or want to have a dialogue.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
These items at the top are on the agenda for discussion, but don&#039;t have a clear place they belong yet:&lt;br /&gt;
* POST handler/API reachability&lt;br /&gt;
* Chat capability&lt;br /&gt;
* Containers&lt;br /&gt;
* NSJail, chroot?&lt;br /&gt;
* Physical systems: controlled via ordinary SSH, provisioned with PXE or other net boot methods?&lt;br /&gt;
* How to model shared resources and contention?&lt;br /&gt;
* Staged/prerequisite content in guide/scenario&lt;br /&gt;
* How to refer to observation/logging instruments&lt;br /&gt;
* How do we assess correctness/domain truth in a model of learning objectives?&lt;br /&gt;
** Navigating a filesystem:&lt;br /&gt;
*** &amp;quot;Can a student navigate the filesystem?&amp;quot;&lt;br /&gt;
**** &amp;quot;Can the student compose an absolute path?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student use / to begin an absolute path?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student refer to directories present at the root of the filesystem?&amp;quot;&lt;br /&gt;
**** &amp;quot;Do they correctly use find?&amp;quot;&lt;br /&gt;
**** &amp;quot;Does the student map correctly between paths and the purposes of the file hierarchy?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student look for binaries in /bin/?&amp;quot;&lt;br /&gt;
**** &amp;quot;Does the student use ~ to refer to their home directory?&amp;quot;&lt;br /&gt;
*** What artifacts help to assess this?&lt;br /&gt;
&lt;br /&gt;
=== Functional requirements: ===&lt;br /&gt;
&lt;br /&gt;
* 20-300 students simultaneously? Semi-simultaneously? ~150 concurrently, 500+ at large institutions?&lt;br /&gt;
* Telemetry layer for instrument/orchestration health&lt;br /&gt;
* Students are provided a learning environment that is safe to explore/attack without threatening the application host infrastructure&lt;br /&gt;
&lt;br /&gt;
=== Ubiquitous language: ===&lt;br /&gt;
(See [https://agilealliance.org/glossary/ubiquitous-language/ What is Ubiquitous Language? | Agile Alliance], [https://www.cosmicpython.com/book/preface.html Cosmic Python] or [https://www.domainlanguage.com/ddd/ DDD Resources - Domain Language])&lt;br /&gt;
* Scenario&lt;br /&gt;
* Learning objectives&lt;br /&gt;
** e.g.&lt;br /&gt;
*** &amp;quot;Students will be able to use ls to list the contents of a directory&amp;quot;&lt;br /&gt;
*** &amp;quot;Students will be able to log in to the exercise via SSH&amp;quot;&lt;br /&gt;
** Milestone&lt;br /&gt;
*** Do these have sufficient resolution to see why/how students arrived at a correct answer?&lt;br /&gt;
** What are indicators of student progress on learning objectives?&lt;br /&gt;
*** How do events (such as those matched by milestones) correlate with questions?&lt;br /&gt;
**** What are students asking about in help requests?&lt;br /&gt;
**** Can we select semantics from log data (commands, chat, questions)?&lt;br /&gt;
** How do we know tangibly that a student is completing learning-related tasks?&lt;br /&gt;
* Questions (are exercises distinct?)&lt;br /&gt;
* Guide&lt;br /&gt;
** Guide section&lt;br /&gt;
* Student&lt;br /&gt;
* Instructor&lt;br /&gt;
* Hint&lt;br /&gt;
* Intervention&lt;br /&gt;
* Prediction&lt;br /&gt;
* Learning environment&lt;br /&gt;
** Underlying system state?&lt;br /&gt;
*** What denotes/distinguishes a command?&lt;br /&gt;
* Observability/instrumentation? (Name?)&lt;br /&gt;
&lt;br /&gt;
=== User stores: ===&lt;br /&gt;
&lt;br /&gt;
* Instructors make Scenarios available to Students&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=902</id>
		<title>New Version Planning</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=902"/>
		<updated>2026-03-24T19:58:35Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* Ubiquitous language: */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As we&#039;re still getting started, please feel free to insert notes and comments where you like - just try to avoid changing or deleting things you yourself didn&#039;t write. We&#039;ll come up with a shared structure as we go. Also consider using the &amp;quot;discussion&amp;quot; tab at the top if you aren&#039;t sure where to comment or want to have a dialogue.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
These items at the top are on the agenda for discussion, but don&#039;t have a clear place they belong yet:&lt;br /&gt;
* POST handler/API reachability&lt;br /&gt;
* Chat capability&lt;br /&gt;
* Containers&lt;br /&gt;
* NSJail, chroot?&lt;br /&gt;
* Physical systems: controlled via ordinary SSH, provisioned with PXE or other net boot methods?&lt;br /&gt;
* How to model shared resources and contention?&lt;br /&gt;
* Staged/prerequisite content in guide/scenario&lt;br /&gt;
* How to refer to observation/logging instruments&lt;br /&gt;
&lt;br /&gt;
=== Functional requirements: ===&lt;br /&gt;
&lt;br /&gt;
* 20-300 students simultaneously? Semi-simultaneously? ~150 concurrently, 500+ at large institutions?&lt;br /&gt;
* Telemetry layer for instrument/orchestration health&lt;br /&gt;
* Students are provided a learning environment that is safe to explore/attack without threatening the application host infrastructure&lt;br /&gt;
&lt;br /&gt;
=== Ubiquitous language: ===&lt;br /&gt;
(See [https://agilealliance.org/glossary/ubiquitous-language/ What is Ubiquitous Language? | Agile Alliance], [https://www.cosmicpython.com/book/preface.html Cosmic Python] or [https://www.domainlanguage.com/ddd/ DDD Resources - Domain Language])&lt;br /&gt;
* Scenario&lt;br /&gt;
* Learning objectives&lt;br /&gt;
** e.g.&lt;br /&gt;
*** &amp;quot;Students will be able to use ls to list the contents of a directory&amp;quot;&lt;br /&gt;
*** &amp;quot;Students will be able to log in to the exercise via SSH&amp;quot;&lt;br /&gt;
** Milestone&lt;br /&gt;
*** Do these have sufficient resolution to see why/how students arrived at a correct answer?&lt;br /&gt;
** What are indicators of student progress on learning objectives?&lt;br /&gt;
*** How do events (such as those matched by milestones) correlate with questions?&lt;br /&gt;
**** What are students asking about in help requests?&lt;br /&gt;
**** Can we select semantics from log data (commands, chat, questions)?&lt;br /&gt;
** How do we know tangibly that a student is completing learning-related tasks?&lt;br /&gt;
* Questions (are exercises distinct?)&lt;br /&gt;
* Guide&lt;br /&gt;
** Guide section&lt;br /&gt;
* Student&lt;br /&gt;
* Instructor&lt;br /&gt;
* Hint&lt;br /&gt;
* Intervention&lt;br /&gt;
* Prediction&lt;br /&gt;
* Learning environment&lt;br /&gt;
** Underlying system state?&lt;br /&gt;
*** What denotes/distinguishes a command?&lt;br /&gt;
* Observability/instrumentation? (Name?)&lt;br /&gt;
&lt;br /&gt;
=== User stores: ===&lt;br /&gt;
&lt;br /&gt;
* How do we assess correctness/domain truth in a model of learning objectives:&lt;br /&gt;
** Navigating a filesystem:&lt;br /&gt;
*** &amp;quot;Can a student navigate the filesystem?&amp;quot;&lt;br /&gt;
**** &amp;quot;Can the student compose an absolute path?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student use / to begin an absolute path?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student refer to directories present at the root of the filesystem?&amp;quot;&lt;br /&gt;
**** &amp;quot;Do they correctly use find?&amp;quot;&lt;br /&gt;
**** &amp;quot;Does the student map correctly between paths and the purposes of the file hierarchy?&amp;quot;&lt;br /&gt;
***** &amp;quot;Does the student look for binaries in /bin/?&amp;quot;&lt;br /&gt;
**** &amp;quot;Does the student use ~ to refer to their home directory?&amp;quot;&lt;br /&gt;
*** What artifacts help to assess this?&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=Main_Page&amp;diff=901</id>
		<title>Main Page</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=Main_Page&amp;diff=901"/>
		<updated>2026-02-22T19:01:26Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: Remove derelict/unpopulated pages and miscategorized/orphaned content&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Welcome to the EDURange Wiki. This is less of a formal documentation as it is a living document.&lt;br /&gt;
&lt;br /&gt;
I think that if it&#039;s a choice between losing track of people&#039;s contributions and having a messy wiki, I&#039;d much prefer a messy wiki.  It&#039;s my hope that we can gather all of our notes, thoughts, and loosely related ephemera here, rather than having it scattered across correspondence and various sharing platforms.&lt;br /&gt;
&lt;br /&gt;
We don&#039;t have many conventions yet. Please follow the Golden Rule and treat others as you would be treated. Put your name on your changes. Consider posting to the discussion tab or appending to a page rather than overwriting it. Try not to overwrite the personal work of others.&lt;br /&gt;
&lt;br /&gt;
If it&#039;s installable/runnable/code of any sort, put it on GitHub as well - preferably under the edurange organization so that our future contributors will have seamless access to it too.&lt;br /&gt;
&lt;br /&gt;
Thanks for your time.&lt;br /&gt;
&lt;br /&gt;
~Joe G&lt;br /&gt;
&lt;br /&gt;
== The EDURange Project ==&lt;br /&gt;
[[:Category:Project History and Roadmaps|Project History and Roadmaps]]&lt;br /&gt;
&lt;br /&gt;
== Developer Links ==&lt;br /&gt;
!!! [[New Version Planning]] !!!&lt;br /&gt;
&lt;br /&gt;
[[Style Guidelines and Developer Tools]]&lt;br /&gt;
&lt;br /&gt;
[[Reference Materials]]&lt;br /&gt;
&lt;br /&gt;
[[:Category:Demonstrations|Demonstrations]]&lt;br /&gt;
&lt;br /&gt;
[[Design Stories (OLD)]]&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=900</id>
		<title>New Version Planning</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=900"/>
		<updated>2026-02-17T19:10:39Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* Functional requirements: */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As we&#039;re still getting started, please feel free to insert notes and comments where you like - just try to avoid changing or deleting things you yourself didn&#039;t write. We&#039;ll come up with a shared structure as we go. Also consider using the &amp;quot;discussion&amp;quot; tab at the top if you aren&#039;t sure where to comment or want to have a dialogue.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
These items at the top are on the agenda for discussion, but don&#039;t have a clear place they belong yet:&lt;br /&gt;
* POST handler/API reachability&lt;br /&gt;
* Chat capability&lt;br /&gt;
* Containers&lt;br /&gt;
* NSJail, chroot?&lt;br /&gt;
* Physical systems: controlled via ordinary SSH, provisioned with PXE or other net boot methods?&lt;br /&gt;
* How to model shared resources and contention?&lt;br /&gt;
* Staged/prerequisite content in guide/scenario&lt;br /&gt;
* How to refer to observation/logging instruments&lt;br /&gt;
&lt;br /&gt;
=== Functional requirements: ===&lt;br /&gt;
&lt;br /&gt;
* 20-300 students simultaneously? Semi-simultaneously? ~150 concurrently, 500+ at large institutions?&lt;br /&gt;
* Telemetry layer for instrument/orchestration health&lt;br /&gt;
* Students are provided a learning environment that is safe to explore/attack without threatening the application host infrastructure&lt;br /&gt;
&lt;br /&gt;
=== Ubiquitous language: ===&lt;br /&gt;
(See [https://agilealliance.org/glossary/ubiquitous-language/ What is Ubiquitous Language? | Agile Alliance], [https://www.cosmicpython.com/book/preface.html Cosmic Python] or [https://www.domainlanguage.com/ddd/ DDD Resources - Domain Language])&lt;br /&gt;
* Scenario&lt;br /&gt;
* Learning objectives&lt;br /&gt;
** e.g.&lt;br /&gt;
*** &amp;quot;Students will be able to use ls to list the contents of a directory&amp;quot;&lt;br /&gt;
*** &amp;quot;Students will be able to log in to the exercise via SSH&amp;quot;&lt;br /&gt;
** Milestone&lt;br /&gt;
* Questions (are exercises distinct?)&lt;br /&gt;
* Guide&lt;br /&gt;
** Guide section&lt;br /&gt;
* Student&lt;br /&gt;
* Instructor&lt;br /&gt;
* Hint&lt;br /&gt;
* Intervention&lt;br /&gt;
* Prediction&lt;br /&gt;
* Learning environment&lt;br /&gt;
* Observability/instrumentation? (Name?)&lt;br /&gt;
&lt;br /&gt;
=== User stores: ===&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=899</id>
		<title>New Version Planning</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=899"/>
		<updated>2026-01-24T01:34:52Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* Functional requirements: */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As we&#039;re still getting started, please feel free to insert notes and comments where you like - just try to avoid changing or deleting things you yourself didn&#039;t write. We&#039;ll come up with a shared structure as we go. Also consider using the &amp;quot;discussion&amp;quot; tab at the top if you aren&#039;t sure where to comment or want to have a dialogue.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
These items at the top are on the agenda for discussion, but don&#039;t have a clear place they belong yet:&lt;br /&gt;
* POST handler/API reachability&lt;br /&gt;
* Chat capability&lt;br /&gt;
* Containers&lt;br /&gt;
* NSJail, chroot?&lt;br /&gt;
* Physical systems: controlled via ordinary SSH, provisioned with PXE or other net boot methods?&lt;br /&gt;
* How to model shared resources and contention?&lt;br /&gt;
* Staged/prerequisite content in guide/scenario&lt;br /&gt;
* How to refer to observation/logging instruments&lt;br /&gt;
&lt;br /&gt;
=== Functional requirements: ===&lt;br /&gt;
&lt;br /&gt;
* 20-300 students simultaneously? Semi-simultaneously? ~150 concurrently&lt;br /&gt;
* Telemetry layer for instrument/orchestration health&lt;br /&gt;
* Students are provided a learning environment that is safe to explore/attack without threatening the application host infrastructure&lt;br /&gt;
&lt;br /&gt;
=== Ubiquitous language: ===&lt;br /&gt;
(See [https://agilealliance.org/glossary/ubiquitous-language/ What is Ubiquitous Language? | Agile Alliance], [https://www.cosmicpython.com/book/preface.html Cosmic Python] or [https://www.domainlanguage.com/ddd/ DDD Resources - Domain Language])&lt;br /&gt;
* Scenario&lt;br /&gt;
* Learning objectives&lt;br /&gt;
** e.g.&lt;br /&gt;
*** &amp;quot;Students will be able to use ls to list the contents of a directory&amp;quot;&lt;br /&gt;
*** &amp;quot;Students will be able to log in to the exercise via SSH&amp;quot;&lt;br /&gt;
** Milestone&lt;br /&gt;
* Questions (are exercises distinct?)&lt;br /&gt;
* Guide&lt;br /&gt;
** Guide section&lt;br /&gt;
* Student&lt;br /&gt;
* Instructor&lt;br /&gt;
* Hint&lt;br /&gt;
* Intervention&lt;br /&gt;
* Prediction&lt;br /&gt;
* Learning environment&lt;br /&gt;
* Observability/instrumentation? (Name?)&lt;br /&gt;
&lt;br /&gt;
=== User stores: ===&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=898</id>
		<title>New Version Planning</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=898"/>
		<updated>2026-01-24T01:31:05Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* Ubiquitous language: */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As we&#039;re still getting started, please feel free to insert notes and comments where you like - just try to avoid changing or deleting things you yourself didn&#039;t write. We&#039;ll come up with a shared structure as we go. Also consider using the &amp;quot;discussion&amp;quot; tab at the top if you aren&#039;t sure where to comment or want to have a dialogue.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
These items at the top are on the agenda for discussion, but don&#039;t have a clear place they belong yet:&lt;br /&gt;
* POST handler/API reachability&lt;br /&gt;
* Chat capability&lt;br /&gt;
* Containers&lt;br /&gt;
* NSJail, chroot?&lt;br /&gt;
* How to model shared resources and contention?&lt;br /&gt;
* Staged/prerequisite content in guide/scenario&lt;br /&gt;
* How to refer to observation/logging instruments&lt;br /&gt;
&lt;br /&gt;
=== Functional requirements: ===&lt;br /&gt;
&lt;br /&gt;
* 20-300 students simultaneously? Semi-simultaneously? ~150 concurrently&lt;br /&gt;
* Telemetry layer for instrument/orchestration health&lt;br /&gt;
* Students are provided a learning environment that is safe to explore/attack without threatening the application host infrastructure&lt;br /&gt;
&lt;br /&gt;
=== Ubiquitous language: ===&lt;br /&gt;
(See [https://agilealliance.org/glossary/ubiquitous-language/ What is Ubiquitous Language? | Agile Alliance], [https://www.cosmicpython.com/book/preface.html Cosmic Python] or [https://www.domainlanguage.com/ddd/ DDD Resources - Domain Language])&lt;br /&gt;
* Scenario&lt;br /&gt;
* Learning objectives&lt;br /&gt;
** e.g.&lt;br /&gt;
*** &amp;quot;Students will be able to use ls to list the contents of a directory&amp;quot;&lt;br /&gt;
*** &amp;quot;Students will be able to log in to the exercise via SSH&amp;quot;&lt;br /&gt;
** Milestone&lt;br /&gt;
* Questions (are exercises distinct?)&lt;br /&gt;
* Guide&lt;br /&gt;
** Guide section&lt;br /&gt;
* Student&lt;br /&gt;
* Instructor&lt;br /&gt;
* Hint&lt;br /&gt;
* Intervention&lt;br /&gt;
* Prediction&lt;br /&gt;
* Learning environment&lt;br /&gt;
* Observability/instrumentation? (Name?)&lt;br /&gt;
&lt;br /&gt;
=== User stores: ===&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=897</id>
		<title>New Version Planning</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=897"/>
		<updated>2026-01-24T01:28:38Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* Functional requirements: */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As we&#039;re still getting started, please feel free to insert notes and comments where you like - just try to avoid changing or deleting things you yourself didn&#039;t write. We&#039;ll come up with a shared structure as we go. Also consider using the &amp;quot;discussion&amp;quot; tab at the top if you aren&#039;t sure where to comment or want to have a dialogue.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
These items at the top are on the agenda for discussion, but don&#039;t have a clear place they belong yet:&lt;br /&gt;
* POST handler/API reachability&lt;br /&gt;
* Chat capability&lt;br /&gt;
* Containers&lt;br /&gt;
* NSJail, chroot?&lt;br /&gt;
* How to model shared resources and contention?&lt;br /&gt;
* Staged/prerequisite content in guide/scenario&lt;br /&gt;
* How to refer to observation/logging instruments&lt;br /&gt;
&lt;br /&gt;
=== Functional requirements: ===&lt;br /&gt;
&lt;br /&gt;
* 20-300 students simultaneously? Semi-simultaneously? ~150 concurrently&lt;br /&gt;
* Telemetry layer for instrument/orchestration health&lt;br /&gt;
* Students are provided a learning environment that is safe to explore/attack without threatening the application host infrastructure&lt;br /&gt;
&lt;br /&gt;
=== Ubiquitous language: ===&lt;br /&gt;
(See [https://agilealliance.org/glossary/ubiquitous-language/ What is Ubiquitous Language? | Agile Alliance], [https://www.cosmicpython.com/book/preface.html Cosmic Python] or [https://www.domainlanguage.com/ddd/ DDD Resources - Domain Language])&lt;br /&gt;
* Scenario&lt;br /&gt;
* Learning objectives&lt;br /&gt;
** e.g.&lt;br /&gt;
*** &amp;quot;Students will be able to use ls to list the contents of a directory&amp;quot;&lt;br /&gt;
*** &amp;quot;Student can log in to the exercise via SSH&amp;quot;&lt;br /&gt;
** Milestone&lt;br /&gt;
* Questions (are exercises distinct?)&lt;br /&gt;
* Guide&lt;br /&gt;
** Guide section&lt;br /&gt;
* Student&lt;br /&gt;
* Instructor&lt;br /&gt;
* Hint&lt;br /&gt;
* Intervention&lt;br /&gt;
* Prediction&lt;br /&gt;
* Learning environment&lt;br /&gt;
* Observability/instrumentation? (Name?)&lt;br /&gt;
&lt;br /&gt;
=== User stores: ===&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=896</id>
		<title>New Version Planning</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=896"/>
		<updated>2026-01-24T01:21:43Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* Ubiquitous language: */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As we&#039;re still getting started, please feel free to insert notes and comments where you like - just try to avoid changing or deleting things you yourself didn&#039;t write. We&#039;ll come up with a shared structure as we go. Also consider using the &amp;quot;discussion&amp;quot; tab at the top if you aren&#039;t sure where to comment or want to have a dialogue.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
These items at the top are on the agenda for discussion, but don&#039;t have a clear place they belong yet:&lt;br /&gt;
* POST handler/API reachability&lt;br /&gt;
* Chat capability&lt;br /&gt;
* Containers&lt;br /&gt;
* NSJail, chroot?&lt;br /&gt;
* How to model shared resources and contention?&lt;br /&gt;
* Staged/prerequisite content in guide/scenario&lt;br /&gt;
* How to refer to observation/logging instruments&lt;br /&gt;
&lt;br /&gt;
=== Functional requirements: ===&lt;br /&gt;
&lt;br /&gt;
* 20-300 students simultaneously? Semi-simultaneously? ~150 concurrently&lt;br /&gt;
* Telemetry layer for instrument/orchestration help&lt;br /&gt;
* Students are provided a learning environment that is safe to explore/attack without threatening the application host infrastructure&lt;br /&gt;
&lt;br /&gt;
=== Ubiquitous language: ===&lt;br /&gt;
(See [https://agilealliance.org/glossary/ubiquitous-language/ What is Ubiquitous Language? | Agile Alliance], [https://www.cosmicpython.com/book/preface.html Cosmic Python] or [https://www.domainlanguage.com/ddd/ DDD Resources - Domain Language])&lt;br /&gt;
* Scenario&lt;br /&gt;
* Learning objectives&lt;br /&gt;
** e.g.&lt;br /&gt;
*** &amp;quot;Students will be able to use ls to list the contents of a directory&amp;quot;&lt;br /&gt;
*** &amp;quot;Student can log in to the exercise via SSH&amp;quot;&lt;br /&gt;
** Milestone&lt;br /&gt;
* Questions (are exercises distinct?)&lt;br /&gt;
* Guide&lt;br /&gt;
** Guide section&lt;br /&gt;
* Student&lt;br /&gt;
* Instructor&lt;br /&gt;
* Hint&lt;br /&gt;
* Intervention&lt;br /&gt;
* Prediction&lt;br /&gt;
* Learning environment&lt;br /&gt;
* Observability/instrumentation? (Name?)&lt;br /&gt;
&lt;br /&gt;
=== User stores: ===&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=Main_Page&amp;diff=895</id>
		<title>Main Page</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=Main_Page&amp;diff=895"/>
		<updated>2026-01-24T01:18:05Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: /* Developer Links */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Welcome to the EDURange Wiki. This is less of a formal documentation as it is a living document.&lt;br /&gt;
&lt;br /&gt;
I think that if it&#039;s a choice between losing track of people&#039;s contributions and having a messy wiki, I&#039;d much prefer a messy wiki.  It&#039;s my hope that we can gather all of our notes, thoughts, and loosely related ephemera here, rather than having it scattered across correspondence and various sharing platforms.&lt;br /&gt;
&lt;br /&gt;
We don&#039;t have many conventions yet. Please follow the Golden Rule and treat others as you would be treated. Put your name on your changes. Consider posting to the discussion tab or appending to a page rather than overwriting it. Try not to overwrite the personal work of others.&lt;br /&gt;
&lt;br /&gt;
If it&#039;s installable/runnable/code of any sort, put it on GitHub as well - preferably under the edurange organization so that our future contributors will have seamless access to it too.&lt;br /&gt;
&lt;br /&gt;
Thanks for your time.&lt;br /&gt;
&lt;br /&gt;
~Joe G&lt;br /&gt;
&lt;br /&gt;
== The EDURange Project ==&lt;br /&gt;
[[:Category:Project History and Roadmaps|Project History and Roadmaps]]&lt;br /&gt;
&lt;br /&gt;
== Developer Links ==&lt;br /&gt;
!!! [[New Version Planning]] !!!&lt;br /&gt;
&lt;br /&gt;
[[Style Guidelines and Developer Tools]]&lt;br /&gt;
&lt;br /&gt;
[[Reference Materials]]&lt;br /&gt;
&lt;br /&gt;
[[:Category:Demonstrations|Demonstrations]]&lt;br /&gt;
&lt;br /&gt;
[[Design Stories (OLD)]]&lt;br /&gt;
&lt;br /&gt;
[[Interface Design]]&lt;br /&gt;
&lt;br /&gt;
[[Scenario Creation Guide]]&lt;br /&gt;
&lt;br /&gt;
[[Terraform Design]]&lt;br /&gt;
&lt;br /&gt;
[[Hosting Requirements]]&lt;br /&gt;
&lt;br /&gt;
[[Domain Modeling]]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
We&#039;re still figuring out Media Wiki, so I kept the following...&lt;br /&gt;
&lt;br /&gt;
== Orphaned Default Media Wiki Content ==&lt;br /&gt;
&amp;lt;strong&amp;gt;Welcome to the EDURange Wiki&amp;lt;/strong&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Consult the [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents User&#039;s Guide] for information on using the wiki software.&lt;br /&gt;
&lt;br /&gt;
== Getting started with Media Wiki ==&lt;br /&gt;
* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]&lt;br /&gt;
* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]&lt;br /&gt;
* [https://lists.wikimedia.org/postorius/lists/mediawiki-announce.lists.wikimedia.org/ MediaWiki release mailing list]&lt;br /&gt;
* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]&lt;br /&gt;
* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Learn how to combat spam on your wiki]&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
	<entry>
		<id>http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=894</id>
		<title>New Version Planning</title>
		<link rel="alternate" type="text/html" href="http://edurange.org/wiki/index.php?title=New_Version_Planning&amp;diff=894"/>
		<updated>2026-01-24T00:54:52Z</updated>

		<summary type="html">&lt;p&gt;Jwgranville: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As we&#039;re still getting started, please feel free to insert notes and comments where you like - just try to avoid changing or deleting things you yourself didn&#039;t write. We&#039;ll come up with a shared structure as we go. Also consider using the &amp;quot;discussion&amp;quot; tab at the top if you aren&#039;t sure where to comment or want to have a dialogue.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
These items at the top are on the agenda for discussion, but don&#039;t have a clear place they belong yet:&lt;br /&gt;
* POST handler/API reachability&lt;br /&gt;
* Chat capability&lt;br /&gt;
* Containers&lt;br /&gt;
* NSJail, chroot?&lt;br /&gt;
* How to model shared resources and contention?&lt;br /&gt;
* Staged/prerequisite content in guide/scenario&lt;br /&gt;
* How to refer to observation/logging instruments&lt;br /&gt;
&lt;br /&gt;
=== Functional requirements: ===&lt;br /&gt;
&lt;br /&gt;
* 20-300 students simultaneously? Semi-simultaneously? ~150 concurrently&lt;br /&gt;
* Telemetry layer for instrument/orchestration help&lt;br /&gt;
* Students are provided a learning environment that is safe to explore/attack without threatening the application host infrastructure&lt;br /&gt;
&lt;br /&gt;
=== Ubiquitous language: ===&lt;br /&gt;
(See [https://agilealliance.org/glossary/ubiquitous-language/ What is Ubiquitous Language? | Agile Alliance])&lt;br /&gt;
* Scenario&lt;br /&gt;
* Learning objectives&lt;br /&gt;
** e.g.&lt;br /&gt;
*** &amp;quot;Students will be able to use ls to list the contents of a directory&amp;quot;&lt;br /&gt;
*** &amp;quot;Student can log in to the exercise via SSH&amp;quot;&lt;br /&gt;
** Milestone&lt;br /&gt;
* Questions (are exercises distinct?)&lt;br /&gt;
* Guide&lt;br /&gt;
** Guide section&lt;br /&gt;
* Student&lt;br /&gt;
* Instructor&lt;br /&gt;
* Hint&lt;br /&gt;
* Intervention&lt;br /&gt;
* Prediction&lt;br /&gt;
* Learning environment&lt;br /&gt;
* Observability/instrumentation? (Name?)&lt;br /&gt;
&lt;br /&gt;
=== User stores: ===&lt;/div&gt;</summary>
		<author><name>Jwgranville</name></author>
	</entry>
</feed>