Ch06 total order: migrate to serial timeline + ordering snake

The chapter walks the leader chain from Ch05 and adds each leader's
round-N ancestor closure to a single linear sequence at the bottom
of the canvas — the "total order snake". Dashed placeholder slots
#1 through #5 sit empty; as `appendToOrder` beats fire, blocks slide
up from below into their position with cubic-out easing and the
snake's spine arrows draw between consecutive slots.

Sequence built across the chapter:
  Round 0: α (#1) → β (#2) → γ (#3) → δ (#4)
  Round 1: ε (#5)

Round-N-ordered badges (green check + "ROUND 0 ORDERED" / "ROUND 1
ORDERED") fire as each round completes; final-convergence badge
("ALL VALIDATORS COMPUTE THE SAME TOTAL ORDER — CONVERGENCE")
fires at the top once the snake is full.

`Ch06Timeline.swift` (new) — 11 beats over ~51.5s. Beat kinds:
`appendToOrder`, `roundOrderedBadge`, `finalConvergence`.

`Ch07_Order.swift` rewritten to render from Ch06Timeline. Lanes
fade slightly when a message goes into the order so the focus
shifts to the snake. Slot connectors are arrowed.

`SceneEngine` and `ImmersiveView` extended for chapter 6.

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:49:34 +02:00
parent 598bee5b81
commit 6345ae94f5
4 changed files with 398 additions and 321 deletions

View file

@ -1,25 +1,7 @@
import SwiftUI
/// Ch06 (file Ch07_Order, user-facing chapter index 6): "Spokespersons line
/// up. Everyone else falls in behind."
///
/// This is the masterclass scene the visible *emergence* of total order
/// from the DAG. Three pedagogical beats:
///
/// - Scene 0 ("Sorting the DAG into a line."): the empty timeline appears
/// across the bottom, a leader-position cursor sweeps left to right, and
/// the FIRST few ordered vertices peel off the DAG into the line so the
/// mechanism is unambiguous. Aaron's, Ben's and Carl's earliest contributions
/// are named on landing.
///
/// - Scene 1 ("Vertices slide into their place."): wave-pull. Each round's
/// ordered vertices fly to their slots in succession, with cast-colored
/// tracer lines so the viewer SEES who went where. Round zones appear on
/// the strip as vertices land in them.
///
/// - Scene 2 ("Everyone produces the same line."): the whole ordered prefix
/// is on the strip; a verification badge confirms Aaron's line equals
/// Ben's line equals Carl's line this is convergence, made visible.
/// Ch06 (chapter index 6, file Ch07_Order.swift):
/// "Spokespersons line up. Everyone else falls in behind." total order.
struct Ch07_Order: View {
let sceneIndex: Int
let localTime: Double
@ -27,343 +9,267 @@ struct Ch07_Order: View {
let dm: DataManager
@Environment(AppSettings.self) private var settings
// Post-convergence step. The 80-step simulation first produces ordered
// vertices at step 38 and reaches a stable converged prefix from step 40
// onwards; we pick step 60 so a substantial ordered prefix is available
// to slide into the "snake" line.
private let dataStep = 60
/// Cap the visible-on-strip count so the line stays readable. Beyond
/// ~40 vertices the strip gets too dense for a teaching frame.
private let maxStripCount = 40
var body: some View {
Canvas { context, size in
render(context: &context, size: size, time: localTime)
let t = Ch06Scenes.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 }
let lanes = dm.castOrderedNodes()
let allVertices = snap.vertices
let allEdges = snap.edges
// First N ordered vertices, sorted by totalPosition.
let orderedAll = allVertices.filter { $0.totalPosition != nil }
.sorted { ($0.totalPosition ?? 0) < ($1.totalPosition ?? 0) }
let ordered = Array(orderedAll.prefix(maxStripCount))
let orderedSet = Set(ordered.map(\.digestHex))
let unordered = allVertices.filter { !orderedSet.contains($0.digestHex) }
// DAG layout is the *source* (vertex pre-slide positions on their lanes).
let layout = DAGLayout.compute(
vertices: allVertices, edges: allEdges, nodes: lanes,
canvasSize: size, margin: 60
)
// Strip geometry single row of slots above the bottom narration band.
let stripY: CGFloat = size.height - 220
let stripMargin: CGFloat = 40
let stripWidth = size.width - stripMargin * 2
let slotSpacing = stripWidth / CGFloat(max(ordered.count, 1))
// Per-vertex slide progress. Wave animation: vertex i begins sliding
// at staggerStart_i, taking `pullDuration` to land. Scene 0 reveals
// only the first few; Scene 1 floods; Scene 2 holds the final state.
let pullDuration: Double = 0.9
let stagger: Double = sceneIndex == 0 ? 0.4 : 0.18
let revealLimit: Int
switch sceneIndex {
case 0: revealLimit = min(ordered.count, max(3, Int(time / 1.6) + 3))
case 1: revealLimit = ordered.count
default: revealLimit = ordered.count
private func render(in context: inout GraphicsContext, size: CGSize, t: Double) {
let world = Ch06Timeline.state(at: t)
drawLanes(in: &context, size: size)
drawCastFigures(in: &context, size: size)
drawAcceptedVertices(in: &context, size: size, world: world)
drawOrderingSnake(in: &context, size: size, world: world, t: t)
drawRoundOrderedBadges(in: &context, size: size, world: world)
if world.finalConvergenceAlpha > 0 {
drawFinalConvergence(in: &context, size: size,
alpha: world.finalConvergenceAlpha)
}
let timeOffsetForLandingAll: Double = sceneIndex == 2 ? 0.0 : Double(ordered.count) * stagger + pullDuration
drawBeatTag(in: &context, size: size, world: world)
}
// Background DAG (dimmed as the snake assembles).
let bgFade = sceneIndex == 0
? 0.55
: max(0.10, 0.55 - 0.45 * min(1.0, time / 4.0))
layout.drawNodeLanes(in: &context, nodes: lanes, canvasSize: size, dm: dm,
textScale: settings.textScale)
layout.drawRoundSeparators(in: &context, canvasSize: size, minRound: 0,
alpha: 0.18 * bgFade, textScale: settings.textScale)
layout.drawEdges(in: &context, edges: allEdges,
alpha: max(0.04, 0.22 * bgFade))
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
}
// Unordered vertices stay in their lanes, dimmed.
for v in unordered {
guard let pos = layout.positions[v.digestHex] else { continue }
let r: CGFloat = 5 + CGFloat(min(v.weight, 8)) * 0.6
let rect = CGRect(x: pos.x - r, y: pos.y - r, width: r * 2, height: r * 2)
let baseColor = dm.castColor(for: v.processIdHex)
context.fill(Circle().path(in: rect), with: .color(baseColor.opacity(0.30 * bgFade)))
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))
}
// The strip itself empty slot row that holds positions even before
// anything has landed. Visible from t=0 in every scene so the viewer
// sees the "destination" before the first vertex flies.
drawStripBackdrop(in: &context, size: size, stripY: stripY,
stripMargin: stripMargin, slotSpacing: slotSpacing,
slotCount: ordered.count, time: time)
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
}
}
// Per-round zones on the strip. We compute them on the fly from the
// ordered list itself no parallel array needed.
drawRoundZonesOnStrip(in: &context, ordered: ordered,
stripY: stripY, stripMargin: stripMargin,
slotSpacing: slotSpacing,
revealLimit: revealLimit, sceneIndex: sceneIndex,
time: time, settings: settings)
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
}
// Slide each ordered vertex from its lane to its slot
for (i, vertex) in ordered.enumerated() {
guard let dagPos = layout.positions[vertex.digestHex] else { continue }
let targetX = stripMargin + (CGFloat(i) + 0.5) * slotSpacing
let targetY = stripY
private static let allMessages: [String] = ["α", "β", "γ", "δ", "ε"]
private static let castLanes: [(Ch01Cast, Int)] = [(.aaron, 0), (.ben, 1), (.carl, 2), (.dave, 3)]
// Per-vertex animation phase
let startAt = Double(i) * stagger
let progress: Double
if sceneIndex == 2 {
// In scene 2 the snake is fully formed.
progress = 1.0
} else if i >= revealLimit {
progress = 0.0
} else {
progress = max(0, min(1, (time - startAt) / pullDuration))
}
private func vertexPosition(cast: Ch01Cast, mid: String, size: CGSize) -> CGPoint? {
guard let i = Self.allMessages.firstIndex(of: mid) else { return nil }
let laneIdx: Int
switch cast {
case .aaron: laneIdx = 0
case .ben: laneIdx = 1
case .carl: laneIdx = 2
case .dave: laneIdx = 3
}
let lane = castLaneY(laneIdx, size: size)
let castX = castPosition(cast: cast, size: size).x
return CGPoint(x: castX + 70 + CGFloat(i) * 56, y: lane)
}
// Cubic ease-out for a bit of motion personality without overdoing.
let eased = 1 - pow(1 - progress, 3)
private func drawLanes(in context: inout GraphicsContext, size: CGSize) {
for (cast, idx) in Self.castLanes {
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
)
}
}
let x = dagPos.x + (targetX - dagPos.x) * CGFloat(eased)
let y = dagPos.y + (targetY - dagPos.y) * CGFloat(eased)
let pos = CGPoint(x: x, y: y)
let castColor = dm.castColor(for: vertex.processIdHex)
let role = dm.castRole(for: vertex.processIdHex)
private func drawCastFigures(in context: inout GraphicsContext, size: CGSize) {
for cast in Ch01Cast.allCases {
let pos = castPosition(cast: cast, size: size)
let r: CGFloat = 24
let color = castColor(cast)
context.fill(
Circle().path(in: CGRect(x: pos.x - r * 1.5, y: pos.y - r * 1.5,
width: r * 3, height: r * 3)),
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.draw(
Text(String(cast.role.displayName.prefix(1)))
.font(.system(size: settings.scaled(16), weight: .heavy, design: .monospaced))
.foregroundColor(.white),
at: pos
)
}
}
// Tracer line during slide fades as the vertex settles.
if eased > 0.05 && eased < 0.97 {
var path = Path()
path.move(to: dagPos)
path.addLine(to: pos)
let traceAlpha = (1 - eased) * 0.5 + 0.1
context.stroke(path,
with: .color(castColor.opacity(traceAlpha)),
style: StrokeStyle(lineWidth: 1.6,
dash: [3, 4]))
}
// Vertex itself.
let radius: CGFloat = 7 + CGFloat(min(vertex.weight, 8)) * 0.6
let rect = CGRect(x: pos.x - radius, y: pos.y - radius,
width: radius * 2, height: radius * 2)
// A small landing flash for one beat after settling.
let timeSinceLand = time - (startAt + pullDuration)
let flashAmt: Double = (timeSinceLand > 0 && timeSinceLand < 0.35)
? max(0, 1 - timeSinceLand / 0.35) : 0
if flashAmt > 0.05 {
let flashR = radius * (1 + 1.4 * CGFloat(flashAmt))
let flashRect = CGRect(x: pos.x - flashR, y: pos.y - flashR,
width: flashR * 2, height: flashR * 2)
context.fill(Circle().path(in: flashRect),
with: .color(castColor.opacity(0.25 * flashAmt)))
}
context.fill(Circle().path(in: rect),
with: .color(castColor.opacity(0.55 + 0.4 * eased)))
// Yellow ring for round-boundary (`isLast`) vertices
// the same convention used in Ch03_Rounds, kept consistent here
// so the viewer sees that round-marking carries over to ordering.
if vertex.isLast && eased > 0.6 {
context.stroke(Circle().path(in: rect.insetBy(dx: -2, dy: -2)),
with: .color(.yellow.opacity(0.5 * eased)),
lineWidth: 1.5)
}
// Position number after landing.
if eased > 0.85 {
private func drawAcceptedVertices(
in context: inout GraphicsContext, size: CGSize, world: Ch06WorldState
) {
for (cast, _) in Self.castLanes {
for mid in Self.allMessages {
guard let pos = vertexPosition(cast: cast, mid: mid, size: size) else { continue }
let r: CGFloat = 11
let color = castColor(authorOf(mid))
let inOrder = world.order.contains(mid)
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(inOrder ? 0.6 : 0.85)))
context.stroke(Circle().path(in: rect),
with: .color(.white.opacity(0.45)), lineWidth: 0.8)
context.draw(
Text("\(i + 1)")
.font(.system(size: settings.scaled(9), weight: .heavy, design: .monospaced))
.foregroundColor(.white.opacity(0.85)),
at: CGPoint(x: pos.x, y: pos.y + radius + 11)
)
}
// Cast-name callout for the FIRST vertex of each named lead.
// Only label the first appearance to keep the strip readable.
if eased > 0.85 && role.isNamedCast,
isFirstAppearance(of: role, in: ordered, atIndex: i) {
context.draw(
Text(role.displayName.uppercased())
Text(mid)
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
.foregroundColor(castColor.opacity(0.9)),
at: CGPoint(x: pos.x, y: pos.y - radius - 12)
.foregroundColor(.white.opacity(inOrder ? 0.7 : 1.0)),
at: pos
)
}
}
}
private func drawOrderingSnake(
in context: inout GraphicsContext, size: CGSize,
world: Ch06WorldState, t: Double
) {
let trackY: CGFloat = size.height - 100
let blockW: CGFloat = 76
let blockH: CGFloat = 38
let blockGap: CGFloat = 16
let totalW = CGFloat(Self.allMessages.count) * blockW
+ CGFloat(Self.allMessages.count - 1) * blockGap
let startX = (size.width - totalW) / 2
context.draw(
Text("TOTAL ORDER")
.font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced))
.foregroundColor(.white.opacity(0.65)),
at: CGPoint(x: size.width / 2, y: trackY - 32)
)
for i in 0..<Self.allMessages.count {
let x = startX + CGFloat(i) * (blockW + blockGap)
let rect = CGRect(x: x, y: trackY - blockH / 2,
width: blockW, height: blockH)
if i >= world.order.count {
context.stroke(RoundedRectangle(cornerRadius: 6).path(in: rect),
with: .color(.white.opacity(0.18)),
style: StrokeStyle(lineWidth: 1.0, dash: [3, 4]))
context.draw(
Text("#\(i + 1)")
.font(.system(size: settings.scaled(9), weight: .regular, design: .monospaced))
.foregroundColor(.white.opacity(0.30)),
at: CGPoint(x: rect.midX, y: rect.midY)
)
}
}
// Subtitle
let subtitle: String = switch sceneIndex {
case 0: "AARON, BEN, CARL — THEIR VERTICES WALK ONTO THE LINE"
case 1: "EACH ROUND'S VERTICES FLY INTO POSITION"
default: "EVERY HONEST NODE PRODUCES THIS EXACT LINE — CONVERGENCE"
}
context.draw(
Text(subtitle)
.font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced))
.foregroundColor(.cyan.opacity(0.55)),
at: CGPoint(x: size.width / 2, y: stripY + 80)
)
// Final-scene convergence badge
if sceneIndex == 2 {
drawConvergenceBadge(in: &context, size: size, ordered: ordered, time: time)
var activeSlideIndex = -1
if case .appendToOrder = world.activeBeat?.kind {
activeSlideIndex = world.order.count - 1
}
// Top-right: ordered count.
context.draw(
Text("\(min(ordered.count, revealLimit))/\(orderedAll.count) ORDERED · \(allVertices.count) TOTAL DAG")
.font(.system(size: settings.scaled(9), weight: .bold, design: .monospaced))
.foregroundColor(.white.opacity(0.35)),
at: CGPoint(x: size.width / 2, y: 16)
)
for (i, mid) in world.order.enumerated() {
let x = startX + CGFloat(i) * (blockW + blockGap)
let restY = trackY
let dropFrom = trackY + 60
let isSliding = (i == activeSlideIndex)
let p = isSliding ? max(0, min(1, world.activeProgress)) : 1.0
let eased = 1 - pow(1 - p, 3)
let y = restY * eased + dropFrom * (1 - eased)
let rect = CGRect(x: x, y: y - blockH / 2,
width: blockW, height: blockH)
let color = castColor(authorOf(mid))
context.fill(RoundedRectangle(cornerRadius: 6).path(in: rect),
with: .color(color.opacity(0.92 * eased)))
context.stroke(RoundedRectangle(cornerRadius: 6).path(in: rect),
with: .color(.white.opacity(0.55 * eased)), lineWidth: 1.2)
context.draw(
Text("\(i + 1)")
.font(.system(size: settings.scaled(8), weight: .regular, design: .monospaced))
.foregroundColor(.white.opacity(0.55 * eased)),
at: CGPoint(x: rect.midX, y: rect.minY + 8)
)
context.draw(
Text(mid)
.font(.system(size: settings.scaled(16), weight: .heavy, design: .monospaced))
.foregroundColor(.white.opacity(eased)),
at: CGPoint(x: rect.midX, y: rect.midY + 4)
)
}
_ = timeOffsetForLandingAll // referenced for clarity in pacing comments
// Snake spine arrows between filled slots
for i in 0..<max(0, world.order.count - 1) {
let x1 = startX + CGFloat(i) * (blockW + blockGap) + blockW
let x2 = startX + CGFloat(i + 1) * (blockW + blockGap)
var path = Path()
path.move(to: CGPoint(x: x1, y: trackY))
path.addLine(to: CGPoint(x: x2, y: trackY))
context.stroke(path, with: .color(.white.opacity(0.35)),
lineWidth: 1.2)
var head = Path()
head.move(to: CGPoint(x: x2, y: trackY))
head.addLine(to: CGPoint(x: x2 - 5, y: trackY - 3))
head.move(to: CGPoint(x: x2, y: trackY))
head.addLine(to: CGPoint(x: x2 - 5, y: trackY + 3))
context.stroke(head, with: .color(.white.opacity(0.35)),
lineWidth: 1.2)
}
}
// MARK: - Pieces
/// Empty strip slots, drawn as a thin rounded rect with tick marks at
/// each slot. Visible from t=0 so the viewer knows where vertices are
/// going before they fly.
private func drawStripBackdrop(
in context: inout GraphicsContext, size: CGSize,
stripY: CGFloat, stripMargin: CGFloat,
slotSpacing: CGFloat, slotCount: Int, time: Double
private func drawRoundOrderedBadges(
in context: inout GraphicsContext, size: CGSize, world: Ch06WorldState
) {
let stripHeight: CGFloat = 4
let stripRect = CGRect(
x: stripMargin, y: stripY + stripHeight * 4,
width: size.width - stripMargin * 2, height: stripHeight
)
context.fill(RoundedRectangle(cornerRadius: 2).path(in: stripRect),
with: .color(.white.opacity(0.12)))
// Tick at each slot.
for i in 0..<slotCount {
let x = stripMargin + (CGFloat(i) + 0.5) * slotSpacing
var tick = Path()
tick.move(to: CGPoint(x: x, y: stripRect.minY - 3))
tick.addLine(to: CGPoint(x: x, y: stripRect.maxY + 3))
context.stroke(tick, with: .color(.white.opacity(0.10)), lineWidth: 0.6)
for (round, alpha) in world.roundOrderedAlpha where alpha > 0 {
let label = "✓ ROUND \(round) ORDERED"
context.draw(
Text(label)
.font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced))
.foregroundColor(.green.opacity(0.95 * alpha)),
at: CGPoint(x: size.width / 2,
y: size.height - 50 - CGFloat(round) * 18)
)
}
// Direction arrow under the strip.
let arrowY = stripRect.maxY + 28
var arrow = Path()
arrow.move(to: CGPoint(x: stripMargin, y: arrowY))
arrow.addLine(to: CGPoint(x: size.width - stripMargin, y: arrowY))
context.stroke(arrow, with: .color(.white.opacity(0.15)), lineWidth: 1)
context.draw(
Text("→ TOTAL ORDER (POSITION 1, 2, 3, …)")
.font(.system(size: settings.scaled(9), weight: .bold, design: .monospaced))
.foregroundColor(.white.opacity(0.30 + 0.05 * sin(time))),
at: CGPoint(x: size.width / 2, y: arrowY + 14)
)
}
/// Per-round shaded zones above the strip. Each round is a translucent
/// band sized to the contiguous run of vertices with that round number.
private func drawRoundZonesOnStrip(
in context: inout GraphicsContext, ordered: [VertexData],
stripY: CGFloat, stripMargin: CGFloat, slotSpacing: CGFloat,
revealLimit: Int, sceneIndex: Int, time: Double,
settings: AppSettings
private func drawFinalConvergence(
in context: inout GraphicsContext, size: CGSize, alpha: Double
) {
guard !ordered.isEmpty else { return }
// Group consecutive same-round runs.
var runStart = 0
var i = 1
let zoneTop = stripY - 16
let zoneHeight: CGFloat = 28
while i <= ordered.count {
let endThisRun = (i == ordered.count) || (ordered[i].round != ordered[runStart].round)
if endThisRun {
// Only draw zones whose first vertex has been revealed.
if runStart < revealLimit {
let endIdx = min(i, revealLimit) - 1
let runRound = ordered[runStart].round
let xStart = stripMargin + CGFloat(runStart) * slotSpacing
let xEnd = stripMargin + CGFloat(endIdx + 1) * slotSpacing
let rect = CGRect(x: xStart, y: zoneTop,
width: xEnd - xStart, height: zoneHeight)
let alpha: Double = sceneIndex == 2 ? 0.18 : 0.12
context.fill(RoundedRectangle(cornerRadius: 5).path(in: rect),
with: .color(.cyan.opacity(alpha)))
context.draw(
Text("R\(runRound)")
.font(.system(size: settings.scaled(9), weight: .heavy, design: .monospaced))
.foregroundColor(.cyan.opacity(0.7)),
at: CGPoint(x: rect.midX, y: rect.midY)
)
}
runStart = i
}
i += 1
}
_ = time
}
/// Final-scene "convergence" badge confirming Aaron's line equals Ben's
/// equals Carl's. The Crisis paper's central guarantee, rendered as a
/// stamp.
private func drawConvergenceBadge(
in context: inout GraphicsContext, size: CGSize,
ordered: [VertexData], time: Double
) {
// Emerge after a beat; pulse subtly.
let appear = max(0, min(1, (time - 1.5) / 1.0))
if appear < 0.05 { return }
let pulse = 0.5 + 0.5 * sin(time * 1.6)
let badgeW: CGFloat = 460
let badgeH: CGFloat = 64
let badgeRect = CGRect(
x: size.width / 2 - badgeW / 2,
y: 70,
width: badgeW, height: badgeH
)
context.fill(RoundedRectangle(cornerRadius: 14).path(in: badgeRect),
with: .color(.black.opacity(0.7 * appear)))
context.stroke(RoundedRectangle(cornerRadius: 14).path(in: badgeRect),
with: .color(.green.opacity(0.6 * appear * (0.7 + 0.3 * pulse))),
lineWidth: 2)
context.draw(
Text("AARON'S LINE = BEN'S LINE = CARL'S LINE")
Text("✓ ALL VALIDATORS COMPUTE THE SAME TOTAL ORDER — CONVERGENCE")
.font(.system(size: settings.scaled(13), weight: .heavy, design: .monospaced))
.foregroundColor(.green.opacity(0.95 * appear)),
at: CGPoint(x: badgeRect.midX, y: badgeRect.midY - 8)
)
context.draw(
Text("\(ordered.count) POSITIONS · IDENTICAL · DETERMINISTIC")
.font(.system(size: settings.scaled(10), weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.65 * appear)),
at: CGPoint(x: badgeRect.midX, y: badgeRect.midY + 14)
.foregroundColor(.green.opacity(0.95 * alpha)),
at: CGPoint(x: size.width / 2, y: 40)
)
}
/// True iff `ordered[index]` is the FIRST occurrence of `role`'s
/// process id in the ordered list. Used so the cast name labels only
/// land on the lead's earliest entry, not every entry.
private func isFirstAppearance(of role: CastRole, in ordered: [VertexData], atIndex index: Int) -> Bool {
guard let pid = dm.castByPid.first(where: { $0.value.id == role.id })?.key else { return false }
guard ordered[index].processIdHex == pid else { return false }
for j in 0..<index where ordered[j].processIdHex == pid {
return false
}
return true
private func drawBeatTag(
in context: inout GraphicsContext, size: CGSize, world: Ch06WorldState
) {
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,165 @@
import SwiftUI
/// Ch06 "Spokespersons line up. Everyone else falls in behind." (Total order.)
///
/// With round leaders chosen in Ch05, the chapter walks the chain of
/// leaders and adds each leader's round-N ancestor closure to a single
/// linear sequence the total order. Visualized as an "ordering snake"
/// at the bottom of the canvas: blocks slide into positions 1, 2, 3,
/// in topological order.
enum Ch06BeatKind {
case settle(label: String)
case carryForward
/// Slide a single message into the next position of the ordering snake.
case appendToOrder(messageId: String)
/// "ROUND N ORDERED" badge on the snake.
case roundOrderedBadge(round: Int)
/// Final convergence emphasis.
case finalConvergence
}
struct Ch06Beat: Identifiable {
let id: String
let kind: Ch06BeatKind
let durationSeconds: Double
let narration: String
var startTime: Double = 0
var endTime: Double { startTime + durationSeconds }
}
struct Ch06WorldState {
/// Total-order snake: messages in the order they've been ordered.
var order: [String] = []
/// "Round N ordered" badge alpha (0..1) per round.
var roundOrderedAlpha: [Int: Double] = [:]
var finalConvergenceAlpha: Double = 0
var activeBeat: Ch06Beat? = nil
var activeProgress: Double = 0
}
enum Ch06Timeline {
static let beats: [Ch06Beat] = {
let raw: [Ch06Beat] = [
.init(id: "carry-forward", kind: .carryForward, durationSeconds: 4.0,
narration: "From Ch05: we have round-0 leader α and round-1 leader ε. Now we use those leaders to build a single canonical sequence — the total order — that every honest validator agrees on."),
.init(id: "intro-totalorder", kind: .settle(label: "Total order via leaders"),
durationSeconds: 5.0,
narration: "Algorithm: walk the chain of round leaders. For each leader, add the leader's round-N ancestor closure to the order in topological sequence. Look at the bottom of the canvas — the ordering snake will fill in left to right."),
// Round 0: α, β, γ, δ topological order based on parent edges
.init(id: "append-alpha", kind: .appendToOrder(messageId: "α"),
durationSeconds: 4.5,
narration: "α has no parents — it goes into position 1 of the snake."),
.init(id: "append-beta", kind: .appendToOrder(messageId: "β"),
durationSeconds: 4.5,
narration: "β references α as parent. α is already in the snake, so β goes into position 2."),
.init(id: "append-gamma", kind: .appendToOrder(messageId: "γ"),
durationSeconds: 4.5,
narration: "γ also references α. β and γ are siblings — they go in lexicographic order, so γ takes position 3."),
.init(id: "append-delta", kind: .appendToOrder(messageId: "δ"),
durationSeconds: 4.5,
narration: "δ references γ. γ is already in the snake at position 3, so δ goes into position 4."),
.init(id: "round-0-ordered", kind: .roundOrderedBadge(round: 0),
durationSeconds: 5.0,
narration: "Round 0 ordered. The snake holds α, β, γ, δ in positions 1 through 4. Crucially, every honest validator who has the same DAG runs the same sort and produces the same four-element prefix."),
// Round 1: ε
.init(id: "append-eps", kind: .appendToOrder(messageId: "ε"),
durationSeconds: 4.5,
narration: "Round 1 leader ε — no other round-1 messages. ε goes into position 5."),
.init(id: "round-1-ordered", kind: .roundOrderedBadge(round: 1),
durationSeconds: 4.5,
narration: "Round 1 ordered. The snake now holds the full total order: α → β → γ → δ → ε."),
.init(id: "final-convergence", kind: .finalConvergence,
durationSeconds: 6.5,
narration: "Every honest validator's snake is byte-for-byte identical. Aaron's line, Ben's line, Carl's line, Dave's line — same sequence. This is total order: convergence on the SHAPE and the SEQUENCE of history."),
.init(id: "outro", kind: .settle(label: "Ordered"),
durationSeconds: 4.0,
narration: "Crisis has converged on a single canonical sequence. Next chapters: data availability — what happens when a leader knows something but doesn't share it."),
]
var t: Double = 0
var assigned: [Ch06Beat] = []
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) -> Ch06Beat? {
let clamped = max(0, min(t, totalDuration))
return beats.first { $0.startTime <= clamped && clamped < $0.endTime }
?? beats.last
}
static func state(at t: Double) -> Ch06WorldState {
var w = Ch06WorldState()
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: Ch06Beat, progress: Double, isActive: Bool,
into w: inout Ch06WorldState
) {
switch beat.kind {
case .settle, .carryForward:
break
case .appendToOrder(let mid):
// Permanent once the beat starts: the message is added.
// The renderer animates the slide-in based on activeProgress.
if !w.order.contains(mid) {
w.order.append(mid)
}
case .roundOrderedBadge(let r):
w.roundOrderedAlpha[r] = isActive ? progress : 1.0
case .finalConvergence:
w.finalConvergenceAlpha = isActive ? progress : 1.0
}
}
}
enum Ch06Scenes {
/// 3 scenes mapping to ~51.5s of timeline at 1×.
static let sceneStarts: [Double] = [0, 22.5, 36.5]
static let sceneDurations: [Double] = [22.5, 14, 15]
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 Ch06Timeline.activeBeat(at: t)?.narration ?? ""
}
}

View file

@ -76,6 +76,10 @@ final class SceneEngine {
// Ch05 leader (2 scenes)
SceneAddress(chapter: 5, scene: 0): 29.0,
SceneAddress(chapter: 5, scene: 1): 18.5,
// Ch06 total order (3 scenes)
SceneAddress(chapter: 6, scene: 0): 22.5,
SceneAddress(chapter: 6, scene: 1): 14.0,
SceneAddress(chapter: 6, scene: 2): 15.0,
// Ch09 Byzantine (2 scenes mapping to Ch09Timeline windows)
SceneAddress(chapter: 9, scene: 0): 47.5,
SceneAddress(chapter: 9, scene: 1): 32.0,

View file

@ -168,6 +168,8 @@ struct ImmersiveView: View {
return Ch04Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
case 5:
return Ch05Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
case 6:
return Ch06Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
case 9:
return Ch09Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
default: