From c9689ec7e5d22a004eb0ca9e62c83c8ace5fb75a Mon Sep 17 00:00:00 2001 From: saymrwulf Date: Thu, 7 May 2026 11:17:58 +0200 Subject: [PATCH] Ch09 Byzantine: migrate to serial timeline + fork detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../CrisisViz/Chapters/Ch10_Byzantine.swift | 734 ++++++++++++------ .../CrisisViz/Engine/Ch09Timeline.swift | 274 +++++++ .../CrisisViz/Engine/SceneEngine.swift | 3 + .../CrisisViz/Views/ImmersiveView.swift | 2 + 4 files changed, 777 insertions(+), 236 deletions(-) create mode 100644 CrisisViz/Sources/CrisisViz/Engine/Ch09Timeline.swift diff --git a/CrisisViz/Sources/CrisisViz/Chapters/Ch10_Byzantine.swift b/CrisisViz/Sources/CrisisViz/Chapters/Ch10_Byzantine.swift index e4e465f..1341ddd 100644 --- a/CrisisViz/Sources/CrisisViz/Chapters/Ch10_Byzantine.swift +++ b/CrisisViz/Sources/CrisisViz/Chapters/Ch10_Byzantine.swift @@ -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 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 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 ?? "" + } +} diff --git a/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift b/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift index ba822ab..81e1d00 100644 --- a/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift +++ b/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift @@ -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. diff --git a/CrisisViz/Sources/CrisisViz/Views/ImmersiveView.swift b/CrisisViz/Sources/CrisisViz/Views/ImmersiveView.swift index 682a68f..548c23c 100644 --- a/CrisisViz/Sources/CrisisViz/Views/ImmersiveView.swift +++ b/CrisisViz/Sources/CrisisViz/Views/ImmersiveView.swift @@ -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) }