返回 CodeWhale
PetSim.swift
根目录 / pet / swift / PetSim.swift
1 // PetSim.swift — the Codewhale pet core, Swift port.
2 //
3 // A faithful, dependency-free port of PetSim.ts. Same 980-point body from
4 // whale-points.tsv, same mulberry32(0xC0FFEE) jitter, same gait field and
5 // spring integration, same colour/hollow/brightness encoding. Pure
6 // Foundation — works on iOS, macOS, and Linux Swift alike.
7
8 import Foundation
9
10 public struct PetState: Codable, Equatable {
11 public var activity: Double = 0.35 // how much work 0..1
12 public var coherence: Double = 0.8 // converging school vs thrashing
13 public var attention: Double = 0 // interaction salience
14 public var channel: String = "reasoning"
15 public var observed: Double = 1 // instrumentation coverage
16 public var roamX: Double = 0 // tank position -1..1
17 public var roamY: Double = 0
18 public var flip: Double = 1 // 1 faces right, -1 left, 0 edge-on
19 public var lit: Double = 1 // sleep dimmer
20 public init() {}
21 }
22
23 public struct Channel {
24 public let key: String
25 public let label: String
26 public let rgb: (Double, Double, Double)
27 public let arch: String
28 public let form: String
29 }
30
31 public let CHANNELS: [Channel] = [
32 Channel(key: "reasoning", label: "Model / reasoning", rgb: (0x73, 0xc9, 0xb5), arch: "gyre", form: "gyre · rolling"),
33 Channel(key: "tool", label: "Tool calls", rgb: (0x74, 0xaa, 0xdd), arch: "strike", form: "strike · reaching"),
34 Channel(key: "memory", label: "Memory / RAG", rgb: (0xb6, 0xa7, 0x7f), arch: "gyre", form: "gyre · scanning"),
35 Channel(key: "code", label: "Code execution", rgb: (0x9b, 0x9e, 0xd7), arch: "strike", form: "strike · along the body"),
36 Channel(key: "filesystem", label: "Filesystem", rgb: (0x92, 0xb9, 0xc9), arch: "strike", form: "strike · fanning"),
37 Channel(key: "network", label: "Network / API", rgb: (0xd3, 0xac, 0x74), arch: "cross", form: "crossing · one way"),
38 Channel(key: "browser", label: "Browser / computer", rgb: (0x9e, 0xa9, 0xdf), arch: "cross", form: "crossing · a sweep"),
39 Channel(key: "communication", label: "Agent messages", rgb: (0x83, 0xc5, 0xc9), arch: "cross", form: "crossing · two ways"),
40 Channel(key: "agent", label: "Subagent activity", rgb: (0xb0, 0x9a, 0xcb), arch: "pod", form: "pod · peers"),
41 Channel(key: "orchestration", label: "Orchestration", rgb: (0x6c, 0x87, 0x98), arch: "pod", form: "pod · hub"),
42 Channel(key: "error", label: "Errors / exceptions", rgb: (0xe7, 0x91, 0x86), arch: "tear", form: "torn · irregular"),
43 Channel(key: "human", label: "Human interaction", rgb: (0xc2, 0xb7, 0x87), arch: "address", form: "decision · junction"),
44 Channel(key: "other", label: "Unclassified", rgb: (0x73, 0x84, 0x92), arch: "drift", form: "drifting · unformed"),
45 ]
46
47 public func channelIndex(_ key: String) -> Int? { CHANNELS.firstIndex { $0.key == key } }
48
49 func archOf(_ key: String) -> String {
50 switch key {
51 case "reasoning", "memory": return "gyre"
52 case "tool", "code", "filesystem": return "strike"
53 case "network", "communication", "browser": return "cross"
54 case "agent", "orchestration": return "pod"
55 case "error": return "tear"
56 case "human": return "address"
57 default: return "drift"
58 }
59 }
60
61 let UNKNOWN_RGB = (115.0, 132.0, 146.0)
62 let REST_RGB = (122.0, 214.0, 240.0)
63
64 @inline(__always) func lerp(_ a: Double, _ b: Double, _ t: Double) -> Double { a + (b - a) * t }
65 @inline(__always) func clamp01(_ v: Double) -> Double { min(1, max(0, v)) }
66
67 /// mulberry32 — the same 32-bit sequence as every other port.
68 public struct Mulberry32 {
69 var a: UInt32
70 public init(seed: UInt32) { a = seed }
71 public mutating func next() -> Double {
72 a = a &+ 0x6D2B79F5
73 var t = a
74 t = (t ^ (t >> 15)) &* (t | 1)
75 t = t ^ (t &+ ((t ^ (t >> 7)) &* (t | 61)))
76 return Double(t ^ (t >> 14)) / 4294967296.0
77 }
78 }
79
80 public struct Particle {
81 public var x: Double = 0, y: Double = 0, vx: Double = 0, vy: Double = 0
82 public var s: Double = 0, jx: Double = 0, jy: Double = 0
83 public var pod: Int = 0
84 public var hx: Double = 0, hy: Double = 0, ang: Double = 0, rad: Double = 0, tail: Double = 0
85 public var tx: Double = 0, ty: Double = 0
86 public init() {}
87 }
88
89 public struct Frame: Codable {
90 public var r: Double = 122, g: Double = 214, b: Double = 240
91 public var alpha: Double = 0.3
92 public var hollow: Bool = false
93 public var channel: String = "reasoning"
94 public var arch: String = "gyre"
95 public var work: Double = 0
96 public init() {}
97 public init(r: Double, g: Double, b: Double, alpha: Double, hollow: Bool,
98 channel: String, arch: String, work: Double) {
99 self.r = r; self.g = g; self.b = b; self.alpha = alpha
100 self.hollow = hollow; self.channel = channel; self.arch = arch; self.work = work
101 }
102 }
103
104 /// The canonical JavaScript checkpoint is validated before this projection is
105 /// decoded. Swift restores its existing particle renderer from that same state.
106 struct PetParticleCheckpoint: Decodable {
107 let version: Int
108 let expressionVersion: Int?
109 let body: [[Double]], particles: [[Double]]
110 let phase: Double, clock: Double, tear: Double
111 let previous: Int, current: Int, color: [Double]
112 let frame: Frame
113 }
114
115 // Version 2 expresses work as fields while preserving seeded particle identity.
116 func fieldTarget(_ q: Particle, _ t: Double, _ act: Double, _ att: Double, _ key: String) -> (Double, Double)? {
117 let u = q.s * 2 - 1, lane = Double(q.pod) - 2.5, a = q.s * Double.pi * 2
118 let flow = t * (0.35 + act * 0.65)
119 switch key {
120 case "reasoning":
121 let ring = 0.34 + 0.105 * cos(a * 3 + flow + lane * 0.18)
122 return (ring * cos(a * 2 + flow * 0.3), ring * sin(a * 2 + flow * 0.3) * 0.7 + 0.10 * sin(a * 3 + flow))
123 case "memory": return (0.46 * cos(a + lane * 0.1 + flow * 0.25), lane * 0.082 + 0.052 * sin(a * 2 + flow))
124 case "code": return (u * 0.57, lane * 0.066 + 0.12 * sin(u * 7 + flow * 2 + Double(q.pod) * Double.pi / 3))
125 case "filesystem":
126 let branch = max(0, (u + 0.3) / 1.3)
127 return (u * 0.56, lane * 0.13 * branch + 0.025 * sin(u * 8 - flow))
128 case "tool":
129 let reach = 0.14 + (u + 1) * 0.20 + 0.04 * sin(flow * 3 - u * 4)
130 return (cos(Double(q.pod) * Double.pi / 3) * reach, sin(Double(q.pod) * Double.pi / 3) * reach * 0.8 + q.hy * 0.06)
131 case "browser": return (u * 0.56, lane * 0.083 + 0.035 * sin(u * 5 - flow * 2))
132 case "network", "communication":
133 let direction = key == "communication" && q.pod % 2 == 1 ? -1.0 : 1.0
134 let phase = a + flow * direction
135 return (0.54 * cos(phase), sin(phase) * (0.12 + Double(q.pod) * 0.035) + lane * 0.024)
136 case "human":
137 let gap = u < 0 ? -0.075 : 0.075
138 return (u * 0.47 + gap, lane * 0.10 * abs(u) + 0.012 * sin(flow + a) * (1 - att))
139 default: return nil
140 }
141 }
142
143 /// Version 1 is retained for saved recordings.
144 /// Ported line-for-line from PetSim.ts gaitTarget().
145 func gaitTarget(_ q: Particle, _ t: Double, _ act: Double, _ coh: Double,
146 _ att: Double, _ key: String, _ work: Double, _ podSlots: [(Int, Double)]? = nil, _ expressionVersion: Int = 1) -> (Double, Double) {
147 let omega = lerp(4.6, 5.2 + act * 2.8, work)
148 let breath = 1 + sin(t * 1.85) * lerp(0.048, 0.018, work)
149 let flex = sin(q.ang * 2.05 + t * omega) * lerp(0.042, 0.016 + act * 0.028, work) * (0.18 + 0.82 * q.tail)
150 var px = cos(q.ang + flex) * q.rad * breath
151 var py = sin(q.ang + flex) * q.rad * breath
152 px += sin(t * 0.33) * lerp(0.030, 0.014, work)
153 py += cos(t * 0.21) * lerp(0.018, 0.010, work)
154 if work < 0.02 { return (px, py) }
155
156 var gx = px, gy = py
157 switch archOf(key) {
158 case "gyre":
159 if key == "memory" {
160 let pulse = 1 + sin(t * (2.4 + act * 1.6) - q.rad * 11) * (0.15 + act * 0.10)
161 gx *= pulse; gy *= pulse
162 } else {
163 let roll = sin(t * (1.05 + act * 0.35)) * (0.48 + act * 0.32)
164 let c = cos(roll), sn = sin(roll)
165 gx = px * c - py * sn * 0.88
166 gy = px * sn * 0.88 + py * c
167 }
168 case "strike":
169 if key == "tool" {
170 let rate = 2.7 + act * 2.1
171 let lunge = pow(max(0, sin(t * rate)), 2)
172 gx += lunge * 0.11
173 if q.s > 0.60 {
174 let reach = pow(max(0, sin(t * rate + Double(q.pod) * 0.92)), 4) * (0.30 + act * 0.24)
175 gx += cos(q.ang) * reach
176 gy += sin(q.ang) * reach
177 }
178 } else if key == "code" {
179 let rate = 3.2 + act * 1.8
180 let wave = sin(t * rate - q.tail * 7.5)
181 let bump = 0.11 + act * 0.08
182 gx += cos(q.ang) * wave * bump
183 gy += sin(q.ang) * wave * bump * 1.2
184 gx += max(0, wave) * 0.07
185 } else {
186 let rate = 2.15 + act * 1.5
187 let side = Double(q.pod % 2) * 2 - 1
188 let w = pow(max(0, sin(t * rate + Double(q.pod) * 0.72)), 2)
189 gx += w * 0.055
190 gy += side * w * (0.17 + act * 0.13)
191 }
192 case "cross":
193 if key == "browser" {
194 let band = (t * (0.55 + act * 0.35)).truncatingRemainder(dividingBy: 1) * 1.28 - 0.64
195 let inBand = max(0, 1 - abs(q.hy - band) / 0.08)
196 gx += inBand * (0.24 + act * 0.10)
197 gy += inBand * 0.02
198 } else {
199 let two = key == "communication"
200 let courier = q.s < (two ? 0.44 : 0.32)
201 if courier {
202 let dir: Double = two ? (q.s < 0.22 ? 1 : -1) : 1
203 let u = (t * (0.38 + act * 0.36) + q.s * 5.2).truncatingRemainder(dividingBy: 1)
204 let going = u < 0.5 ? u * 2 : 2 - u * 2
205 let e = going * going * (3 - 2 * going)
206 gx = lerp(q.hx, dir * 0.80, e)
207 gy = q.hy * (1 - e * 0.38) + sin(going * .pi) * 0.11 * dir
208 }
209 }
210 case "pod":
211 let n = 6
212 let member = podSlots.flatMap { $0.isEmpty ? nil : $0[q.pod % $0.count] }
213 let k = member?.0 ?? q.pod % n
214 let hub = key == "orchestration" && k == 0
215 let spread = 0.30 + act * 0.11
216 let orbit = t * (0.55 + act * 0.28)
217 if hub {
218 gx = px * 0.70; gy = py * 0.70
219 } else {
220 let slots = key == "orchestration" ? n - 1 : n
221 let a = Double(key == "orchestration" ? k - 1 : k) * (.pi * 2 / Double(slots)) + orbit + (member.map { $0.1 * 0.04 } ?? 0)
222 let sc = 0.34
223 gx = q.hx * sc + cos(a) * spread * 1.28
224 gy = q.hy * sc + sin(a) * spread * 0.80
225 }
226 case "tear":
227 let side: Double = q.hx + q.hy < 0 ? -1 : 1
228 gx += side * (0.24 + (1 - coh) * 0.16)
229 gy += side * 0.15
230 gx += sin(t * 11.4 + q.s * 40) * (0.045 + act * 0.05)
231 gy += cos(t * 9.2 + q.s * 31) * (0.040 + act * 0.045)
232 case "address":
233 let face = 0.90 + att * 0.08
234 let th = 0.70
235 let z = (q.s - 0.5) * 0.42
236 var ax = q.hx * cos(th) + z * sin(th)
237 var ay = q.hy
238 let disc = 0.48 * face
239 ax = lerp(ax, cos(q.ang) * min(0.36, q.rad + 0.06) * 0.95, disc)
240 ay = lerp(ay, sin(q.ang) * min(0.36, q.rad + 0.06) * 1.08, disc)
241 let grow = 1.20 + sin(t * 1.65) * 0.055
242 gx = ax * grow; gy = ay * grow
243 default:
244 let mill = 0.13 + (1 - coh) * 0.10
245 gx = q.hx * 0.52 + sin(t * 0.72 + q.jx) * mill
246 gy = q.hy * 0.52 + cos(t * 0.54 + q.jy) * mill
247 }
248 if expressionVersion == 2, let field = fieldTarget(q, t, act, att, key) { gx = field.0; gy = field.1 }
249 return (lerp(px, gx, work), lerp(py, gy, work))
250 }
251
252 func stillT(_ key: String) -> Double {
253 switch key {
254 case "reasoning": return 1.15; case "memory": return 0.42; case "tool": return 0.30
255 case "code": return 0.18; case "filesystem": return 0.48; case "network": return 0.72
256 case "browser": return 0.95; case "communication": return 0.58; case "agent": return 1.25
257 case "orchestration": return 0.85; case "error": return 0.35; case "human": return 0.05
258 case "other": return 0.90; default: return 0.4
259 }
260 }
261
262 public final class PetSim {
263 public private(set) var p: [Particle]
264 public private(set) var expressionVersion: Int
265 var phase = 0.0
266 var clock = 0.0
267 var tear = 0.0
268 var prev: Int
269 var col = REST_RGB
270 var cur: Int
271 public private(set) var frame = Frame()
272
273 public init(points: [(Double, Double)], seed: UInt32 = 0xC0FFEE, expressionVersion: Int = 2) {
274 precondition(expressionVersion == 1 || expressionVersion == 2)
275 self.expressionVersion = expressionVersion
276 var rng = Mulberry32(seed: seed)
277 p = points.enumerated().map { (i, pt) in
278 var q = Particle()
279 q.hx = pt.0; q.hy = pt.1
280 q.x = pt.0; q.y = pt.1; q.tx = pt.0; q.ty = pt.1
281 q.s = rng.next(); q.jx = rng.next() * 6.283; q.jy = rng.next() * 6.283
282 q.pod = i % 6
283 q.ang = atan2(q.hy, q.hx)
284 q.rad = (q.hx * q.hx + q.hy * q.hy).squareRoot()
285 q.tail = clamp01(((-q.hx - q.hy) * 0.5 + 0.22) / 0.62)
286 return q
287 }
288 cur = channelIndex("reasoning")!
289 prev = cur
290 }
291
292 func restoreValidated(_ checkpoint: PetParticleCheckpoint) throws {
293 // Array and identity checks also protect this native boundary if its
294 // caller changes. Mutation starts only after the complete shape passes.
295 guard checkpoint.version == 1, [1, 2].contains(checkpoint.expressionVersion ?? 1), checkpoint.body.count == p.count,
296 checkpoint.particles.count == p.count, checkpoint.color.count == 3,
297 CHANNELS.indices.contains(checkpoint.previous), CHANNELS.indices.contains(checkpoint.current),
298 checkpoint.body.enumerated().allSatisfy({ i, v in
299 v.count == 3 && v[0] == p[i].hx && v[1] == p[i].hy && v[2] == p[i].s
300 }), checkpoint.particles.allSatisfy({ $0.count == 8 && $0.allSatisfy(\.isFinite) })
301 else { throw NSError(domain: "CodewhalePet", code: 1, userInfo: [NSLocalizedDescriptionKey: "The particle checkpoint does not match this whale."]) }
302 expressionVersion = checkpoint.expressionVersion ?? 1
303 phase = checkpoint.phase; clock = checkpoint.clock; tear = checkpoint.tear
304 prev = checkpoint.previous; cur = checkpoint.current
305 col = (checkpoint.color[0], checkpoint.color[1], checkpoint.color[2]); frame = checkpoint.frame
306 for i in p.indices {
307 let v = checkpoint.particles[i]
308 p[i].x = v[0]; p[i].y = v[1]; p[i].vx = v[2]; p[i].vy = v[3]
309 p[i].jx = v[4]; p[i].jy = v[5]; p[i].tx = v[6]; p[i].ty = v[7]
310 }
311 }
312
313 /// Advance the sim by dt seconds under `state`. Identical math to PetSim.ts.
314 public func step(dt: Double, state: PetState, motion: Bool = true, sensitivity: Double = 1, podSlots: [(Int, Double)]? = nil) {
315 let s: (Double) -> Double = { lerp(0.5, $0, sensitivity) }
316 let act = s(state.activity), coh = s(state.coherence), att = s(state.attention)
317 let seen = s(state.observed)
318 let mot = motion ? 1.0 : 0.0
319 phase += dt * (0.18 + act * 0.55) * mot
320 if motion { clock += dt }
321
322 if let i = channelIndex(state.channel) { cur = i }
323 let shown = cur
324 let ch = CHANNELS[shown]
325
326 let work = clamp01((act - 0.16) / 0.18)
327 let wander = lerp(0.32, 1, pow(1 - coh, 1.15))
328
329 if shown != prev {
330 if shown == channelIndex("error")! { tear = 1 }
331 prev = shown
332 }
333 tear = motion ? max(0, tear - dt * 1.6) : 0
334
335 let split = pow(1 - coh, 1.6) * 0.16 + tear * 0.10
336 let blur = pow(1 - coh, 1.45) * 0.22 + tear * 0.18
337 let pull = motion ? (2.2 + coh * 5.2) : 18.0
338 let tGait = motion ? clock : stillT(ch.key)
339
340 for i in p.indices {
341 if motion {
342 p[i].jx += dt * (0.40 + act * 1.1)
343 p[i].jy += dt * (0.34 + act * 0.9)
344 }
345 let (gx, gy) = gaitTarget(p[i], tGait, act, coh, att, ch.key, work, podSlots, expressionVersion)
346 let podAng = Double(p[i].pod) * 1.047 + phase * 0.22
347 let tx = gx + sin(p[i].jx + p[i].s * 9) * blur * wander + cos(podAng) * split
348 let ty = gy + cos(p[i].jy + p[i].s * 7) * blur * wander + sin(podAng) * split * 0.55
349 p[i].tx = tx; p[i].ty = ty
350 if !motion { p[i].x = tx; p[i].y = ty; p[i].vx = 0; p[i].vy = 0; continue }
351 p[i].vx += (tx - p[i].x) * pull * dt
352 p[i].vy += (ty - p[i].y) * pull * dt
353 p[i].vx *= 0.90; p[i].vy *= 0.90
354 let speed = motion ? 2.6 : 8.0
355 p[i].x += p[i].vx * dt * speed
356 p[i].y += p[i].vy * dt * speed
357 }
358
359 let want = work > 0.35 ? CHANNELS[shown].rgb : REST_RGB
360 let k = motion ? min(1, dt * 2.6) : 1
361 col = (col.0 + (lerp(UNKNOWN_RGB.0, want.0, seen) - col.0) * k,
362 col.1 + (lerp(UNKNOWN_RGB.1, want.1, seen) - col.1) * k,
363 col.2 + (lerp(UNKNOWN_RGB.2, want.2, seen) - col.2) * k)
364 let lit = clamp01(state.lit)
365 let alpha = (0.22 + act * 0.10) * lerp(0.50, 1, coh) * lerp(0.55, 1, seen) * lerp(0.35, 1, lit)
366 frame = Frame(r: col.0, g: col.1, b: col.2,
367 alpha: min(0.92, alpha * 1.85),
368 hollow: seen < 0.92,
369 channel: ch.key, arch: ch.arch, work: work)
370 }
371 }
372
373 /// Body-space → renderer-space, same as PetSim.ts layout().
374 public struct PetLayout {
375 public let scale: Double, flipX: Double, ox: Double, oy: Double, dot: Double
376 }
377 public func petLayout(w: Double, h: Double, state: PetState) -> PetLayout {
378 let att = state.attention
379 let scale = min(w * 0.52, h * 0.92) * (1 + att * 0.07)
380 return PetLayout(
381 scale: scale, flipX: state.flip,
382 ox: w / 2 + state.roamX * w * 0.30,
383 oy: h / 2 + state.roamY * h * 0.30 + h * att * 0.05,
384 dot: max(1.6, min(w, h) * 0.0092) * (1 + att * 0.18))
385 }
386
387 /// Conformance digest — the same 64×32 quantization + FNV-1a as every port.
388 public func petDigest(_ sim: PetSim) -> String {
389 let W = 64, H = 32
390 var grid = [UInt8](repeating: 0, count: W * H)
391 for q in sim.p {
392 let cx = Int(((q.x + 0.66) / 1.32 * Double(W)).rounded(.down))
393 let cy = Int(((q.y + 0.66) / 1.32 * Double(H)).rounded(.down))
394 if cx >= 0 && cx < W && cy >= 0 && cy < H {
395 let i = cy * W + cx
396 grid[i] = grid[i] == 255 ? 255 : grid[i] + 1
397 }
398 }
399 var h: UInt64 = 0xcbf29ce484222325
400 func mix(_ b: UInt64) { h ^= b & 0xff; h = h &* 0x100000001b3 }
401 for v in grid { mix(UInt64(v)) }
402 mix(UInt64(sim.frame.r.rounded()))
403 mix(UInt64(sim.frame.g.rounded()))
404 mix(UInt64(sim.frame.b.rounded()))
405 mix(UInt64((sim.frame.alpha * 255).rounded()))
406 mix(sim.frame.hollow ? 1 : 0)
407 return String(format: "%016llx", h)
408 }
409
409 lines Plain Text