Foundation redesign: persistent cast, sidebars, story-beat titles

The previous chapter-by-chapter visualization felt disconnected — every
scene used a different idiom and the cast was anonymous "honest-0". This
commit lays the foundation for a coherent end-to-end story:

Cast (Sources/CrisisViz/Model/Cast.swift):
- Four named validators with stable color slots — Aaron (coral), Ben (teal),
  Carl (amber), Dave (violet, Byzantine). The first three honest nodes claim
  the lead slots; the byzantine node always plays Dave; surplus honest
  nodes become muted "Peer-N" fallbacks.
- buildAssignment(nodes:) does the mapping once at load time and is cached
  on DataManager so every render is a dictionary lookup.

DataManager:
- nodeColors / nodeNames now serve cast colors and cast display names so
  every existing call site that reads them automatically picks up the new
  naming with no chapter-by-chapter changes required.
- castRole(for:), castColor(for:), laneIndex(for:), castOrderedNodes() —
  helpers chapters can opt into when they want lane order to follow the
  cast (Aaron→Ben→Carl→Dave) rather than the simulation's raw node order.

Persistent sidebars (Sources/CrisisViz/Views/Sidebars.swift):
- CastSidebar (180pt left edge): one card per lead with color swatch,
  display name, and personality cue. BYZ badge on Dave.
- LegendSidebar (200pt right edge): persistent encoding rules — color
  = validator, stripe = round, border = vertex state, edge style =
  parent link. The answer to "every view looks different".
- ImmersiveView wraps the SceneRouter in an HStack(CastSidebar, scene,
  LegendSidebar) so chapters get a slightly narrower canvas without
  needing to know the sidebars exist.

Story-beat titles (ChapterDefinitions + SceneNarrations):
- Chapter titles are now full sentences with a [Technical: ...] suffix
  ("Aaron speaks. Ben listens. The graph begins. [Technical: gossip
  & DAG]"). Story-beat for the noob, technical handle for the engineer.
- Scene titles likewise rewritten as continuous narrative beats.
- All chapter narrations rewritten to address the cast by name and walk
  the reader through what is happening rather than asserting it.

Decisions saved to memory at project_redesign_decisions.md so future
sessions know cast names, title format, and morph-not-cut policy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
saymrwulf 2026-05-04 11:18:11 +02:00
parent dd185e70ae
commit 06ef8234d5
6 changed files with 567 additions and 91 deletions

View file

@ -9,9 +9,15 @@ final class DataManager {
/// Node colors keyed by processIdHex
private(set) var nodeColors: [String: Color] = [:]
/// Node names keyed by processIdHex
/// Node names keyed by processIdHex (display names, e.g. "Aaron").
/// We deliberately overwrite the simulation's "honest-0/byzantine-0"
/// names with the cast names here so every existing call site that
/// renders `nodeNames[pid]` automatically picks up the new naming.
private(set) var nodeNames: [String: String] = [:]
/// Persistent cast assignment built at load time. See `Cast.swift`.
private(set) var castByPid: [String: CastRole] = [:]
// Palette: distinct colors for up to 9 nodes
static let palette: [Color] = [
Color(red: 0.30, green: 0.69, blue: 0.94), // cyan-blue
@ -64,9 +70,15 @@ final class DataManager {
}
private func buildLookups(_ data: SimulationData) {
for (i, node) in data.nodes.enumerated() {
nodeColors[node.processIdHex] = Self.palette[min(i, Self.palette.count - 1)]
nodeNames[node.processIdHex] = node.name
// Cast assignment is the source of truth for both name and color now.
// The legacy palette is kept on the type for any non-cast call site
// that still indexes into it.
let cast = Cast.buildAssignment(nodes: data.nodes)
castByPid = cast
for node in data.nodes {
let role = cast[node.processIdHex]
nodeColors[node.processIdHex] = role?.color ?? Cast.muted
nodeNames[node.processIdHex] = role?.displayName ?? node.name
}
}
@ -76,6 +88,54 @@ final class DataManager {
return sim.nodes.firstIndex(where: { $0.processIdHex == processIdHex }) ?? 0
}
/// Cast lookup: the named role this validator plays in the story.
/// Returns a muted "Peer-N" placeholder for unknown PIDs so callers
/// don't have to handle nil.
func castRole(for processIdHex: String) -> CastRole {
castByPid[processIdHex] ?? Cast.peer(0)
}
/// Direct color lookup that respects the cast assignment.
/// Prefer this over indexing into `palette` for any new code.
func castColor(for processIdHex: String) -> Color {
castByPid[processIdHex]?.color ?? Cast.muted
}
/// Lane index 0..3 for the four leads, or nil for muted peers.
/// Used by the lane-and-rounds layout so every chapter places Aaron at
/// lane 0, Ben at lane 1, Carl at lane 2, Dave at lane 3.
func laneIndex(for processIdHex: String) -> Int? {
guard let role = castByPid[processIdHex], role.isNamedCast else { return nil }
return Cast.leads.firstIndex(where: { $0.id == role.id })
}
/// Nodes in cast lane order (Aaron Ben Carl Dave) followed by any
/// muted peers. Pass this into `DAGLayout.compute(...)`'s `nodes:`
/// parameter so the layout's vertical ordering matches the CastSidebar
/// Dave ends up just below Carl rather than at the bottom of the
/// list of seven simulation nodes.
func castOrderedNodes() -> [NodeMeta] {
guard let sim else { return [] }
let pidToNode = Dictionary(uniqueKeysWithValues: sim.nodes.map { ($0.processIdHex, $0) })
// Look up each cast lead by id, then by finding the pid assigned to that role.
var ordered: [NodeMeta] = []
var taken = Set<String>()
for lead in Cast.leads {
// First pid whose role.id == lead.id
if let pid = castByPid.first(where: { $0.value.id == lead.id })?.key,
let node = pidToNode[pid] {
ordered.append(node)
taken.insert(pid)
}
}
// Append any remaining (muted peers, etc.) in their original order.
for node in sim.nodes where !taken.contains(node.processIdHex) {
ordered.append(node)
}
return ordered
}
/// Get snapshot for a given step (clamped)
func snapshot(step: Int) -> StepSnapshot? {
guard let sim else { return nil }

View file

@ -0,0 +1,121 @@
import SwiftUI
/// The four named validators that anchor the entire teaching narrative.
///
/// Why this exists: in the redesign we made on 2026-05-04, we replaced the
/// generic "honest-0honest-5 + byzantine-0" naming with a persistent cast of
/// four MALE characters who appear in every chapter with a stable color and
/// vertical lane. A returning viewer always knows where Aaron is.
///
/// **Mapping policy** (since the loaded simulation has 6 honest + 1 byzantine,
/// not 4 total):
/// - First three honest nodes (sorted by `name`) Aaron, Ben, Carl
/// - First byzantine node Dave
/// - Any remaining honest nodes unnamed `Peer-N` rendered in muted gray
///
/// All chapters read color and display name through `Cast.role(for:)`. Never
/// use `DataManager.palette` directly for cast members go through Cast so the
/// whole story stays color-consistent.
struct CastRole: Identifiable, Hashable {
let id: String // stable cast slot id ("aaron", "ben", "carl", "dave", "peer-N")
let displayName: String // "Aaron", "Ben", "Carl", "Dave", "Peer-1"
let color: Color
let cue: String // one-line personality cue shown in the sidebar
let isByzantineSlot: Bool
let isNamedCast: Bool // true for the 4 leads, false for muted peers
}
enum Cast {
// MARK: - Color slots never used outside the cast
static let coral = Color(red: 0.97, green: 0.50, blue: 0.45) // Aaron
static let teal = Color(red: 0.30, green: 0.78, blue: 0.78) // Ben
static let amber = Color(red: 0.96, green: 0.74, blue: 0.30) // Carl
static let violet = Color(red: 0.72, green: 0.50, blue: 0.92) // Dave (Byzantine)
static let muted = Color(red: 0.55, green: 0.58, blue: 0.62) // Peer-N
// MARK: - The four named leads
static let aaron = CastRole(
id: "aaron",
displayName: "Aaron",
color: coral,
cue: "Proposer — \"I saw it first\"",
isByzantineSlot: false,
isNamedCast: true
)
static let ben = CastRole(
id: "ben",
displayName: "Ben",
color: teal,
cue: "Careful witness",
isByzantineSlot: false,
isNamedCast: true
)
static let carl = CastRole(
id: "carl",
displayName: "Carl",
color: amber,
cue: "Late joiner — graph fills in last",
isByzantineSlot: false,
isNamedCast: true
)
static let dave = CastRole(
id: "dave",
displayName: "Dave",
color: violet,
cue: "Partitioned / Byzantine actor",
isByzantineSlot: true,
isNamedCast: true
)
/// The four leads in fixed lane order top to bottom: Aaron, Ben, Carl, Dave.
/// Lane order never changes; this is what makes "where is Aaron?" a trivial
/// question for the viewer in every chapter.
static let leads: [CastRole] = [aaron, ben, carl, dave]
static func peer(_ index: Int) -> CastRole {
CastRole(
id: "peer-\(index)",
displayName: "Peer-\(index)",
color: muted,
cue: "background validator",
isByzantineSlot: false,
isNamedCast: false
)
}
// MARK: - processIdHex CastRole assignment
//
// Built once when DataManager finishes loading the simulation. Cached on
// DataManager so every Canvas render is a dictionary lookup, not a sort.
/// Build the assignment for an ordered list of `NodeMeta`. Returns a
/// dictionary keyed by `processIdHex`. Honest nodes are assigned in
/// alphabetical-by-name order (matches the testbed's deterministic sort).
static func buildAssignment(nodes: [NodeMeta]) -> [String: CastRole] {
let honest = nodes.filter { !$0.isByzantine }.sorted { $0.name < $1.name }
let byz = nodes.filter { $0.isByzantine }.sorted { $0.name < $1.name }
var out: [String: CastRole] = [:]
let leadHonestSlots: [CastRole] = [aaron, ben, carl]
for (i, node) in honest.enumerated() {
if i < leadHonestSlots.count {
out[node.processIdHex] = leadHonestSlots[i]
} else {
// Surplus honest nodes become muted peers, numbered from 1.
out[node.processIdHex] = peer(i - leadHonestSlots.count + 1)
}
}
// Byzantine slot: first byzantine claims Dave; any extras also become peers.
for (i, node) in byz.enumerated() {
if i == 0 {
out[node.processIdHex] = dave
} else {
out[node.processIdHex] = peer(100 + i) // distinct namespace
}
}
return out
}
}

View file

@ -33,17 +33,42 @@ struct SceneAddress: Equatable {
}
enum AllChapters {
// Titles use the redesign's story-beat + [Technical: ...] format. The
// story-beat sentence makes the chapter approachable for a noob; the
// bracket reminds engineers which protocol concept the chapter maps to.
// Scene counts unchanged from the previous structure so every existing
// SceneRouter case keeps lining up.
static let list: [ChapterDef] = [
ChapterDef(id: 0, title: "The Problem", subtitle: "Why is consensus hard?", sceneCount: 3),
ChapterDef(id: 1, title: "Building the Graph", subtitle: "Asynchronous gossip & the Lamport DAG", sceneCount: 7),
ChapterDef(id: 2, title: "Network Partition", subtitle: "When nodes lose connectivity", sceneCount: 4),
ChapterDef(id: 3, title: "Rounds from Weight", subtitle: "PoW accumulation triggers boundaries", sceneCount: 3),
ChapterDef(id: 4, title: "Virtual Voting", subtitle: "No vote messages — just graph inference", sceneCount: 3),
ChapterDef(id: 5, title: "Leader Election", subtitle: "The PoW lottery picks a winner", sceneCount: 2),
ChapterDef(id: 6, title: "Total Order", subtitle: "Deterministic ordering = convergence", sceneCount: 3),
ChapterDef(id: 7, title: "Data Availability — The Problem", subtitle: "Why gossip is not storage", sceneCount: 4),
ChapterDef(id: 8, title: "Data Availability — A Design", subtitle: "Erasure coding, Merkle proofs & incentives", sceneCount: 5),
ChapterDef(id: 9, title: "Byzantine Resilience", subtitle: "Why attackers fail", sceneCount: 2),
ChapterDef(id: 0, title: "Four friends, one ledger, no boss.",
subtitle: "[Technical: validator set & the BFT consensus problem]",
sceneCount: 3),
ChapterDef(id: 1, title: "Aaron speaks. Ben listens. The graph begins.",
subtitle: "[Technical: asynchronous gossip & the Lamport DAG]",
sceneCount: 7),
ChapterDef(id: 2, title: "Dave can't hear Aaron. The graph splits.",
subtitle: "[Technical: network partition & local divergence]",
sceneCount: 4),
ChapterDef(id: 3, title: "Counting witnesses to mark a round.",
subtitle: "[Technical: PoW weight accumulation → round boundary]",
sceneCount: 3),
ChapterDef(id: 4, title: "Did you see what I saw?",
subtitle: "[Technical: virtual voting via strongly-seeing paths]",
sceneCount: 3),
ChapterDef(id: 5, title: "One vertex per round becomes the spokesperson.",
subtitle: "[Technical: PoW leader election]",
sceneCount: 2),
ChapterDef(id: 6, title: "Spokespersons line up. Everyone else falls in behind.",
subtitle: "[Technical: total order via Kahn's algorithm]",
sceneCount: 3),
ChapterDef(id: 7, title: "The leader knows. Did the leader tell anyone?",
subtitle: "[Technical: data availability — gossip is not storage]",
sceneCount: 4),
ChapterDef(id: 8, title: "Erasure shards make the data un-loseable.",
subtitle: "[Technical: erasure coding + Merkle proofs + fee market]",
sceneCount: 5),
ChapterDef(id: 9, title: "Dave lies. Crisis catches him.",
subtitle: "[Technical: Byzantine resilience under f < n/3]",
sceneCount: 2),
]
static var totalScenes: Int {

View file

@ -14,98 +14,125 @@ enum SceneNarrations {
// MARK: - Titles
// Scene titles use the same story-beat phrasing as chapter titles.
// [Technical: ...] suffix is added on the *opening* scene of each
// chapter (it would be noise on every scene); subsequent scenes are
// pure narrative beats.
private static let titles: [[String]] = [
// Ch 0: The Problem
["Three Nodes, Three Truths", "Why Agreement Is Hard", "The Question"],
// Ch 1: Building the Graph
["Async Gossip Begins", "The DAG Grows", "Tip References Only", "Hash Inspection",
"Commit-Reveal", "Graph Identity", "Recursive Expansion"],
// Ch 2: Network Partition
["Connections Break", "Diverging Realities", "Virtual Voting Diverges", "Reconnection"],
// Ch 3: Rounds from Weight
["Weight Accumulates", "Threshold Crossing", "Unanimity"],
// Ch 4: Virtual Voting
["No Vote Messages", "SVP Trace", "Deterministic Outcome"],
// Ch 5: Leader Election
["Candidates by Weight", "The Hash Lottery"],
// Ch 6: Total Order
["Topological Sort", "Animated Ordering", "Convergence"],
// Ch 7: DA The Problem
["Gossip ≠ Storage", "The Bootstrapping Problem", "Sybil Attack on Reveals", "The Separation"],
// Ch 8: DA A Design
["Erasure Coding", "Merkle Tree of Chunks", "On-Demand Retrieval", "Incentivized Storage", "Full Stack"],
// Ch 9: Byzantine Resilience
["The Attacker", "Why Attacks Fail"],
// Ch 0: Four friends, one ledger, no boss.
["Meet the cast.", "Each writes their own log.", "But there's only one truth."],
// Ch 1: Aaron speaks. Ben listens. The graph begins.
["Aaron's first message.",
"Ben copies what he saw.",
"Carl arrives and links in.",
"Click any vertex to look inside.",
"A hash hides what's underneath.",
"Same messages, same graph, every time.",
"Following hashes back to the start."],
// Ch 2: Dave can't hear Aaron. The graph splits.
["Dave goes silent.",
"The world keeps building without him.",
"Two graphs, two stories.",
"Dave reconnects. Stories reconcile."],
// Ch 3: Counting witnesses to mark a round.
["Each message carries weight.",
"Enough weight, and a round closes.",
"Everyone agrees on the round, without talking."],
// Ch 4: Did you see what I saw?
["No vote messages — only the graph.",
"Walking back through shared ancestors.",
"If we share enough ancestors, we agree."],
// Ch 5: One vertex per round becomes the spokesperson.
["Heaviest weight wins the round.",
"The hash lottery picks who speaks."],
// Ch 6: Spokespersons line up. Everyone else falls in behind.
["Sorting the DAG into a line.",
"Vertices slide into their place.",
"Everyone produces the same line."],
// Ch 7: The leader knows. Did the leader tell anyone?
["Gossip is loud, but forgetful.",
"A new joiner asks for everything.",
"Ten thousand fake joiners ask for everything.",
"Ordering and storage are different problems."],
// Ch 8: Erasure shards make the data un-loseable.
["Cut the message into k shards, send n.",
"Every shard carries a Merkle proof.",
"Pay a small fee, get a shard back.",
"Storage nodes earn for holding rare data.",
"The full stack: order on top, data underneath."],
// Ch 9: Dave lies. Crisis catches him.
["Dave forks his message.",
"The protocol routes around him."],
]
// MARK: - Narrations
private static let narrations: [[String]] = [
// Ch 0: The Problem
// Ch 0: Four friends, one ledger, no boss.
[
"Three nodes observe transactions in different orders. Without a shared clock, each node's local history tells a different story.",
"In an asynchronous network with no central authority, agreeing on a single order requires a protocol — brute force won't work.",
"How can nodes that never fully trust each other converge on one truth? This is the problem Crisis solves.",
"Aaron, Ben, Carl and Dave each run a node. There is no central server and no boss who decides what happened first. Whatever order of events emerges has to come from the four of them talking to each other.",
"Each of the four keeps their own log of what they have seen. Because messages travel at different speeds, they can record the same events in different orders. Right now, four logs means four different stories.",
"Yet at the end of the day they all need to agree on ONE history — same events, same order, byte-for-byte. This is the problem Crisis solves: how to turn four independent points of view into one shared truth, even when one of the four (Dave) is lying.",
],
// Ch 1: Building the Graph
// Ch 1: Aaron speaks. Ben listens. The graph begins.
[
"Nodes grind proof-of-work at different speeds. There is no global clock — messages emerge chaotically, whenever a node finishes its PoW puzzle.",
"Each new message references the DAG tips it has seen — the frontier of knowledge. The graph grows organically, shaped only by causality.",
"A message references only the TIPS of the DAG — the latest messages a node knows about. Transitive hash commitment means everything behind those tips is implicitly referenced.",
"Click any vertex to inspect it. Follow hash references backward through the graph — each hash reveals the full causal history beneath it.",
"Hash is opaque: hash(C) reveals nothing about A or B inside. Only when the pre-image is shared can the contents be verified.",
"Despite chaotic timing, the same set of messages always produces the same graph. The DAG is deterministic given its inputs.",
"Opening hash layers recursively: from any message, you can trace the entire history back to genesis by following pre-images.",
"Aaron grinds proof-of-work and produces the first message. There is no global clock telling him when to do this — he just finishes his PoW puzzle and broadcasts. The story starts whenever he is ready.",
"Ben hears Aaron's message and references it from his own next message. That little arrow between them — Ben's vertex pointing back to Aaron's — is what we'll call a parent edge. It says \"I saw this before I spoke\".",
"Carl now joins in. His message points back to whatever tips of the DAG he can see — currently Aaron's and Ben's. The graph is starting to braid the four perspectives together.",
"You can click any vertex to look inside it. The window shows what that validator saw at that moment — its own message, plus the chain of parents it acknowledged.",
"But hashes are one-way. If you only see Carl's hash, you cannot tell what's underneath it. You need the actual messages, opened up, to verify the chain. This is why \"data availability\" will become its own chapter later.",
"Despite the chaotic timing, the same set of messages always produces the same graph. Aaron's view, Ben's view, and Carl's view — once they've all gossiped — are byte-for-byte identical. This determinism is what makes consensus even possible.",
"From any vertex you can walk back through parent hashes all the way to the very first message. That walk is the validator's full causal history.",
],
// Ch 2: Network Partition
// Ch 2: Dave can't hear Aaron. The graph splits.
[
"Two nodes lose connectivity. Their messages stop flowing to the network, and the network's messages stop reaching them.",
"The majority continues building a rich DAG. The isolated nodes only see each other — their local DAG is sparse and incomplete.",
"Virtual voting runs on whatever graph a node sees. Same algorithm, different graph → different election results. The isolated nodes disagree.",
"When connectivity returns, gossip floods the gap. The isolated nodes catch up, graphs converge, and consensus resumes.",
"Dave's connection drops. His messages stop flowing to Aaron, Ben and Carl, and theirs stop reaching him. Notice Dave's lane is still drawing vertices — but the rest of the world stops linking to them.",
"Aaron, Ben and Carl keep gossiping with each other and their part of the graph stays rich. Dave's lane, on the other hand, is producing messages that nobody else can see — his graph is sparse and increasingly out of step.",
"Now we have two stories on screen. The top three lanes converge on one history. Dave's lane has its own. Both are internally consistent — that is the danger of partitions.",
"Dave's connection comes back. Gossip floods the gap, the missing messages catch up in both directions, and Dave's view merges back into the same graph the others were building. Consensus picks up where it left off.",
],
// Ch 3: Rounds from Weight
// Ch 3: Counting witnesses to mark a round.
[
"Each message carries proof-of-work weight. As messages accumulate in a round, the total weight grows toward a threshold.",
"When cumulative weight crosses the threshold, a round boundary is declared. The is_last flag marks the transition.",
"All honest nodes, seeing the same graph, compute the same round boundaries. Weight is objective — no negotiation needed.",
"Every message carries a proof-of-work weight — the harder the puzzle, the heavier the message. Round 0 starts collecting weight as Aaron, Ben and Carl publish.",
"When the total weight inside a round crosses a threshold, the round closes. The very last message to push it over the line is flagged with `is_last` — that's the round boundary marker.",
"Crucially, every honest validator looking at the same graph computes the same round boundary. Nobody negotiates. Weight is just arithmetic — and arithmetic does not depend on who you ask.",
],
// Ch 4: Virtual Voting
// Ch 4: Did you see what I saw?
[
"There are no vote messages in Crisis. Votes are inferred from graph structure — if you can see a path, you can count the vote.",
"Strongly-seeing path (SVP): trace from a candidate message through the DAG to the deciding round. Each intermediate message is a witness.",
"Same graph, same paths, same votes. Virtual voting is deterministic — all honest nodes reach the same conclusion without exchanging a single ballot.",
"Crisis sends NO ballots and NO vote messages. Voting is just \"can I trace a path through my graph from your vertex back to a shared ancestor?\". If yes, you've seen what I've seen.",
"Watch this slow walk. We highlight Aaron's round-4 vertex on top, and Carl's round-4 vertex below. We then draw the depth-3 ancestor cone of each. The pulsing white region is where the cones overlap — those are the vertices BOTH of them have witnessed.",
"Two or more shared ancestors is enough. Aaron and Carl now agree. This is the collapse: their two opinions snap together into one round-marked consensus, with no message ever named \"vote\" being sent.",
],
// Ch 5: Leader Election
// Ch 5: One vertex per round becomes the spokesperson.
[
"All decided messages in a round are candidates. They're ranked by their PoW weight — heavier proof wins.",
"The highest-weight candidate becomes the round's leader. Since PoW hashes are unpredictable, no one can game the outcome.",
"In each round, Aaron's, Ben's, and Carl's vertices all compete on PoW weight. Heaviest wins. Dave's vertices, as a Byzantine actor, are never trusted — but their weight is still real, so they participate in the lottery.",
"The heaviest-weight vertex of the round becomes that round's leader — its spokesperson. Nobody can game this; PoW outcomes are unpredictable until the puzzle is solved.",
],
// Ch 6: Total Order
// Ch 6: Spokespersons line up. Everyone else falls in behind.
[
"Kahn's algorithm produces a topological ordering of the DAG. When multiple orderings are possible, PoW weight breaks ties deterministically.",
"Watch as vertices slide into their final ordered positions. The DAG's partial order becomes a total order.",
"Every honest node produces the identical sequence. Convergence is guaranteed — the same graph always yields the same order.",
"Every leader vertex pulls its causal history with it. Run Kahn's topological sort across that history, with PoW weight breaking ties, and you get a single ordered line.",
"Watch as Aaron's and Ben's vertices slide into the snake. The DAG's partial order — \"this came before that\" only where parents say so — collapses into a total order: position 0, position 1, position 2, …",
"Every honest validator produces the IDENTICAL sequence. That's convergence. Whatever Aaron's line is, Ben's line and Carl's line are byte-for-byte the same.",
],
// Ch 7: DA The Problem
// Ch 7: The leader knows. Did the leader tell anyone?
[
"Gossip is push-based: nodes broadcast CURRENT messages to peers. It's a firehose for the present, not a database for the past.",
"A new node joins and needs historical data. If it requests all pre-images via gossip: O(history) bandwidth per joiner. The network drowns.",
"An attacker spins up 10,000 sybil nodes. Each requests full history. Bandwidth meters max out. The honest network collapses under the load.",
"Crisis provides ORDERING — deterministic from the DAG. Data availability is a SEPARATE layer. They're coupled only by hash commitments.",
"Gossip is great at \"here's what just happened\". It is awful at \"can you replay everything from the beginning?\". The firehose flows forward, not backward.",
"A new validator joins. To catch up, it needs every historical message. If we serve that over gossip, every joiner asks the network to replay all of history. Bandwidth dies.",
"An attacker spins up ten thousand fake joiners, each demanding full history. The honest network melts. This is why ordering and storage have to be separated.",
"Crisis solves ORDERING — that's the DAG. Storing and serving the actual message bytes is a SEPARATE layer, glued on by hash commitments. The next chapter shows the design.",
],
// Ch 8: DA A Design
// Ch 8: Erasure shards make the data un-loseable.
[
"Split each message into k chunks, encode to n chunks (n > k). Any k-of-n suffice to reconstruct. Redundancy without full replication.",
"Each chunk gets a Merkle proof. A requester can verify any single chunk without downloading all of them. Compact, trustless verification.",
"Node Z wants a pre-image. It sends a request with a fee attached. A storage node responds with the chunk + Merkle proof. Point-to-point, not broadcast.",
"Storage nodes earn fees for serving data. A fee market emerges: popular data is cheap (many providers), rare data commands a premium.",
"The full stack: Crisis orders messages (consensus layer). The DA layer stores and serves pre-images (storage layer). Nodes pay for what they need.",
"Cut each message into k shards. Encode it to n shards where n > k, so any k of those n are enough to reconstruct the whole. No single storage node holds the message — the message is *spread*.",
"Every shard ships with a Merkle proof tying it back to the original message hash. A requester can verify any single shard against the hash they already have, without trusting the storage node.",
"When Aaron needs an old message back, he pays a small fee and asks for shards. Storage nodes hand them over with proofs. He reconstructs the message from any k of them.",
"Storage nodes that hold rare data earn more — a tiny fee market for memory. Popular data stays cheap; obscure data commands a premium; nothing is ever quietly forgotten.",
"Top to bottom: Crisis orders messages, the DA layer stores and serves their bytes, and validators pay for what they actually need. The two layers are independent but locked together by hashes.",
],
// Ch 9: Byzantine Resilience
// Ch 9: Dave lies. Crisis catches him.
[
"A byzantine node is highlighted. It can send conflicting messages, withhold data, or try to manipulate voting outcomes.",
"Attacks fail because: the protocol tolerates < 1/3 byzantine weight, hashes can't be forged, and PoW outcomes are unpredictable.",
"Dave decides to send conflicting messages — one to Aaron, a different one to Ben. He's trying to make Aaron and Ben disagree about what they saw.",
"It doesn't work. Aaron and Ben gossip with each other and quickly notice they have two contradictory Dave-vertices. The protocol marks Dave's vertices as banned (red X). Total order routes around them. Aaron and Ben still converge — and Dave's weight is wasted.",
],
]
}

View file

@ -15,18 +15,27 @@ struct ImmersiveView: View {
var body: some View {
ZStack {
// Single TimelineView at the top chapters are pure renderers below.
// The chapter is wrapped in an HStack with the persistent CastSidebar
// (left) and LegendSidebar (right). Chapters never know about the
// sidebars; they just see a slightly narrower canvas. The sidebars
// are part of the scene rather than overlays so they morph naturally
// alongside chapter content rather than floating on top of it.
TimelineView(.animation(minimumInterval: 1.0 / 60)) { timeline in
let localTime = engine.localTime(at: timeline.date)
SceneRouter(
address: engine.address,
localTime: localTime,
engine: engine,
dm: dm,
inspection: inspection
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.id(engine.address.chapter) // recreate only on chapter change
.transition(.opacity)
HStack(spacing: 0) {
CastSidebar()
SceneRouter(
address: engine.address,
localTime: localTime,
engine: engine,
dm: dm,
inspection: inspection
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.id(engine.address.chapter) // recreate only on chapter change
.transition(.opacity)
LegendSidebar()
}
.background(.black)
}

View file

@ -0,0 +1,234 @@
import SwiftUI
/// Persistent left-edge cast strip. Always visible, in every chapter, with
/// the cast members in fixed lane order (Aaron Ben Carl Dave). The
/// vertical position of each card lines up with the lane that validator
/// occupies in the chapter canvas, so the viewer can trace "this card
/// that lane" without thinking.
///
/// Width is fixed (`Self.width`) and the chapter HStack subtracts that
/// width from its available space, so chapters never have to know the
/// sidebar exists they just see a slightly narrower canvas.
struct CastSidebar: View {
static let width: CGFloat = 180
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("CAST")
.scaledFont(size: 11, weight: .heavy, design: .monospaced)
.foregroundStyle(.white.opacity(0.45))
.tracking(2)
.padding(.bottom, 4)
ForEach(Cast.leads, id: \.id) { role in
CastCard(role: role)
}
Spacer()
}
.padding(.horizontal, 14)
.padding(.vertical, 24)
.frame(width: Self.width, alignment: .topLeading)
.background(
LinearGradient(
colors: [
Color.black.opacity(0.55),
Color.black.opacity(0.20),
],
startPoint: .leading,
endPoint: .trailing
)
)
}
}
private struct CastCard: View {
let role: CastRole
var body: some View {
HStack(spacing: 10) {
// Color swatch same color the vertices for this validator wear.
Circle()
.fill(role.color)
.frame(width: 18, height: 18)
.overlay(
Circle().stroke(Color.white.opacity(0.35), lineWidth: 1)
)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(role.displayName)
.scaledFont(size: 13, weight: .heavy, design: .monospaced)
.foregroundStyle(.white.opacity(0.92))
if role.isByzantineSlot {
Text("BYZ")
.scaledFont(size: 8, weight: .heavy, design: .monospaced)
.foregroundStyle(.red.opacity(0.85))
.padding(.horizontal, 4)
.padding(.vertical, 1)
.background(
RoundedRectangle(cornerRadius: 3)
.stroke(Color.red.opacity(0.6), lineWidth: 0.8)
)
}
}
Text(role.cue)
.scaledFont(size: 9, weight: .medium, design: .monospaced)
.foregroundStyle(.white.opacity(0.55))
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
}
}
/// Persistent right-edge legend. Lists the encoding rules that hold across
/// every chapter:
/// - color = which named validator a vertex belongs to
/// - vertical stripe = round
/// - border thickness/halo = vertex state
/// - edge style = parent link kind
struct LegendSidebar: View {
static let width: CGFloat = 200
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("LEGEND")
.scaledFont(size: 11, weight: .heavy, design: .monospaced)
.foregroundStyle(.white.opacity(0.45))
.tracking(2)
Group {
section(title: "color = validator") {
VStack(alignment: .leading, spacing: 4) {
ForEach(Cast.leads, id: \.id) { role in
HStack(spacing: 6) {
Circle().fill(role.color)
.frame(width: 8, height: 8)
Text(role.displayName)
.scaledFont(size: 10, weight: .medium, design: .monospaced)
.foregroundStyle(.white.opacity(0.78))
}
}
}
}
section(title: "stripe = round") {
HStack(spacing: 0) {
ForEach(0..<4, id: \.self) { i in
Rectangle()
.fill(Color.white.opacity(i.isMultiple(of: 2) ? 0.06 : 0.02))
.frame(width: 16, height: 18)
.overlay(
Text("R\(i)")
.scaledFont(size: 7, weight: .heavy, design: .monospaced)
.foregroundStyle(.white.opacity(0.55))
)
}
}
.background(Color.black.opacity(0.4))
}
section(title: "border = state") {
VStack(alignment: .leading, spacing: 6) {
legendDot(border: 0.6, halo: false, label: "unconfirmed")
legendDot(border: 1.6, halo: false, label: "round-marked")
legendDot(border: 1.6, halo: true, label: "leader")
legendXMark(label: "banned (Byz)")
}
}
section(title: "edge = parent link") {
VStack(alignment: .leading, spacing: 4) {
edgeSample(dashed: false, label: "self-parent")
edgeSample(dashed: true, label: "cross-parent")
}
}
}
Spacer()
}
.padding(.horizontal, 14)
.padding(.vertical, 24)
.frame(width: Self.width, alignment: .topLeading)
.background(
LinearGradient(
colors: [
Color.black.opacity(0.20),
Color.black.opacity(0.55),
],
startPoint: .leading,
endPoint: .trailing
)
)
}
@ViewBuilder
private func section<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.scaledFont(size: 9, weight: .heavy, design: .monospaced)
.foregroundStyle(.white.opacity(0.50))
.tracking(1)
content()
}
}
private func legendDot(border: CGFloat, halo: Bool, label: String) -> some View {
HStack(spacing: 8) {
ZStack {
if halo {
Circle()
.stroke(Color.white.opacity(0.25), lineWidth: 4)
.frame(width: 18, height: 18)
}
Circle()
.fill(Cast.coral.opacity(0.85))
.frame(width: 10, height: 10)
.overlay(
Circle().stroke(Color.white.opacity(0.85), lineWidth: border)
)
}
.frame(width: 22, height: 22)
Text(label)
.scaledFont(size: 9, weight: .medium, design: .monospaced)
.foregroundStyle(.white.opacity(0.72))
}
}
private func legendXMark(label: String) -> some View {
HStack(spacing: 8) {
ZStack {
Circle()
.fill(Cast.violet.opacity(0.85))
.frame(width: 10, height: 10)
Image(systemName: "xmark")
.font(.system(size: 8, weight: .heavy))
.foregroundStyle(.red)
}
.frame(width: 22, height: 22)
Text(label)
.scaledFont(size: 9, weight: .medium, design: .monospaced)
.foregroundStyle(.white.opacity(0.72))
}
}
private func edgeSample(dashed: Bool, label: String) -> some View {
HStack(spacing: 8) {
Canvas { ctx, size in
var p = Path()
p.move(to: CGPoint(x: 0, y: size.height / 2))
p.addLine(to: CGPoint(x: size.width, y: size.height / 2))
let style = StrokeStyle(
lineWidth: 1.2,
dash: dashed ? [3, 3] : []
)
ctx.stroke(p, with: .color(.white.opacity(0.65)), style: style)
}
.frame(width: 26, height: 10)
Text(label)
.scaledFont(size: 9, weight: .medium, design: .monospaced)
.foregroundStyle(.white.opacity(0.72))
}
}
}