A small program with shape
Greeter shows what one locus looks like in isolation. Real
programs are more than one. Loci coordinate over a typed bus
— a publish/subscribe channel where subjects are first-class
declarations, not strings.
Here’s a small program with three loci communicating over one topic:
type Tick { n: Int; }
topic Beats { payload: Tick; }
locus Counter {
params { sum: Int = 0; }
bus { subscribe Beats as on_beat; }
fn on_beat(t: Tick) { self.sum = self.sum + t.n; }
}
locus Echoer {
bus { subscribe Beats as on_beat; }
fn on_beat(t: Tick) { println("tick: ", t.n); }
}
locus Pulse {
params { iters: Int = 4; }
bus { publish Beats; }
run() {
let mut i = 1;
while i <= self.iters {
Beats <- Tick { n: i };
i = i + 1;
}
}
}
fn main() {
let c = Counter { };
Echoer { };
Pulse { iters: 4 };
print("sum=");
println(c.sum);
}
Save it as beats.ap and run:
aperio run beats.ap
Output:
tick: 1
tick: 2
tick: 3
tick: 4
sum=10
What’s happening
Three loci, one topic, two subscribers.
type Tickis a value-shape record. No lifecycle, no flow — pure data that crosses the bus.topic Beatsnames a typed channel carryingTickvalues. The payload type travels with the declaration, not with each subscriber.Countersubscribes toBeats; itson_beathandler accumulatest.nintoself.sum.Echoersubscribes to the same topic and prints each tick. Two subscribers, one topic, no coordination needed between them — the bus does fan-out invisibly.Pulsepublishes four ticks, then exits itsrun()body.
Notice what’s not in the program:
- No channel-creation boilerplate. The topic IS the channel.
- No subscriber-registration calls. The
bus { subscribe ... }block IS the registration. - No event-loop. The runtime drains pending bus events at
cooperative yield points;
run()and the handlers compose naturally. - No coordination between
CounterandEchoer. The fact that two loci listen to the same topic is not their concern; it’s the bus’s.
Locus lifetimes here
Three different locus shapes get instantiated in main:
let c = Counter { };— a let-bound locus.cis a handle to the locus; the binding stays valid for the rest of the function.Counterdissolves at the end ofmain.Echoer { };(no binding, but has bus subscriptions) — a long-lived anonymous child. BecauseEchoerhas a bus subscription, the runtime keeps it alive past the statement boundary so it can still receive events. It dissolves at the end ofmainalongsideCounter.Pulse { iters: 4 };(no binding, hasrun()but no subscriptions) — a statement-position literal with work to do. Itsrun()body fires synchronously, all four ticks flow through the bus, andPulsedissolves at the statement boundary.
The pending bus events fire before Pulse dissolves, so by
the time println(c.sum) runs, both subscribers have
processed all four ticks.
Where to next
This program already raises questions the Concepts chapters answer:
- What’s the rule about who subscribes vs. who publishes? — See The bus.
- Why does an anonymous
Echoerstay alive but anonymousPulsedoesn’t? — See Lifecycle & time. - What’s the right way to organize this program if there
were ten subscribers, or if
Counter’s state had to survive a restart? — See The locus and Modeling — how to think in Aperio.
The next section is Concepts, which walks through the structural model one primitive at a time.