Ch09 Byzantine: migrate to serial timeline + fork detection

Dave creates two messages with the same author + same parent set but
different bodies (ζ_a "send 50 BTC to Aaron", ζ_b "send 50 BTC to
Charlie"). Sends one to Aaron, the other to Ben. Aaron and Ben gossip;
the conflict is detected; Dave's vertices get banned with a red X;
honest 3 converge.

Beat structure:

  Scene 0 (47.5s) — Dave thinks ("I'll send different things"), then
                    composes ζ_a (5s slo-mo with red-ring slot
                    chrome to flag this is a fork), seals, repeats
                    for ζ_b (same identity, different body). Sends
                    ζ_a only to Aaron, ζ_b only to Ben.

  Scene 1 (32s) — Aaron forwards his copy of ζ_a to Ben → Ben already
                  has ζ_b → fork detected. Threshold bar appears
                  (f=1, n=4, 3f<n). Both Dave-vertices banned.
                  AARON·BEN·CARL CONVERGE badge.

`Ch09Timeline.swift` (new) — 18 beats over ~80s. Beat kinds
introduced: `forkCompose` / `forkSeal` / `forkSend` / `forkAccept`
(envelope acceptance into a recipient's view), `gossipExchange`
(Aaron forwarding to Ben that triggers detection), `forkDetected`,
`thresholdBar` (f<n/3 visualization), `banDave` (red X across
Dave's lane forks + dim them in towers).

`Ch10_Byzantine.swift` rewritten end-to-end (269 → ~525 lines) to
render from Ch09Timeline. Two red-ringed fork vertices appear on
Dave's lane past the carry-forward {α..ε}. Composing slot for forks
gets a red border to flag the deception. Towers track each player's
fork-acceptance: Aaron picks up ζ_a, Ben picks up ζ_b then ζ_a from
gossip, Carl never receives either, Dave's own view stays clean of
the lies he wrote. Once banned, the fork blocks dim and a red X
overlays them.

`SceneEngine` gets duration overrides for Ch09's 2 scenes.
`ImmersiveView.liveNarration` extends to chapter 9.

Bundled, harness 55/55 invariants, 0 audit errors, 281 PNGs,
36/36 MP4 written.

Known polish item: Dave's thought bubble lands at Carl's lane Y
because the bubble is offset −80pt from the cast circle. Lower-lane
casts need a "below-instead-of-above" placement variant. Tracked
for next pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
saymrwulf 2026-05-07 11:17:58 +02:00
parent b33a2cf4a5
commit c9689ec7e5
4 changed files with 777 additions and 236 deletions

View file

@ -1,18 +1,12 @@
import SwiftUI
/// Ch09 (file Ch10_Byzantine, user-facing chapter index 9): "Dave lies. Crisis catches him."
/// Ch09 (chapter index 9, file Ch10_Byzantine.swift): "Dave lies. Crisis catches him."
///
/// Two beats from the narration:
///
/// - Scene 0 ("Dave forks his message."): on the persistent lane base,
/// Dave's `isByzantineSource` vertices are highlighted with red rings,
/// multi-parent fork lines, and contrasting payloads. The viewer SEES
/// Dave producing two contradictory messages from the same lane.
///
/// - Scene 1 ("The protocol routes around him."): Aaron, Ben, Carl
/// converge on a total order DESPITE Dave's forks. Dave's vertices
/// are X'd out; the f<n/3 shield asserts the byzantine resilience
/// guarantee. The threshold bar appears below.
/// Renders from `Ch09Timeline`. Dave creates two conflicting messages
/// under the same identity (ζ_a, ζ_b), sends one to Aaron and the
/// other to Ben, and tries to make them disagree. Aaron and Ben
/// gossip; the fork is detected; Dave's vertices are banned; the
/// honest 3 converge anyway.
struct Ch10_Byzantine: View {
let sceneIndex: Int
let localTime: Double
@ -20,250 +14,518 @@ struct Ch10_Byzantine: View {
let dm: DataManager
@Environment(AppSettings.self) private var settings
private let dataStep = 60 // post-convergence
var body: some View {
Canvas { context, size in
render(context: &context, size: size, time: localTime)
let t = Ch09Scenes.timelineT(sceneIndex: sceneIndex,
localTime: localTime)
render(in: &context, size: size, t: t)
}
}
private func render(context: inout GraphicsContext, size: CGSize, time: Double) {
guard let sim = dm.sim,
let snap = dm.honestData(step: dataStep) else { return }
private func render(in context: inout GraphicsContext, size: CGSize, t: Double) {
let world = Ch09Timeline.state(at: t)
let lanes = dm.castOrderedNodes()
let layout = DAGLayout.compute(
vertices: snap.vertices, edges: snap.edges, nodes: lanes,
canvasSize: CGSize(width: size.width, height: size.height * 0.65),
margin: 50
)
drawLanes(in: &context, size: size)
drawCastFigures(in: &context, size: size, t: t)
drawAcceptedVertices(in: &context, size: size, world: world)
drawDaveForks(in: &context, size: size, world: world, t: t)
let minRound = snap.vertices.map { $0.round }.min() ?? 0
layout.drawNodeLanes(in: &context, nodes: lanes,
canvasSize: CGSize(width: size.width, height: size.height * 0.65),
dm: dm, textScale: settings.textScale)
layout.drawRoundSeparators(
in: &context,
canvasSize: CGSize(width: size.width, height: size.height * 0.65),
minRound: minRound, alpha: 0.20, textScale: settings.textScale
)
// Identify Dave and his byzantine vertices
let davePid = dm.castByPid.first(where: { $0.value.id == Cast.dave.id })?.key
let daveVertices = davePid.map { pid in
snap.vertices.filter { $0.processIdHex == pid }
} ?? []
let forkedVertices = daveVertices.filter { $0.isByzantineSource }
// Edges: dim everything; brighten edges that touch a forked vertex.
let forkedSet = Set(forkedVertices.map(\.digestHex))
for edge in snap.edges {
guard let from = layout.positions[edge.from],
let to = layout.positions[edge.to] else { continue }
let touchesFork = forkedSet.contains(edge.from) || forkedSet.contains(edge.to)
let alpha = touchesFork ? 0.55 : 0.18
var path = Path()
path.move(to: from)
path.addLine(to: to)
context.stroke(path,
with: .color((touchesFork ? Color.red : Color.white).opacity(alpha)),
lineWidth: touchesFork ? 1.6 : 0.9)
if let thought = world.thought {
drawThoughtBubble(in: &context, size: size, thought: thought)
}
if let composing = world.composing {
drawComposingSlot(in: &context, size: size, composing: composing)
}
if let flight = world.inFlight {
drawForkFlight(in: &context, size: size, flight: flight)
}
if world.forkDetectedAlpha > 0 {
drawForkDetected(in: &context, size: size,
alpha: world.forkDetectedAlpha)
}
if world.thresholdBarAlpha > 0 {
drawThresholdBar(in: &context, size: size,
alpha: world.thresholdBarAlpha)
}
if world.convergedAlpha > 0 {
drawConvergedBadge(in: &context, size: size,
alpha: world.convergedAlpha)
}
// Vertices.
for vertex in snap.vertices {
guard let pos = layout.positions[vertex.digestHex] else { continue }
let role = dm.castRole(for: vertex.processIdHex)
let isForked = forkedSet.contains(vertex.digestHex)
drawPerceptionTowers(in: &context, size: size, world: world)
drawBeatTag(in: &context, size: size, world: world)
}
let r: CGFloat = 7 + CGFloat(min(vertex.weight, 8)) * 0.5
let rect = CGRect(x: pos.x - r, y: pos.y - r, width: r * 2, height: r * 2)
let baseColor = isForked ? Color.red : role.color
let alpha: Double = sceneIndex == 1 && isForked ? 0.4 : 0.85
context.fill(Circle().path(in: rect), with: .color(baseColor.opacity(alpha)))
// MARK: - Geometry / lookups
if isForked {
// Pulsing red halo around forked vertices.
let pulse = 0.5 + 0.5 * sin(time * 3 + Double(vertex.weight))
let haloR = r * 1.9 * pulse
let haloRect = CGRect(x: pos.x - haloR, y: pos.y - haloR,
width: haloR * 2, height: haloR * 2)
context.stroke(Circle().path(in: haloRect),
with: .color(.red.opacity(0.4 * pulse)), lineWidth: 1.5)
private func castLaneY(_ laneIdx: Int, size: CGSize) -> CGFloat {
let margin: CGFloat = 60
let nodeCount: CGFloat = 7
let laneHeight = (size.height - 2 * margin) / nodeCount
return margin + (CGFloat(laneIdx) + 0.5) * laneHeight
}
// Scene 1: X out the forked vertex (banned).
if sceneIndex == 1 {
let banAppear = min(1, time / 1.5)
let xLen: CGFloat = r * 1.3 * CGFloat(banAppear)
var x1 = Path()
x1.move(to: CGPoint(x: pos.x - xLen, y: pos.y - xLen))
x1.addLine(to: CGPoint(x: pos.x + xLen, y: pos.y + xLen))
var x2 = Path()
x2.move(to: CGPoint(x: pos.x + xLen, y: pos.y - xLen))
x2.addLine(to: CGPoint(x: pos.x - xLen, y: pos.y + xLen))
context.stroke(x1, with: .color(.red.opacity(0.95)), lineWidth: 2.5)
context.stroke(x2, with: .color(.red.opacity(0.95)), lineWidth: 2.5)
private func castPosition(cast: Ch01Cast, size: CGSize) -> CGPoint {
let laneIdx: Int
switch cast {
case .aaron: laneIdx = 0
case .ben: laneIdx = 1
case .carl: laneIdx = 2
case .dave: laneIdx = 3
}
return CGPoint(x: size.width * 0.20, y: castLaneY(laneIdx, size: size))
}
private func castColor(_ cast: Ch01Cast) -> Color {
switch cast {
case .aaron: return Cast.coral
case .ben: return Cast.teal
case .carl: return Cast.amber
case .dave: return Cast.violet
}
}
private func authorOf(_ mid: String) -> Ch01Cast {
if mid.hasPrefix("ζ") { return .dave }
if let m = Ch01Timeline.messages[mid] { return m.author }
if let m = Ch02Timeline.messages[mid] { return m.author }
return .aaron
}
private func hashOf(_ mid: String) -> String {
if let info = Ch09Timeline.forkVersions[mid] { return info.hashShort }
if let m = Ch01Timeline.messages[mid] { return m.hashShort }
if let m = Ch02Timeline.messages[mid] { return m.hashShort }
return "????"
}
private static let initialMessages: [String] = ["α", "β", "γ", "δ", "ε"]
// MARK: - Lanes
private func drawLanes(in context: inout GraphicsContext, size: CGSize) {
let casts: [(Ch01Cast, Int)] = [(.aaron, 0), (.ben, 1), (.carl, 2), (.dave, 3)]
for (cast, idx) in casts {
let y = castLaneY(idx, size: size)
var path = Path()
path.move(to: CGPoint(x: 36, y: y))
path.addLine(to: CGPoint(x: size.width - 24, y: y))
context.stroke(path, with: .color(castColor(cast).opacity(0.18)),
style: StrokeStyle(lineWidth: 0.8, dash: [4, 6]))
context.draw(
Text(cast.role.displayName.capitalized)
.font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced))
.foregroundColor(castColor(cast).opacity(0.75)),
at: CGPoint(x: 24, y: y),
anchor: .leading
)
}
}
// MARK: - Cast figures
private func drawCastFigures(
in context: inout GraphicsContext, size: CGSize, t: Double
) {
for cast in Ch01Cast.allCases {
let pos = castPosition(cast: cast, size: size)
let r: CGFloat = 26
let color = castColor(cast)
let haloR = r * 1.5
context.fill(
Circle().path(in: CGRect(x: pos.x - haloR, y: pos.y - haloR,
width: haloR * 2, height: haloR * 2)),
with: .color(color.opacity(0.10))
)
context.fill(
Circle().path(in: CGRect(x: pos.x - r, y: pos.y - r,
width: r * 2, height: r * 2)),
with: .color(color.opacity(0.95))
)
context.stroke(
Circle().path(in: CGRect(x: pos.x - r, y: pos.y - r,
width: r * 2, height: r * 2)),
with: .color(.white.opacity(0.5)), lineWidth: 1.5
)
context.draw(
Text(String(cast.role.displayName.prefix(1)))
.font(.system(size: settings.scaled(18), weight: .heavy, design: .monospaced))
.foregroundColor(.white),
at: pos
)
context.draw(
Text(cast.role.displayName.uppercased())
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
.foregroundColor(color.opacity(0.95)),
at: CGPoint(x: pos.x, y: pos.y + r + 12)
)
}
}
// MARK: - Accepted (carry-forward) vertices on each lane
private func drawAcceptedVertices(
in context: inout GraphicsContext, size: CGSize, world: Ch09WorldState
) {
let casts: [(Ch01Cast, Int)] = [(.aaron, 0), (.ben, 1), (.carl, 2), (.dave, 3)]
for (cast, laneIdx) in casts {
let lane = castLaneY(laneIdx, size: size)
let castX = castPosition(cast: cast, size: size).x
let firstX = castX + 70
let gap: CGFloat = 56
for (i, mid) in Self.initialMessages.enumerated() {
let x = firstX + CGFloat(i) * gap
if x > size.width - 60 { break }
drawAcceptedVertex(in: &context,
at: CGPoint(x: x, y: lane),
messageId: mid)
}
}
}
private func drawAcceptedVertex(
in context: inout GraphicsContext, at pos: CGPoint,
messageId: String
) {
let r: CGFloat = 13
let color = castColor(authorOf(messageId))
let rect = CGRect(x: pos.x - r, y: pos.y - r, width: r * 2, height: r * 2)
context.fill(Circle().path(in: rect),
with: .color(color.opacity(0.85)))
context.stroke(Circle().path(in: rect),
with: .color(.white.opacity(0.55)), lineWidth: 1.0)
context.draw(
Text(messageId)
.font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced))
.foregroundColor(.white),
at: pos
)
}
// MARK: - Dave's forks
private func drawDaveForks(
in context: inout GraphicsContext, size: CGSize,
world: Ch09WorldState, t: Double
) {
let lane = castLaneY(3, size: size)
let castX = castPosition(cast: .dave, size: size).x
// Forks sit at the right end of Dave's accepted-vertex row,
// visually past ε.
let baseX = castX + 70 + CGFloat(Self.initialMessages.count) * 56
let forkGap: CGFloat = 56
for (i, vid) in world.forksOnDaveLane.enumerated() {
let pos = CGPoint(x: baseX + CGFloat(i) * forkGap, y: lane)
let r: CGFloat = 16
let pulse: CGFloat = world.daveBanned ? 1.0 : 1.0 + 0.05 * CGFloat(sin(t * 4))
let rr = r * pulse
// Outer red fork ring
let ringR: CGFloat = rr + 4
context.stroke(
Circle().path(in: CGRect(x: pos.x - ringR, y: pos.y - ringR,
width: ringR * 2, height: ringR * 2)),
with: .color(.red.opacity(0.85)), lineWidth: 2.4
)
// Inner Dave-violet fill
context.fill(
Circle().path(in: CGRect(x: pos.x - rr, y: pos.y - rr,
width: rr * 2, height: rr * 2)),
with: .color(Cast.violet.opacity(world.daveBanned ? 0.45 : 0.95))
)
context.stroke(
Circle().path(in: CGRect(x: pos.x - rr, y: pos.y - rr,
width: rr * 2, height: rr * 2)),
with: .color(.white.opacity(0.55)), lineWidth: 1.2
)
context.draw(
Text(vid)
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
.foregroundColor(.white.opacity(world.daveBanned ? 0.6 : 1.0)),
at: pos
)
context.draw(
Text(hashOf(vid))
.font(.system(size: settings.scaled(8), weight: .regular, design: .monospaced))
.foregroundColor(.white.opacity(0.5)),
at: CGPoint(x: pos.x, y: pos.y + rr + 8)
)
// Big red X if Dave is banned
if world.daveBanned {
let xr: CGFloat = ringR + 2
var xPath = Path()
xPath.move(to: CGPoint(x: pos.x - xr, y: pos.y - xr))
xPath.addLine(to: CGPoint(x: pos.x + xr, y: pos.y + xr))
xPath.move(to: CGPoint(x: pos.x - xr, y: pos.y + xr))
xPath.addLine(to: CGPoint(x: pos.x + xr, y: pos.y - xr))
context.stroke(xPath,
with: .color(.red.opacity(0.95)), lineWidth: 3.0)
}
}
}
// MARK: - Thought / composing / flight
private func drawThoughtBubble(
in context: inout GraphicsContext, size: CGSize,
thought: Ch09WorldState.Ch09Thought
) {
let pos = castPosition(cast: thought.cast, size: size)
let bubbleW: CGFloat = max(140, CGFloat(thought.label.count) * 7 + 24)
let bubbleH: CGFloat = 36
let bubbleRect = CGRect(
x: pos.x - bubbleW / 2,
y: pos.y - 80 - bubbleH,
width: bubbleW, height: bubbleH
)
let color = castColor(thought.cast)
context.fill(RoundedRectangle(cornerRadius: 18).path(in: bubbleRect),
with: .color(.black.opacity(0.78)))
context.stroke(RoundedRectangle(cornerRadius: 18).path(in: bubbleRect),
with: .color(color.opacity(0.85)), lineWidth: 1.4)
context.draw(
Text(thought.label)
.font(.system(size: settings.scaled(11), weight: .medium, design: .default))
.foregroundColor(.white.opacity(0.92))
.italic(),
at: CGPoint(x: bubbleRect.midX, y: bubbleRect.midY)
)
}
private func drawComposingSlot(
in context: inout GraphicsContext, size: CGSize,
composing: Ch09WorldState.Ch09Composing
) {
guard let info = Ch09Timeline.forkVersions[composing.versionId] else { return }
let authorPos = castPosition(cast: .dave, size: size)
let boxW: CGFloat = min(540, size.width - 80)
let boxRect = CGRect(x: size.width / 2 - boxW / 2, y: 16,
width: boxW, height: 110)
var connector = Path()
connector.move(to: CGPoint(x: boxRect.midX, y: boxRect.maxY))
connector.addLine(to: CGPoint(x: authorPos.x, y: authorPos.y - 36))
context.stroke(connector,
with: .color(Cast.violet.opacity(0.45)),
style: StrokeStyle(lineWidth: 1.4, dash: [3, 4]))
context.fill(RoundedRectangle(cornerRadius: 10).path(in: boxRect),
with: .color(.black.opacity(0.88)))
// Red ring on the box to flag this is a fork
context.stroke(RoundedRectangle(cornerRadius: 10).path(in: boxRect),
with: .color(.red.opacity(0.95)), lineWidth: 1.8)
context.draw(
Text("✎ DAVE WRITING \(info.label) (FORK)")
.font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced))
.foregroundColor(.red.opacity(0.95)),
at: CGPoint(x: boxRect.minX + 14, y: boxRect.minY + 14),
anchor: .leading
)
context.draw(
Text(info.claim)
.font(.system(size: settings.scaled(11), weight: .regular, design: .monospaced))
.foregroundColor(.white.opacity(0.88)),
at: CGPoint(x: boxRect.minX + 14, y: boxRect.minY + 36),
anchor: .leading
)
context.draw(
Text("parents: ε")
.font(.system(size: settings.scaled(11), weight: .regular, design: .monospaced))
.foregroundColor(.white.opacity(0.88)),
at: CGPoint(x: boxRect.minX + 14, y: boxRect.minY + 54),
anchor: .leading
)
if composing.sealed {
context.draw(
Text("hash: \(info.hashShort)… ✓")
.font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced))
.foregroundColor(Cast.violet.opacity(0.95)),
at: CGPoint(x: boxRect.minX + 14, y: boxRect.minY + 72),
anchor: .leading
)
}
}
private func drawForkFlight(
in context: inout GraphicsContext, size: CGSize,
flight: Ch09WorldState.Ch09Flight
) {
let lift: CGFloat = 36
let from = castPosition(cast: flight.from, size: size)
let to = castPosition(cast: flight.to, size: size)
let fromTrack = CGPoint(x: from.x, y: from.y - lift)
let toTrack = CGPoint(x: to.x, y: to.y - lift)
var path = Path()
path.move(to: fromTrack)
path.addLine(to: toTrack)
context.stroke(path,
with: .color(.red.opacity(0.25)),
style: StrokeStyle(lineWidth: 1.0, dash: [3, 5]))
let p = CGFloat(flight.progress)
let pos = CGPoint(x: fromTrack.x + (toTrack.x - fromTrack.x) * p,
y: fromTrack.y + (toTrack.y - fromTrack.y) * p)
let envW: CGFloat = 80
let envH: CGFloat = 30
let rect = CGRect(x: pos.x - envW / 2, y: pos.y - envH / 2,
width: envW, height: envH)
context.fill(RoundedRectangle(cornerRadius: 5).path(in: rect),
with: .color(Cast.violet.opacity(0.95)))
context.stroke(RoundedRectangle(cornerRadius: 5).path(in: rect),
with: .color(.red.opacity(0.85)), lineWidth: 1.4)
context.draw(
Text("\(flight.versionId) · \(hashOf(flight.versionId))")
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
.foregroundColor(.white),
at: pos
)
}
// MARK: - Fork detected / threshold / converged
private func drawForkDetected(
in context: inout GraphicsContext, size: CGSize, alpha: Double
) {
let cy: CGFloat = 60
let label = "⚠ FORK DETECTED — same Dave identity, two different bodies"
context.draw(
Text(label)
.font(.system(size: settings.scaled(13), weight: .heavy, design: .monospaced))
.foregroundColor(.red.opacity(0.95 * alpha)),
at: CGPoint(x: size.width / 2, y: cy)
)
}
private func drawThresholdBar(
in context: inout GraphicsContext, size: CGSize, alpha: Double
) {
// f<n/3 visual: a small bar showing 1 byzantine / 4 total.
let cy: CGFloat = 92
let barW: CGFloat = 280
let barH: CGFloat = 18
let barX = size.width / 2 - barW / 2
let rect = CGRect(x: barX, y: cy - barH / 2, width: barW, height: barH)
context.stroke(RoundedRectangle(cornerRadius: 4).path(in: rect),
with: .color(.white.opacity(0.45 * alpha)), lineWidth: 1.0)
// Threshold: 1/3 of bar marked at 33% line
let threshFrac = CGFloat(1.0 / 3.0)
let threshX = barX + threshFrac * barW
var threshLine = Path()
threshLine.move(to: CGPoint(x: threshX, y: rect.minY - 4))
threshLine.addLine(to: CGPoint(x: threshX, y: rect.maxY + 4))
context.stroke(threshLine,
with: .color(.yellow.opacity(0.85 * alpha)),
style: StrokeStyle(lineWidth: 1.4, dash: [3, 3]))
// Filled portion: 1/4 = 25% (one byzantine of four)
let fillFrac = CGFloat(1.0 / 4.0)
let fillRect = CGRect(x: barX, y: rect.minY,
width: fillFrac * barW, height: barH)
context.fill(RoundedRectangle(cornerRadius: 4).path(in: fillRect),
with: .color(.green.opacity(0.7 * alpha)))
context.draw(
Text("f = 1, n = 4 · 3f = 3 < n = 4 · safety holds")
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
.foregroundColor(.white.opacity(0.9 * alpha)),
at: CGPoint(x: rect.midX, y: rect.maxY + 14)
)
}
private func drawConvergedBadge(
in context: inout GraphicsContext, size: CGSize, alpha: Double
) {
context.draw(
Text("✓ AARON · BEN · CARL CONVERGE — Dave's weight wasted")
.font(.system(size: settings.scaled(13), weight: .heavy, design: .monospaced))
.foregroundColor(.green.opacity(0.95 * alpha)),
at: CGPoint(x: size.width / 2, y: size.height - 50)
)
}
// MARK: - Perception towers
private func drawPerceptionTowers(
in context: inout GraphicsContext, size: CGSize, world: Ch09WorldState
) {
let casts: [Ch01Cast] = [.aaron, .ben, .carl, .dave]
let blockH: CGFloat = 18
let blockGap: CGFloat = 3
let maxBlocks = 7 // up to 5 honest + 2 fork versions
let towerH: CGFloat = CGFloat(maxBlocks) * (blockH + blockGap) + 28
let baseY: CGFloat = size.height - 70
let towerW: CGFloat = 110
let totalW = CGFloat(casts.count) * towerW + CGFloat(casts.count - 1) * 24
let startX = (size.width - totalW) / 2
for (i, cast) in casts.enumerated() {
let towerX = startX + CGFloat(i) * (towerW + 24)
let towerCenter = towerX + towerW / 2
let color = castColor(cast)
context.draw(
Text(cast.role.displayName.uppercased())
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
.foregroundColor(color.opacity(0.85)),
at: CGPoint(x: towerCenter, y: baseY - towerH + 4)
)
context.draw(
Text("VIEW")
.font(.system(size: settings.scaled(8), weight: .regular, design: .monospaced))
.foregroundColor(.white.opacity(0.35)),
at: CGPoint(x: towerCenter, y: baseY - towerH + 18)
)
var baseline = Path()
baseline.move(to: CGPoint(x: towerX, y: baseY))
baseline.addLine(to: CGPoint(x: towerX + towerW, y: baseY))
context.stroke(baseline, with: .color(color.opacity(0.45)),
lineWidth: 1.2)
for railX in [towerX, towerX + towerW] {
var rail = Path()
rail.move(to: CGPoint(x: railX, y: baseY))
rail.addLine(to: CGPoint(x: railX, y: baseY - towerH + 26))
context.stroke(rail, with: .color(color.opacity(0.18)),
style: StrokeStyle(lineWidth: 0.8, dash: [3, 4]))
}
let order = world.views[cast] ?? []
for (j, mid) in order.enumerated() {
let blockY = baseY - CGFloat(j + 1) * (blockH + blockGap)
let rect = CGRect(x: towerX + 6, y: blockY,
width: towerW - 12, height: blockH)
let isFork = mid.hasPrefix("ζ")
let blockColor = castColor(authorOf(mid))
context.fill(RoundedRectangle(cornerRadius: 5).path(in: rect),
with: .color(blockColor.opacity(world.daveBanned && isFork ? 0.30 : 0.88)))
context.stroke(RoundedRectangle(cornerRadius: 5).path(in: rect),
with: .color(isFork ? .red.opacity(0.85) : .white.opacity(0.45)),
lineWidth: isFork ? 1.6 : 1.0)
context.draw(
Text(mid)
.font(.system(size: settings.scaled(9), weight: .heavy, design: .monospaced))
.foregroundColor(.white.opacity(world.daveBanned && isFork ? 0.55 : 1.0)),
at: CGPoint(x: rect.midX, y: rect.midY)
)
if world.daveBanned && isFork {
var x = Path()
x.move(to: CGPoint(x: rect.minX + 4, y: rect.minY + 4))
x.addLine(to: CGPoint(x: rect.maxX - 4, y: rect.maxY - 4))
x.move(to: CGPoint(x: rect.minX + 4, y: rect.maxY - 4))
x.addLine(to: CGPoint(x: rect.maxX - 4, y: rect.minY + 4))
context.stroke(x, with: .color(.red.opacity(0.9)),
lineWidth: 1.5)
}
}
if vertex.isLast && !isForked && sceneIndex == 1 {
context.stroke(Circle().path(in: rect.insetBy(dx: -2, dy: -2)),
with: .color(.green.opacity(0.6)), lineWidth: 1.6)
}
}
// Scene-specific bottom panel
switch sceneIndex {
case 0:
renderScene0Bottom(in: &context, size: size, time: time,
forkedCount: forkedVertices.count, daveTotal: daveVertices.count)
case 1:
renderScene1Bottom(in: &context, size: size, time: time, sim: sim,
forkedCount: forkedVertices.count)
default: break
}
// Top header reads the same in both scenes anchors the chapter.
context.draw(
Text("DAVE LIES — CRISIS CATCHES HIM")
.font(.system(size: settings.scaled(13), weight: .heavy, design: .monospaced))
.foregroundColor(.red.opacity(0.55))
.kerning(2),
at: CGPoint(x: size.width / 2, y: 28)
)
}
// MARK: - Scene 0: forks revealed
// MARK: - Beat tag
private func renderScene0Bottom(
in context: inout GraphicsContext, size: CGSize, time: Double,
forkedCount: Int, daveTotal: Int
private func drawBeatTag(
in context: inout GraphicsContext, size: CGSize, world: Ch09WorldState
) {
let bandY = size.height * 0.7
let appear = min(1.0, time * 0.4)
guard let beatId = world.activeBeat?.id else { return }
context.draw(
Text("DAVE'S FORKS — VIOLET LANE, RED RINGS")
.font(.system(size: settings.scaled(12), weight: .heavy, design: .monospaced))
.foregroundColor(.red.opacity(0.7 * appear)),
at: CGPoint(x: size.width / 2, y: bandY)
)
// Stats row
let stats: [(String, String, Color)] = [
("DAVE TOTAL", "\(daveTotal) MSGS", Cast.violet),
("CONFLICTS", "\(forkedCount)", .red),
("STRATEGY", "FORK SAME ID, DIFFERENT PARENTS", .orange)
]
let pillW: CGFloat = 220
let pillH: CGFloat = 46
let totalW = pillW * CGFloat(stats.count) + 24 * CGFloat(stats.count - 1)
let startX = (size.width - totalW) / 2
for (i, stat) in stats.enumerated() {
let appearI = max(0, min(1, time * 0.4 - Double(i) * 0.5))
if appearI < 0.05 { continue }
let x = startX + CGFloat(i) * (pillW + 24)
let rect = CGRect(x: x, y: bandY + 26, width: pillW, height: pillH)
context.fill(RoundedRectangle(cornerRadius: 8).path(in: rect),
with: .color(.black.opacity(0.55 * appearI)))
context.stroke(RoundedRectangle(cornerRadius: 8).path(in: rect),
with: .color(stat.2.opacity(0.7 * appearI)),
lineWidth: 1.5)
context.draw(
Text(stat.0)
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
.foregroundColor(stat.2.opacity(0.95 * appearI)),
at: CGPoint(x: rect.midX, y: rect.minY + 14)
)
context.draw(
Text(stat.1)
.font(.system(size: settings.scaled(11), weight: .bold, design: .monospaced))
.foregroundColor(.white.opacity(0.95 * appearI)),
at: CGPoint(x: rect.midX, y: rect.minY + 32)
)
}
let hintAppear = max(0, min(1, time * 0.3 - 1.5))
context.draw(
Text("EACH FORKED VERTEX SHARES DAVE'S ID BUT POINTS AT DIFFERENT PARENTS — TRYING TO TRICK AARON & BEN INTO DISAGREEING")
.font(.system(size: settings.scaled(10), weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.55 * hintAppear)),
at: CGPoint(x: size.width / 2, y: size.height - 36)
)
}
// MARK: - Scene 1: protocol routes around
private func renderScene1Bottom(
in context: inout GraphicsContext, size: CGSize, time: Double,
sim: SimulationData, forkedCount: Int
) {
let bandY = size.height * 0.7
// Threshold bar.
let n = sim.nodes.count
let f = sim.nodes.filter { $0.isByzantine }.count
let appear = min(1.0, time * 0.3)
context.draw(
Text("BYZANTINE RESILIENCE — f < n/3")
.font(.system(size: settings.scaled(13), weight: .heavy, design: .monospaced))
.foregroundColor(.green.opacity(0.7 * appear))
.kerning(1.5),
at: CGPoint(x: size.width / 2, y: bandY)
)
let barW: CGFloat = size.width * 0.55
let barH: CGFloat = 14
let barX = (size.width - barW) / 2
let barY = bandY + 32
// Track
let bgRect = CGRect(x: barX, y: barY, width: barW, height: barH)
context.fill(RoundedRectangle(cornerRadius: 7).path(in: bgRect),
with: .color(.white.opacity(0.06 * appear)))
// Byzantine fill.
let byzFrac = Double(f) / Double(n)
let fillRect = CGRect(x: barX, y: barY, width: barW * byzFrac, height: barH)
context.fill(RoundedRectangle(cornerRadius: 7).path(in: fillRect),
with: .color(.red.opacity(0.55 * appear)))
// Threshold marker at 1/3.
let threshX = barX + barW * (1.0 / 3.0)
var threshLine = Path()
threshLine.move(to: CGPoint(x: threshX, y: barY - 5))
threshLine.addLine(to: CGPoint(x: threshX, y: barY + barH + 5))
context.stroke(threshLine, with: .color(.green.opacity(0.85 * appear)), lineWidth: 2.5)
context.draw(
Text("1/3 THRESHOLD")
.font(.system(size: settings.scaled(9), weight: .heavy, design: .monospaced))
.foregroundColor(.green.opacity(0.7 * appear)),
at: CGPoint(x: threshX, y: barY + barH + 16)
)
context.draw(
Text("\(f)/\(n) BYZANTINE = \(String(format: "%.1f", byzFrac * 100))%")
.font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced))
.foregroundColor(.white.opacity(0.85 * appear)),
at: CGPoint(x: barX + barW / 2, y: barY - 14)
)
// Defense line.
let defenseAppear = max(0, min(1, time * 0.3 - 1.5))
context.draw(
Text("\(forkedCount) FORKS DETECTED · ✓ DAVE'S VERTICES BANNED · ✓ AARON·BEN·CARL CONVERGE")
.font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced))
.foregroundColor(.green.opacity(0.7 * defenseAppear)),
at: CGPoint(x: size.width / 2, y: barY + barH + 44)
)
let footerAppear = max(0, min(1, time * 0.3 - 2.5))
context.draw(
Text("CRISIS GUARANTEES TOTAL ORDER WHENEVER FEWER THAN ONE-THIRD OF VALIDATORS LIE.")
.font(.system(size: settings.scaled(10), weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.55 * footerAppear)),
at: CGPoint(x: size.width / 2, y: size.height - 36)
Text(beatId)
.font(.system(size: settings.scaled(8), weight: .regular, design: .monospaced))
.foregroundColor(.white.opacity(0.20)),
at: CGPoint(x: size.width - 14, y: 10),
anchor: .trailing
)
}
}

View file

@ -0,0 +1,274 @@
import SwiftUI
/// Ch09 "Dave lies. Crisis catches him."
///
/// Dave produces two conflicting messages under the same identity,
/// sends one to Aaron and a different one to Ben, and tries to make
/// the honest validators disagree about what they saw. The protocol
/// catches him: as soon as Aaron and Ben gossip with each other, a
/// fork is detected, Dave's vertices are banned, and the honest 3
/// converge without him.
// MARK: - Types
enum Ch09BeatKind {
case settle(label: String)
case carryForward
case think(Ch01Cast, label: String)
case forkCompose(versionId: String) // composing one of the two conflicting messages
case forkSeal(versionId: String) // both forks now visible on Dave's lane
case forkSend(versionId: String, to: Ch01Cast)
case forkAccept(at: Ch01Cast, versionId: String)
case gossipExchange(from: Ch01Cast, to: Ch01Cast) // Aaron sends his ζ_a to Ben
case forkDetected // "FORK DETECTED" badge
case banDave // red X across Dave's forks + lane
case convergeWithoutDave // honest 3 converge, Dave's weight wasted
case thresholdBar // f<n/3 visualization
}
struct Ch09Beat: Identifiable {
let id: String
let kind: Ch09BeatKind
let durationSeconds: Double
let narration: String
var startTime: Double = 0
var endTime: Double { startTime + durationSeconds }
}
struct Ch09WorldState {
/// Honest-cast tower contents carry-forward from Ch03's converged
/// state {α, β, γ, δ, ε}, plus whatever Dave-fork they accepted.
var views: [Ch01Cast: [String]] = [:]
/// Both fork versions: ζ_a and ζ_b. Once a `forkSeal` beat fires,
/// the corresponding version is in `forksOnDaveLane`.
var forksOnDaveLane: [String] = []
/// Composing animation for the current fork being written.
var composing: Ch09Composing? = nil
/// In-flight fork envelope, if active.
var inFlight: Ch09Flight? = nil
/// "FORK DETECTED" overlay opacity 0..1.
var forkDetectedAlpha: Double = 0
/// Once Dave's vertices are banned, this flag flips.
var daveBanned: Bool = false
/// The threshold bar (f<n/3) appears in scene 1.
var thresholdBarAlpha: Double = 0
/// Convergence flag show "AARON · BEN · CARL CONVERGE" badge.
var convergedAlpha: Double = 0
/// Thought bubble.
var thought: Ch09Thought? = nil
var activeBeat: Ch09Beat? = nil
var activeProgress: Double = 0
struct Ch09Composing {
let versionId: String
var sealed: Bool
}
struct Ch09Flight {
let versionId: String
let from: Ch01Cast
let to: Ch01Cast
let progress: Double
}
struct Ch09Thought {
let cast: Ch01Cast
let label: String
}
}
// MARK: - Timeline
enum Ch09Timeline {
/// Two conflicting fork versions Dave writes.
static let forkVersions: [String: (label: String, claim: String, hashShort: String)] = [
"ζ_a": ("ζ_a", "claims: send 50 BTC to Aaron", "f1aa"),
"ζ_b": ("ζ_b", "claims: send 50 BTC to Charlie", "f1bb"),
]
static let beats: [Ch09Beat] = {
let raw: [Ch09Beat] = [
.init(id: "carry-forward", kind: .carryForward, durationSeconds: 4.0,
narration: "By now Aaron, Ben, Carl and Dave all hold the same converged set of messages from earlier chapters. The graph has been clean. But what if a player tries to lie?"),
.init(id: "dave-thinks", kind: .think(.dave, label: "I'll send different things to different people."),
durationSeconds: 4.5,
narration: "Dave decides to attack. He'll write TWO messages with the SAME identity — same author, same parent set — but different content. He'll send one to Aaron and the other to Ben. If they trust him, they'll disagree about what Dave actually said."),
// Compose ζ_a
.init(id: "compose-zeta-a", kind: .forkCompose(versionId: "ζ_a"),
durationSeconds: 5.0,
narration: "First fork: ζ_a. Body: 'send 50 BTC to Aaron'. Parents: ε. Hash: f1aa."),
.init(id: "seal-zeta-a", kind: .forkSeal(versionId: "ζ_a"),
durationSeconds: 3.0,
narration: "ζ_a is sealed. A red-ringed vertex appears on Dave's lane — the ring is the visual cue that this vertex is part of a fork."),
// Compose ζ_b
.init(id: "compose-zeta-b", kind: .forkCompose(versionId: "ζ_b"),
durationSeconds: 5.0,
narration: "Second fork: ζ_b. SAME identity (Dave), SAME parent set (ε), but DIFFERENT body: 'send 50 BTC to Charlie'. Hash: f1bb. This is the lie."),
.init(id: "seal-zeta-b", kind: .forkSeal(versionId: "ζ_b"),
durationSeconds: 3.5,
narration: "ζ_b is sealed. A SECOND red-ringed vertex appears on Dave's lane, right next to ζ_a. Now Dave has two contradictory messages with the same identity. Watch what he does next."),
// Send ζ_a to Aaron, ζ_b to Ben
.init(id: "send-zeta-a-aaron", kind: .forkSend(versionId: "ζ_a", to: .aaron),
durationSeconds: 6.0,
narration: "Dave sends ζ_a to Aaron — and only to Aaron."),
.init(id: "aaron-accepts-zeta-a", kind: .forkAccept(at: .aaron, versionId: "ζ_a"),
durationSeconds: 3.0,
narration: "Aaron has no reason yet to suspect anything. ζ_a is signed by Dave, references a parent he knows, hashes correctly. Aaron accepts. His tower grows by ζ_a."),
.init(id: "send-zeta-b-ben", kind: .forkSend(versionId: "ζ_b", to: .ben),
durationSeconds: 6.0,
narration: "Dave sends ζ_b to Ben — and only to Ben."),
.init(id: "ben-accepts-zeta-b", kind: .forkAccept(at: .ben, versionId: "ζ_b"),
durationSeconds: 3.5,
narration: "Ben also has no reason to suspect. ζ_b is signed, parent matches, hash matches. Ben accepts ζ_b. Now Aaron and Ben hold DIFFERENT Dave-versions."),
.init(id: "scene-0-end", kind: .settle(label: "Two stories from one liar"),
durationSeconds: 4.0,
narration: "Look at Aaron's tower — it has ζ_a. Ben's tower has ζ_b. Same author, different content. Dave has succeeded in splitting their views. For exactly this moment."),
// Scene 1: gossip exchange fork detected ban converge
.init(id: "gossip-aaron-ben", kind: .gossipExchange(from: .aaron, to: .ben),
durationSeconds: 6.0,
narration: "Now Aaron and Ben gossip. Aaron forwards his copy of Dave's message — ζ_a — to Ben."),
.init(id: "fork-detected", kind: .forkDetected,
durationSeconds: 5.5,
narration: "Ben already has ζ_b from Dave. Now ζ_a arrives from Aaron. SAME author, SAME parent, DIFFERENT content. Ben's verifier flags this immediately: a fork. No vote, no committee — just arithmetic on two signed messages."),
.init(id: "threshold-bar", kind: .thresholdBar,
durationSeconds: 5.0,
narration: "How does Crisis tolerate this? f < n/3. One byzantine out of four is f=1, n=4. 3f = 3 < 4 = n. The protocol's safety threshold holds — Dave alone cannot break consensus."),
.init(id: "ban-dave", kind: .banDave,
durationSeconds: 5.5,
narration: "Both Dave-vertices get banned — a red X across each. Total order routes around them. Dave's PoW weight is wasted. The honest validators continue without him."),
.init(id: "converge", kind: .convergeWithoutDave,
durationSeconds: 6.0,
narration: "Aaron, Ben and Carl now agree. They're missing none of each other's messages. Dave's two forks are explicitly excluded. Convergence holds — even with a liar in the room."),
.init(id: "outro", kind: .settle(label: "Crisis catches the liar"),
durationSeconds: 4.0,
narration: "That is Byzantine resilience under f < n/3. The system tolerates one liar; if more than a third of validators were byzantine, all bets are off. With one — exactly one — Crisis routes around them and converges anyway. Done."),
]
var t: Double = 0
var assigned: [Ch09Beat] = []
for var b in raw {
b.startTime = t
assigned.append(b)
t += b.durationSeconds
}
return assigned
}()
static var totalDuration: Double {
beats.last.map { $0.endTime } ?? 0
}
static func activeBeat(at t: Double) -> Ch09Beat? {
let clamped = max(0, min(t, totalDuration))
return beats.first { $0.startTime <= clamped && clamped < $0.endTime }
?? beats.last
}
static func state(at t: Double) -> Ch09WorldState {
var w = Ch09WorldState()
// Carry-forward: every cast starts with {α, β, γ, δ, ε}.
let initial = ["α", "β", "γ", "δ", "ε"]
for cast in Ch01Cast.allCases {
w.views[cast] = initial
}
let clamped = max(0, min(t, totalDuration))
for beat in beats {
if clamped < beat.startTime { break }
let isActive = clamped < beat.endTime
let progress = isActive
? max(0, min(1, (clamped - beat.startTime) / beat.durationSeconds))
: 1.0
apply(beat, progress: progress, isActive: isActive, into: &w)
if isActive {
w.activeBeat = beat
w.activeProgress = progress
}
}
return w
}
private static func apply(
_ beat: Ch09Beat, progress: Double, isActive: Bool,
into w: inout Ch09WorldState
) {
switch beat.kind {
case .settle, .carryForward:
break
case .think(let cast, let label):
if isActive { w.thought = .init(cast: cast, label: label) }
case .forkCompose(let vid):
if w.composing?.versionId != vid {
w.composing = .init(versionId: vid, sealed: false)
}
case .forkSeal(let vid):
if !w.forksOnDaveLane.contains(vid) {
w.forksOnDaveLane.append(vid)
}
if !isActive { w.composing = nil }
else { w.composing = .init(versionId: vid, sealed: true) }
case .forkSend(let vid, let to):
if isActive {
w.inFlight = .init(versionId: vid, from: .dave,
to: to, progress: progress)
}
case .forkAccept(let at, let vid):
if !w.views[at, default: []].contains(vid) {
w.views[at, default: []].append(vid)
}
case .gossipExchange(let from, let to):
// Animate Aaron sending his ζ_a to Ben.
if isActive {
w.inFlight = .init(versionId: "ζ_a", from: from,
to: to, progress: progress)
} else {
// Permanent: Ben now also has ζ_a (in addition to ζ_b).
if !w.views[to, default: []].contains("ζ_a") {
w.views[to, default: []].append("ζ_a")
}
}
case .forkDetected:
w.forkDetectedAlpha = isActive ? progress : 1.0
case .thresholdBar:
w.thresholdBarAlpha = isActive ? progress : 1.0
case .banDave:
w.daveBanned = true
case .convergeWithoutDave:
w.convergedAlpha = isActive ? progress : 1.0
}
}
}
// MARK: - Scene mapping
enum Ch09Scenes {
/// 2 scenes, total ~79.5s. Scene 0 = Dave creates the fork; Scene 1
/// = detection + threshold + ban + convergence.
static let sceneStarts: [Double] = [0, 47.5]
static let sceneDurations: [Double] = [47.5, 32.0]
static func timelineT(sceneIndex: Int, localTime: Double) -> Double {
let idx = max(0, min(sceneIndex, sceneStarts.count - 1))
return sceneStarts[idx] + localTime
}
static func durationFor(scene: Int) -> Double {
let idx = max(0, min(scene, sceneDurations.count - 1))
return sceneDurations[idx]
}
static func narrationAt(sceneIndex: Int, localTime: Double) -> String {
let t = timelineT(sceneIndex: sceneIndex, localTime: localTime)
return Ch09Timeline.activeBeat(at: t)?.narration ?? ""
}
}

View file

@ -69,6 +69,9 @@ final class SceneEngine {
SceneAddress(chapter: 3, scene: 0): 23.5,
SceneAddress(chapter: 3, scene: 1): 20.5,
SceneAddress(chapter: 3, scene: 2): 28.0,
// Ch09 Byzantine (2 scenes mapping to Ch09Timeline windows)
SceneAddress(chapter: 9, scene: 0): 47.5,
SceneAddress(chapter: 9, scene: 1): 32.0,
]
/// Effective duration for the current scene, honoring overrides.

View file

@ -164,6 +164,8 @@ struct ImmersiveView: View {
return Ch02Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
case 3:
return Ch03Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
case 9:
return Ch09Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
default:
return SceneNarrations.narration(chapter: addr.chapter, scene: addr.scene)
}