Ch03 rounds: migrate to serial timeline + weight thermometer

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 <noreply@anthropic.com>
This commit is contained in:
saymrwulf 2026-05-07 11:10:56 +02:00
parent 08f6e6ff8b
commit b33a2cf4a5
4 changed files with 703 additions and 58 deletions

View file

@ -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
)
}
}

View file

@ -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<String> = [] // 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 ?? ""
}
}

View file

@ -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.

View file

@ -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)
}