From b33a2cf4a535cd3de8ca9fc9e06be6a47c9ef683 Mon Sep 17 00:00:00 2001 From: saymrwulf Date: Thu, 7 May 2026 11:10:56 +0200 Subject: [PATCH] Ch03 rounds: migrate to serial timeline + weight thermometer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ch03 walks through the five messages from Ch01/Ch02 and shows how round numbers are derived — not declared — from accumulated PoW weight. A horizontal thermometer at the top of the canvas fills green as each highlighted vertex contributes its weight; a yellow dashed line marks the round-closing threshold. When δ pushes the total over the threshold, a yellow ring + "is_last" label appears on δ on every cast member's lane simultaneously. Round 0 closes; round 1 opens with ε at weight 1. The chapter's pedagogical punch: nobody voted, nobody declared the boundary. Every honest player who has the same five messages computes the same total weight, sees δ push the same threshold, and flags δ identically. The thermometer is a calculator, not a ballot box. Closing beats: - "Each player keeps their own DAG. Full stop." overlay - "Re-gossip is harmless" demonstrated: a duplicate α envelope flies to Ben, arrives, gets a "✗ DUPLICATE — DROPPED" label, and dissolves. No tower update, no thermometer change. - "Weight is arithmetic. Arithmetic doesn't depend on who you ask." `Ch03Timeline.swift` (new) — 17 beats over ~72s. New beat kinds this chapter introduces: `introduceWeights` / `highlightVertex` / `markIsLast` / `openNewRound` / `bookkeepingNote` / `reGossipDuplicate`. `Ch04_Rounds.swift` rewritten end-to-end (91 → 460 lines) to render from Ch03Timeline. Lanes carry the carry-forward five messages with weight + round labels; thermometer at top; bookkeeping note overlay below; perception towers at the bottom (now also showing round numbers per block). `SceneEngine` and `ImmersiveView` extended for chapter 3. Bundled, harness 55/55 invariants, 0 audit errors, 281 PNGs, 36/36 MP4 written. Co-Authored-By: Claude Opus 4.7 --- .../CrisisViz/Chapters/Ch04_Rounds.swift | 507 ++++++++++++++++-- .../CrisisViz/Engine/Ch03Timeline.swift | 248 +++++++++ .../CrisisViz/Engine/SceneEngine.swift | 4 + .../CrisisViz/Views/ImmersiveView.swift | 2 + 4 files changed, 703 insertions(+), 58 deletions(-) create mode 100644 CrisisViz/Sources/CrisisViz/Engine/Ch03Timeline.swift diff --git a/CrisisViz/Sources/CrisisViz/Chapters/Ch04_Rounds.swift b/CrisisViz/Sources/CrisisViz/Chapters/Ch04_Rounds.swift index 761a806..278d0de 100644 --- a/CrisisViz/Sources/CrisisViz/Chapters/Ch04_Rounds.swift +++ b/CrisisViz/Sources/CrisisViz/Chapters/Ch04_Rounds.swift @@ -1,6 +1,13 @@ import SwiftUI -/// Ch04: "Rounds from Weight" — PoW weight accumulation triggers round boundaries. +/// Ch03 (chapter index 3): "Counting witnesses to mark a round." +/// +/// Renders from `Ch03Timeline`. Picks up Ch02's final state ({α, β, γ, +/// δ, ε} on every player's lane), then walks through each message in +/// turn — adding its proof-of-work weight to a thermometer at the top +/// of the canvas — until the threshold is crossed and the round +/// boundary is marked. The chapter shows that round numbers are +/// DERIVED from arithmetic on weight, not declared or negotiated. struct Ch04_Rounds: View { let sceneIndex: Int let localTime: Double @@ -8,84 +15,468 @@ struct Ch04_Rounds: View { let dm: DataManager @Environment(AppSettings.self) private var settings - private var dataStep: Int { sceneIndex + 2 } // steps 2, 3, 4 - var body: some View { Canvas { context, size in - render(context: &context, size: size, time: localTime) + let t = Ch03Scenes.timelineT(sceneIndex: sceneIndex, + localTime: localTime) + render(in: &context, size: size, t: t) } } - private func render(context: inout GraphicsContext, size: CGSize, time: Double) { - guard dm.sim != nil, - let snap = dm.honestData(step: dataStep) else { return } + private func render(in context: inout GraphicsContext, size: CGSize, t: Double) { + let world = Ch03Timeline.state(at: t) - let vertices = snap.vertices - let edges = snap.edges + drawLanes(in: &context, size: size) + drawCastFigures(in: &context, size: size, t: t) + drawAcceptedVertices(in: &context, size: size, world: world) + drawAcceptedEdges(in: &context, size: size, world: world) - // Draw the DAG with round separators prominent - let layout = DAGLayout.compute(vertices: vertices, edges: edges, nodes: dm.castOrderedNodes(), - canvasSize: size, margin: 60) - let minRound = vertices.map { $0.round }.min() ?? 0 - layout.drawNodeLanes(in: &context, nodes: dm.castOrderedNodes(), canvasSize: size, dm: dm, textScale: settings.textScale) - layout.drawRoundSeparators(in: &context, canvasSize: size, minRound: minRound, alpha: 0.4, textScale: settings.textScale) - layout.drawEdges(in: &context, edges: edges, alpha: 0.3) + drawThermometer(in: &context, size: size, world: world) - // Highlight isLast vertices (round boundary markers) with bright rings - let roundMarkers = Set(vertices.filter { $0.isLast }.map { $0.digestHex }) - layout.drawVertices(in: &context, vertices: vertices, nodes: dm.castOrderedNodes(), dm: dm, - showLabels: true, showWeight: true, highlightSet: roundMarkers, textScale: settings.textScale) + if let bookkeeping = world.bookkeepingText { + drawBookkeepingNote(in: &context, size: size, text: bookkeeping) + } + if let regossip = world.reGossipFlash { + drawReGossipDuplicate(in: &context, size: size, regossip: regossip) + } - // Weight bars per round at the bottom - let rounds = Dictionary(grouping: vertices, by: { $0.round }) - let barY = size.height - 80.0 - let barHeight: CGFloat = 20 - let roundCount = rounds.keys.count - let barSpacing = min(120.0, (size.width - 120) / CGFloat(max(roundCount, 1))) - let startX = 60.0 + drawPerceptionTowers(in: &context, size: size, world: world) + drawBeatTag(in: &context, size: size, world: world) + } - for (round, verts) in rounds.sorted(by: { $0.key < $1.key }) { - let totalWeight = verts.reduce(0) { $0 + $1.weight } - let x = startX + CGFloat(round - minRound) * barSpacing - let barW = barSpacing * 0.7 - let maxWeight = 30.0 // scale factor + // MARK: - Geometry / lookup helpers - // Background - let bgRect = CGRect(x: x, y: barY, width: barW, height: barHeight) - context.fill(RoundedRectangle(cornerRadius: 3).path(in: bgRect), - with: .color(.white.opacity(0.05))) + 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 + } - // Fill proportional to weight - let fillPct = min(1.0, Double(totalWeight) / maxWeight) - let fillW = barW * fillPct - let fillRect = CGRect(x: x, y: barY, width: fillW, height: barHeight) - let allIsLast = verts.allSatisfy { $0.isLast } - let fillColor: Color = allIsLast ? .yellow : .cyan - context.fill(RoundedRectangle(cornerRadius: 3).path(in: fillRect), - with: .color(fillColor.opacity(0.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)) + } - // Label + 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 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 m = Ch01Timeline.messages[mid] { return m.hashShort } + if let m = Ch02Timeline.messages[mid] { return m.hashShort } + return "????" + } + + private func parentsOf(_ mid: String) -> [String] { + if let m = Ch01Timeline.messages[mid] { return m.parents } + if let m = Ch02Timeline.messages[mid] { return m.parents } + return [] + } + + /// All four cast members hold {α, β, γ, δ, ε} from the carry-forward. + private var allMessages: [String] { Ch03Timeline.messageOrder } + + // MARK: - Lanes + cast + + 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("R\(round): Σw=\(totalWeight)") - .font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced)) - .foregroundColor(.white.opacity(0.5)), - at: CGPoint(x: x + barW / 2, y: barY + barHeight + 12) + 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 + ) + } + } + + 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 vertices on each lane + + private func drawAcceptedVertices( + in context: inout GraphicsContext, size: CGSize, world: Ch03WorldState + ) { + 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 allMessages.enumerated() { + let x = firstX + CGFloat(i) * gap + if x > size.width - 60 { break } + drawAcceptedVertex( + in: &context, at: CGPoint(x: x, y: lane), + messageId: mid, world: world + ) + } + } + } + + private func drawAcceptedVertex( + in context: inout GraphicsContext, at pos: CGPoint, + messageId: String, world: Ch03WorldState + ) { + let r: CGFloat = 14 + let color = castColor(authorOf(messageId)) + let rect = CGRect(x: pos.x - r, y: pos.y - r, width: r * 2, height: r * 2) + + // Highlight halo if this vertex is the active focus. + if world.highlighted == messageId { + let haloR: CGFloat = 24 + context.stroke( + Circle().path(in: CGRect(x: pos.x - haloR, y: pos.y - haloR, + width: haloR * 2, height: haloR * 2)), + with: .color(.white.opacity(0.55)), lineWidth: 1.5 ) } - // isLast annotation + // is_last yellow ring + if world.isLastSet.contains(messageId) { + let ringR: CGFloat = 21 + context.stroke( + Circle().path(in: CGRect(x: pos.x - ringR, y: pos.y - ringR, + width: ringR * 2, height: ringR * 2)), + with: .color(.yellow.opacity(0.95)), lineWidth: 2.4 + ) + } + + 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.2) context.draw( - Text("○ = isLast (ROUND BOUNDARY) — WEIGHT TRIGGERS TRANSITION") - .font(.system(size: settings.scaled(9), weight: .bold, design: .monospaced)) - .foregroundColor(.yellow.opacity(0.4)), - at: CGPoint(x: size.width / 2, y: barY - 16) + Text(messageId) + .font(.system(size: settings.scaled(12), weight: .heavy, design: .monospaced)) + .foregroundColor(.white), + at: pos ) + // Hash + weight + round number below + var sub: String = hashOf(messageId) + if world.weightsVisible { + let round = world.roundOf[messageId] ?? 0 + sub = "w=1 r=\(round)" + } context.draw( - Text("\(snap.vertices.count) VERTICES · MAX ROUND \(snap.maxRound)") - .font(.system(size: settings.scaled(9), weight: .bold, design: .monospaced)) - .foregroundColor(.white.opacity(0.2)), - at: CGPoint(x: size.width / 2, y: 14) + Text(sub) + .font(.system(size: settings.scaled(8), weight: .regular, design: .monospaced)) + .foregroundColor(.white.opacity(0.55)), + at: CGPoint(x: pos.x, y: pos.y + r + 8) + ) + + // is_last label + if world.isLastSet.contains(messageId) { + context.draw( + Text("is_last") + .font(.system(size: settings.scaled(8), weight: .heavy, design: .monospaced)) + .foregroundColor(.yellow.opacity(0.95)), + at: CGPoint(x: pos.x, y: pos.y - r - 10) + ) + } + } + + private func drawAcceptedEdges( + in context: inout GraphicsContext, size: CGSize, world: Ch03WorldState + ) { + let casts: [(Ch01Cast, Int)] = [(.aaron, 0), (.ben, 1), (.carl, 2), (.dave, 3)] + for (_, laneIdx) in casts { + let lane = castLaneY(laneIdx, size: size) + let castX = castPosition(cast: .aaron, size: size).x // all use same x + let firstX = castX + 70 + let gap: CGFloat = 56 + var positions: [String: CGPoint] = [:] + for (i, mid) in allMessages.enumerated() { + positions[mid] = CGPoint(x: firstX + CGFloat(i) * gap, y: lane) + } + for (mid, childPos) in positions { + for parentId in parentsOf(mid) { + guard let parentPos = positions[parentId] else { continue } + var path = Path() + path.move(to: CGPoint(x: childPos.x - 14, y: childPos.y)) + path.addLine(to: CGPoint(x: parentPos.x + 14, y: parentPos.y)) + context.stroke(path, + with: .color(castColor(authorOf(mid)).opacity(0.55)), + lineWidth: 1.0) + } + } + } + } + + // MARK: - Weight thermometer (top of canvas) + + private func drawThermometer( + in context: inout GraphicsContext, size: CGSize, world: Ch03WorldState + ) { + // Horizontal bar near the top of the canvas. Width sized so the + // composing-slot space is preserved, although Ch03 doesn't use + // the slot. + let barW: CGFloat = min(560, size.width - 100) + let barH: CGFloat = 26 + let barX = size.width / 2 - barW / 2 + let barY: CGFloat = 18 + let rect = CGRect(x: barX, y: barY, width: barW, height: barH) + + // Frame + context.fill(RoundedRectangle(cornerRadius: 6).path(in: rect), + with: .color(.black.opacity(0.7))) + context.stroke(RoundedRectangle(cornerRadius: 6).path(in: rect), + with: .color(.white.opacity(0.5)), lineWidth: 1.0) + + // Threshold tick (proportional to threshold within max=5) + let maxBar: Double = 5 + let threshFrac = world.thermometerThreshold / maxBar + let threshX = barX + CGFloat(threshFrac) * barW + var threshLine = Path() + threshLine.move(to: CGPoint(x: threshX, y: barY - 4)) + threshLine.addLine(to: CGPoint(x: threshX, y: barY + barH + 4)) + context.stroke(threshLine, + with: .color(.yellow.opacity(0.85)), + style: StrokeStyle(lineWidth: 1.4, dash: [3, 3])) + context.draw( + Text("threshold = \(Int(world.thermometerThreshold))") + .font(.system(size: settings.scaled(8), weight: .heavy, design: .monospaced)) + .foregroundColor(.yellow.opacity(0.85)), + at: CGPoint(x: threshX, y: barY - 14) + ) + + // Fill + let fillFrac = min(1.0, world.thermometerWeight / maxBar) + let fillW = CGFloat(fillFrac) * barW + if fillW > 0 { + let fillRect = CGRect(x: barX, y: barY, width: fillW, height: barH) + context.fill(RoundedRectangle(cornerRadius: 6).path(in: fillRect), + with: .color(.green.opacity(0.7))) + } + + // Label + let label = String(format: "ROUND %d · weight = %.0f / %d", + world.currentRound, + world.thermometerWeight, + Int(world.thermometerThreshold)) + context.draw( + Text(label) + .font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced)) + .foregroundColor(.white.opacity(0.95)), + at: CGPoint(x: rect.midX, y: rect.midY) + ) + } + + // MARK: - Bookkeeping note + re-gossip duplicate + + private func drawBookkeepingNote( + in context: inout GraphicsContext, size: CGSize, text: String + ) { + // Sits under the thermometer. + let cy: CGFloat = 64 + let rect = CGRect(x: size.width / 2 - 280, y: cy - 14, + width: 560, height: 28) + context.fill(RoundedRectangle(cornerRadius: 6).path(in: rect), + with: .color(.black.opacity(0.55))) + context.stroke(RoundedRectangle(cornerRadius: 6).path(in: rect), + with: .color(.white.opacity(0.35)), lineWidth: 0.8) + context.draw( + Text(text) + .font(.system(size: settings.scaled(11), weight: .bold, design: .monospaced)) + .foregroundColor(.white.opacity(0.85)), + at: CGPoint(x: rect.midX, y: rect.midY) + ) + } + + private func drawReGossipDuplicate( + in context: inout GraphicsContext, size: CGSize, + regossip: Ch03WorldState.ReGossip + ) { + // The duplicate envelope flies from Aaron toward the recipient, + // arrives, then displays a "DROP — duplicate" label that fades. + let from = castPosition(cast: .aaron, size: size) + let to = castPosition(cast: regossip.recipient, size: size) + let lift: CGFloat = 36 + let fromTrack = CGPoint(x: from.x, y: from.y - lift) + let toTrack = CGPoint(x: to.x, y: to.y - lift) + // Path + var path = Path() + path.move(to: fromTrack) + path.addLine(to: toTrack) + context.stroke(path, + with: .color(.white.opacity(0.18)), + style: StrokeStyle(lineWidth: 1.0, dash: [3, 5])) + let p = CGFloat(min(1.0, regossip.progress * 1.5)) // arrives at 0.67 + let pos = CGPoint(x: fromTrack.x + (toTrack.x - fromTrack.x) * p, + y: fromTrack.y + (toTrack.y - fromTrack.y) * p) + let envW: CGFloat = 78 + let envH: CGFloat = 28 + let rect = CGRect(x: pos.x - envW / 2, y: pos.y - envH / 2, + width: envW, height: envH) + let envFade = regossip.progress < 0.7 ? 1.0 : Double(max(0, 1 - (regossip.progress - 0.7) / 0.3)) + context.fill(RoundedRectangle(cornerRadius: 5).path(in: rect), + with: .color(Cast.coral.opacity(0.85 * envFade))) + context.stroke(RoundedRectangle(cornerRadius: 5).path(in: rect), + with: .color(.white.opacity(0.55 * envFade)), + lineWidth: 1.0) + context.draw( + Text("\(regossip.messageId) (re-gossip)") + .font(.system(size: settings.scaled(9), weight: .heavy, design: .monospaced)) + .foregroundColor(.white.opacity(envFade)), + at: pos + ) + // "✗ duplicate dropped" label appears once envelope is at recipient + if regossip.progress > 0.65 { + let alpha = min(1.0, (regossip.progress - 0.65) / 0.2) + context.draw( + Text("✗ DUPLICATE — DROPPED") + .font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced)) + .foregroundColor(.red.opacity(0.95 * alpha)), + at: CGPoint(x: to.x, y: to.y - 60) + ) + } + } + + // MARK: - Perception towers + + private func drawPerceptionTowers( + in context: inout GraphicsContext, size: CGSize, world: Ch03WorldState + ) { + let casts: [Ch01Cast] = [.aaron, .ben, .carl, .dave] + let blockH: CGFloat = 22 + let blockGap: CGFloat = 3 + let maxBlocks = 5 + let towerH: CGFloat = CGFloat(maxBlocks) * (blockH + blockGap) + 28 + let baseY: CGFloat = size.height - 90 + 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])) + } + + // Blocks: all five messages in alphabetical order + for (j, mid) in allMessages.enumerated() { + let blockY = baseY - CGFloat(j + 1) * (blockH + blockGap) + let rect = CGRect(x: towerX + 6, y: blockY, + width: towerW - 12, height: blockH) + let blockColor = castColor(authorOf(mid)) + context.fill(RoundedRectangle(cornerRadius: 5).path(in: rect), + with: .color(blockColor.opacity(0.88))) + context.stroke(RoundedRectangle(cornerRadius: 5).path(in: rect), + with: .color(.white.opacity(0.45)), lineWidth: 1.0) + // is_last yellow accent + if world.isLastSet.contains(mid) { + context.stroke(RoundedRectangle(cornerRadius: 5).path(in: rect), + with: .color(.yellow.opacity(0.95)), + lineWidth: 2.0) + } + let round = world.roundOf[mid] ?? 0 + context.draw( + Text("\(mid) r\(round)") + .font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced)) + .foregroundColor(.white), + at: CGPoint(x: rect.midX, y: rect.midY) + ) + } + } + } + + // MARK: - Beat tag + + private func drawBeatTag( + in context: inout GraphicsContext, size: CGSize, world: Ch03WorldState + ) { + guard let beatId = world.activeBeat?.id else { return } + context.draw( + 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 ) } } diff --git a/CrisisViz/Sources/CrisisViz/Engine/Ch03Timeline.swift b/CrisisViz/Sources/CrisisViz/Engine/Ch03Timeline.swift new file mode 100644 index 0000000..576dfe9 --- /dev/null +++ b/CrisisViz/Sources/CrisisViz/Engine/Ch03Timeline.swift @@ -0,0 +1,248 @@ +import SwiftUI + +/// Ch03 — "Counting witnesses to mark a round." +/// +/// The chapter is about ROUND DERIVATION: round numbers in Crisis are +/// computed from accumulated proof-of-work weight, not declared or +/// negotiated. The chapter walks through the five messages from +/// Ch01/Ch02 (α through ε) and shows weight summing into a thermometer +/// at the top of the canvas. When a threshold is crossed, the message +/// that pushed weight over the line earns the `is_last` flag — that's +/// the round boundary marker. + +// MARK: - Types + +enum Ch03BeatKind { + case settle(label: String) + case carryForward // initial state from Ch02 + case introduceWeights // each vertex shows its weight + case highlightVertex(messageId: String) // thermometer adds weight + case markIsLast(messageId: String) // yellow ring + boundary badge + case openNewRound(roundNumber: Int) // increment round counter + case bookkeepingNote(text: String) // overlay text only + case reGossipDuplicate(messageId: String, recipient: Ch01Cast) // duplicate ignored +} + +struct Ch03Beat: Identifiable { + let id: String + let kind: Ch03BeatKind + let durationSeconds: Double + let narration: String + var startTime: Double = 0 + var endTime: Double { startTime + durationSeconds } +} + +struct Ch03WorldState { + var weightsVisible: Bool = false // show weight=1 labels next to vertices + var roundOf: [String: Int] = [:] // message id → round number once derived + var isLastSet: Set = [] // messages flagged is_last + var highlighted: String? = nil // currently focused vertex (halo) + var currentRound: Int = 0 + var thermometerWeight: Double = 0 // accumulated weight in current round + var thermometerThreshold: Double = 4 // round closes when weight ≥ threshold + var bookkeepingText: String? = nil + var reGossipFlash: ReGossip? = nil + var activeBeat: Ch03Beat? = nil + var activeProgress: Double = 0 + + struct ReGossip { + let messageId: String + let recipient: Ch01Cast + let progress: Double + } +} + +// MARK: - Timeline + +enum Ch03Timeline { + /// Each message has weight 1 in this chapter (PoW puzzles all the + /// same difficulty). Round threshold = 4, so α/β/γ/δ close round 0 + /// (δ is_last) and ε opens round 1. + static let messageWeights: [String: Double] = [ + "α": 1, "β": 1, "γ": 1, "δ": 1, "ε": 1, + ] + static let messageOrder: [String] = ["α", "β", "γ", "δ", "ε"] + static let threshold: Double = 4 + + static let beats: [Ch03Beat] = { + let raw: [Ch03Beat] = [ + .init(id: "carry-forward", kind: .carryForward, durationSeconds: 4.0, + narration: "Coming out of the partition: all four cast members hold the same five messages — α, β, γ, δ, ε. Now we ask a different question. What ROUND is each one in? And how do we even know?"), + + .init(id: "introduce-weights", kind: .introduceWeights, durationSeconds: 5.0, + narration: "First, every message carries a proof-of-work weight. Harder puzzles → heavier messages. In this demo each message has weight 1. The little 'w=1' label appears next to each vertex on every lane."), + + .init(id: "thermometer-explained", kind: .settle(label: "Thermometer at the top"), + durationSeconds: 4.0, + narration: "Look at the top of the canvas. The thermometer accumulates weight as we count messages within the current round. The dotted line on it is the round-closing threshold."), + + // Walk through α/β/γ/δ — each contributes weight to round 0 + .init(id: "highlight-alpha", kind: .highlightVertex(messageId: "α"), durationSeconds: 3.5, + narration: "α is the first vertex on every lane. Round 0. Weight 1. The thermometer ticks up to 1."), + .init(id: "highlight-beta", kind: .highlightVertex(messageId: "β"), durationSeconds: 3.5, + narration: "β references α — still inside round 0. Weight 1. Thermometer ticks up to 2."), + .init(id: "highlight-gamma", kind: .highlightVertex(messageId: "γ"), durationSeconds: 3.5, + narration: "γ also references α — still inside round 0. Weight 1. Thermometer ticks up to 3."), + .init(id: "highlight-delta", kind: .highlightVertex(messageId: "δ"), durationSeconds: 4.0, + narration: "δ references γ — still inside round 0 because we haven't crossed the threshold yet. Weight 1. Thermometer ticks up to 4 — exactly the threshold."), + + .init(id: "mark-delta-islast", kind: .markIsLast(messageId: "δ"), + durationSeconds: 5.5, + narration: "The threshold is met. δ — the message that pushed weight over the line — gets the is_last flag. A yellow ring marks it on every lane that holds it. Round 0 has closed."), + + .init(id: "round-0-closed-settle", kind: .settle(label: "Round 0 closed"), + durationSeconds: 4.0, + narration: "Crucially: NOBODY VOTED. Nobody declared the boundary. Every honest player who has the same five messages computes the same total weight, sees δ push over the same threshold, and flags δ as is_last. Round 0 is DERIVED, not declared."), + + // Open round 1 with ε + .init(id: "open-round-1", kind: .openNewRound(roundNumber: 1), durationSeconds: 3.0, + narration: "The thermometer resets. Round 1 begins."), + .init(id: "highlight-eps", kind: .highlightVertex(messageId: "ε"), durationSeconds: 4.0, + narration: "ε is the first vertex of round 1. It references γ as a parent — old parents are perfectly legitimate. Round 1 has weight 1 so far. The threshold has not been met, so round 1 is still open — no message in round 1 has been flagged is_last yet."), + + .init(id: "round-1-open-settle", kind: .settle(label: "Round 1 still open"), + durationSeconds: 3.5, + narration: "If more messages get written and accepted, weight will accumulate in round 1, and eventually some message will close it. Same arithmetic, same outcome on every honest validator."), + + // Bookkeeping note + .init(id: "bookkeeping-1", kind: .bookkeepingNote(text: "Each player keeps their own DAG. Full stop."), + durationSeconds: 5.0, + narration: "Bookkeeping: every honest player keeps their own DAG of received messages. Nothing else. Nobody tracks who-sent-what-to-whom; the gossip layer fans out and the digest dedupes on the receiver."), + .init(id: "bookkeeping-2", kind: .bookkeepingNote(text: "Re-gossip is harmless."), + durationSeconds: 4.0, + narration: "Re-gossip is harmless. If the same message arrives twice, the receiver detects the duplicate by its hash and drops the second copy. Watch."), + + // Demonstrate duplicate dropping + .init(id: "regossip-alpha-ben", kind: .reGossipDuplicate(messageId: "α", recipient: .ben), + durationSeconds: 5.5, + narration: "Aaron tries to re-send α to Ben. Ben already has α in his view. The envelope arrives, the hash is matched against his local set, the duplicate is detected, and the message is dropped. No tower update. No round-weight change. The system stays consistent."), + + .init(id: "weight-arithmetic", kind: .bookkeepingNote(text: "Weight is arithmetic. Arithmetic doesn't depend on who you ask."), + durationSeconds: 6.0, + narration: "Weight is arithmetic. Arithmetic doesn't depend on who you ask. As long as two honest validators have the same set of accepted messages, they compute the same round numbers — without exchanging any vote, any negotiation, any consensus message at all."), + + .init(id: "outro", kind: .settle(label: "Rounds derived"), + durationSeconds: 4.0, + narration: "Rounds are now defined. Next chapter: how a leader is picked from each round, and how the round leaders chain into a total order."), + ] + var t: Double = 0 + var assigned: [Ch03Beat] = [] + 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) -> Ch03Beat? { + let clamped = max(0, min(t, totalDuration)) + return beats.first { $0.startTime <= clamped && clamped < $0.endTime } + ?? beats.last + } + + static func state(at t: Double) -> Ch03WorldState { + var w = Ch03WorldState() + // All five messages start in round 0; markIsLast/openNewRound + // promotes ε to round 1. + for mid in messageOrder { w.roundOf[mid] = 0 } + + 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: Ch03Beat, progress: Double, isActive: Bool, + into w: inout Ch03WorldState + ) { + switch beat.kind { + case .settle, .carryForward: + break + case .introduceWeights: + // Permanent fade-in: once visible, stays visible. + w.weightsVisible = true + case .highlightVertex(let mid): + if isActive { + w.highlighted = mid + } + // Even if past, the vertex's weight has been added to the + // thermometer. + if let weight = messageWeights[mid] { + if isActive { + w.thermometerWeight += weight * progress + } else { + w.thermometerWeight += weight + } + } + case .markIsLast(let mid): + // Permanent: the message gets the is_last flag once the beat + // starts (so the yellow ring appears immediately). + w.isLastSet.insert(mid) + if isActive { + w.highlighted = mid + } + case .openNewRound(let roundNum): + // Permanent: thermometer resets, round counter increments. + w.currentRound = roundNum + w.thermometerWeight = 0 + // Promote ε's round number once round 1 opens. + for mid in messageOrder where (w.isLastSet.contains(mid) == false && w.roundOf[mid] == 0) { + // Messages NOT flagged is_last but in the round-0 batch + // stay at round 0; messages AFTER δ go into round 1. + if mid == "ε" { + w.roundOf[mid] = 1 + } + } + case .bookkeepingNote(let text): + if isActive { + w.bookkeepingText = text + } + case .reGossipDuplicate(let mid, let recipient): + if isActive { + w.reGossipFlash = .init(messageId: mid, recipient: recipient, + progress: progress) + } + } + } +} + +// MARK: - Scene mapping + +enum Ch03Scenes { + /// 3 scenes mapping to ~72s of timeline at 1×. + static let sceneStarts: [Double] = [0, 23.5, 44.0] + static let sceneDurations: [Double] = [23.5, 20.5, 28.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 Ch03Timeline.activeBeat(at: t)?.narration ?? "" + } +} diff --git a/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift b/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift index c03f79c..ba822ab 100644 --- a/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift +++ b/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift @@ -65,6 +65,10 @@ final class SceneEngine { SceneAddress(chapter: 2, scene: 1): 35.0, SceneAddress(chapter: 2, scene: 2): 22.5, SceneAddress(chapter: 2, scene: 3): 44.0, + // Ch03 — rounds (3 scenes mapping to Ch03Timeline windows) + SceneAddress(chapter: 3, scene: 0): 23.5, + SceneAddress(chapter: 3, scene: 1): 20.5, + SceneAddress(chapter: 3, scene: 2): 28.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 0edd82c..682a68f 100644 --- a/CrisisViz/Sources/CrisisViz/Views/ImmersiveView.swift +++ b/CrisisViz/Sources/CrisisViz/Views/ImmersiveView.swift @@ -162,6 +162,8 @@ struct ImmersiveView: View { return Ch01Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime) case 2: return Ch02Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime) + case 3: + return Ch03Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime) default: return SceneNarrations.narration(chapter: addr.chapter, scene: addr.scene) }