返回 CodeWhale
PetSim.kt
根目录 / pet / android / PetSim.kt
1 // PetSim.kt — the Codewhale pet core, Kotlin/Android port.
2 //
3 // A faithful port of PetSim.ts. Same 980-point body from whale-points.tsv,
4 // same mulberry32(0xC0FFEE) jitter, same gait field and spring integration,
5 // same colour/hollow/brightness encoding. Pure Kotlin + java.lang.Math —
6 // no Android APIs, so it runs on the JVM for tests and in the app for rendering.
7 //
8 // Compile-verified offline with the cached Kotlin 2.3.0 JVM compiler.
9 // android/verify.sh compares all four tapes/modes against TypeScript.
10
11 package codewhale.pet
12
13 import kotlin.math.*
14
15 data class PetState(
16 var activity: Double = 0.35,
17 var coherence: Double = 0.8,
18 var attention: Double = 0.0,
19 var channel: String = "reasoning",
20 var observed: Double = 1.0,
21 var roamX: Double = 0.0,
22 var roamY: Double = 0.0,
23 var flip: Double = 1.0,
24 var lit: Double = 1.0,
25 )
26
27 data class Channel(val key: String, val label: String, val r: Int, val g: Int, val b: Int,
28 val arch: String, val form: String)
29
30 val CHANNELS = listOf(
31 Channel("reasoning", "Model / reasoning", 0x73, 0xc9, 0xb5, "gyre", "gyre · rolling"),
32 Channel("tool", "Tool calls", 0x74, 0xaa, 0xdd, "strike", "strike · reaching"),
33 Channel("memory", "Memory / RAG", 0xb6, 0xa7, 0x7f, "gyre", "gyre · scanning"),
34 Channel("code", "Code execution", 0x9b, 0x9e, 0xd7, "strike", "strike · along the body"),
35 Channel("filesystem", "Filesystem", 0x92, 0xb9, 0xc9, "strike", "strike · fanning"),
36 Channel("network", "Network / API", 0xd3, 0xac, 0x74, "cross", "crossing · one way"),
37 Channel("browser", "Browser / computer", 0x9e, 0xa9, 0xdf, "cross", "crossing · a sweep"),
38 Channel("communication", "Agent messages", 0x83, 0xc5, 0xc9, "cross", "crossing · two ways"),
39 Channel("agent", "Subagent activity", 0xb0, 0x9a, 0xcb, "pod", "pod · peers"),
40 Channel("orchestration", "Orchestration", 0x6c, 0x87, 0x98, "pod", "pod · hub"),
41 Channel("error", "Errors / exceptions", 0xe7, 0x91, 0x86, "tear", "torn · irregular"),
42 Channel("human", "Human interaction", 0xc2, 0xb7, 0x87, "address", "decision · junction"),
43 Channel("other", "Unclassified", 0x73, 0x84, 0x92, "drift", "drifting · unformed"),
44 )
45
46 val CHANNEL_INDEX = CHANNELS.mapIndexed { i, c -> c.key to i }.toMap()
47
48 fun archOf(key: String) = when (key) {
49 "reasoning", "memory" -> "gyre"
50 "tool", "code", "filesystem" -> "strike"
51 "network", "communication", "browser" -> "cross"
52 "agent", "orchestration" -> "pod"
53 "error" -> "tear"
54 "human" -> "address"
55 else -> "drift"
56 }
57
58 private val UNKNOWN_RGB = doubleArrayOf(115.0, 132.0, 146.0)
59 private val REST_RGB = doubleArrayOf(122.0, 214.0, 240.0)
60 // Keep the native checkpoint boundary aligned with the shared world clock.
61 private const val PET_MAX_SECONDS = 100.0 * 365.0 * 86_400.0
62
63 private fun lerp(a: Double, b: Double, t: Double) = a + (b - a) * t
64 private fun clamp(v: Double, lo: Double = 0.0, hi: Double = 1.0) = min(hi, max(lo, v))
65
66 /** mulberry32 — the same 32-bit sequence as every other port. */
67 class Mulberry32(seed: Int) {
68 private var a = seed
69 fun next(): Double {
70 a += 0x6D2B79F5.toInt()
71 var t = a
72 t = (t xor t.ushr(15)) * (t or 1)
73 t = t xor (t + (t xor t.ushr(7)) * (t or 61))
74 return (t xor t.ushr(14)).toUInt().toDouble() / 4294967296.0
75 }
76 }
77
78 class Particle {
79 var x = 0.0; var y = 0.0; var vx = 0.0; var vy = 0.0
80 var s = 0.0; var jx = 0.0; var jy = 0.0
81 var pod = 0
82 var hx = 0.0; var hy = 0.0; var ang = 0.0; var rad = 0.0; var tail = 0.0
83 var tx = 0.0; var ty = 0.0
84 }
85
86 data class Frame(
87 var r: Double = 122.0, var g: Double = 214.0, var b: Double = 240.0,
88 var alpha: Double = 0.3, var hollow: Boolean = false,
89 var channel: String = "reasoning", var arch: String = "gyre", var work: Double = 0.0,
90 )
91
92 // Version 2; the same field math as TypeScript, Swift and Rust.
93 private fun fieldTarget(q: Particle, t: Double, act: Double, att: Double, key: String): Pair<Double, Double>? {
94 val u = q.s * 2 - 1; val lane = q.pod - 2.5; val a = q.s * PI * 2
95 val flow = t * (0.35 + act * 0.65)
96 return when (key) {
97 "reasoning" -> {
98 val ring = 0.34 + 0.105 * cos(a * 3 + flow + lane * 0.18)
99 ring * cos(a * 2 + flow * 0.3) to ring * sin(a * 2 + flow * 0.3) * 0.7 + 0.10 * sin(a * 3 + flow)
100 }
101 "memory" -> 0.46 * cos(a + lane * 0.1 + flow * 0.25) to lane * 0.082 + 0.052 * sin(a * 2 + flow)
102 "code" -> u * 0.57 to lane * 0.066 + 0.12 * sin(u * 7 + flow * 2 + q.pod * PI / 3)
103 "filesystem" -> {
104 val branch = max(0.0, (u + 0.3) / 1.3)
105 u * 0.56 to lane * 0.13 * branch + 0.025 * sin(u * 8 - flow)
106 }
107 "tool" -> {
108 val reach = 0.14 + (u + 1) * 0.20 + 0.04 * sin(flow * 3 - u * 4)
109 cos(q.pod * PI / 3) * reach to sin(q.pod * PI / 3) * reach * 0.8 + q.hy * 0.06
110 }
111 "browser" -> u * 0.56 to lane * 0.083 + 0.035 * sin(u * 5 - flow * 2)
112 "network", "communication" -> {
113 val direction = if (key == "communication" && q.pod % 2 == 1) -1.0 else 1.0
114 val phase = a + flow * direction
115 0.54 * cos(phase) to sin(phase) * (0.12 + q.pod * 0.035) + lane * 0.024
116 }
117 "human" -> {
118 val gap = if (u < 0) -0.075 else 0.075
119 u * 0.47 + gap to lane * 0.10 * abs(u) + 0.012 * sin(flow + a) * (1 - att)
120 }
121 else -> null
122 }
123 }
124
125 /** Version 1 is retained for saved recordings. */
126 private fun gaitTarget(q: Particle, t: Double, act: Double, coh: Double,
127 att: Double, key: String, work: Double, expressionVersion: Int = 1,
128 podSlots: List<Pair<Int, Double>>? = null): Pair<Double, Double> {
129 val omega = lerp(4.6, 5.2 + act * 2.8, work)
130 val breath = 1 + sin(t * 1.85) * lerp(0.048, 0.018, work)
131 val flex = sin(q.ang * 2.05 + t * omega) * lerp(0.042, 0.016 + act * 0.028, work) * (0.18 + 0.82 * q.tail)
132 var px = cos(q.ang + flex) * q.rad * breath
133 var py = sin(q.ang + flex) * q.rad * breath
134 px += sin(t * 0.33) * lerp(0.030, 0.014, work)
135 py += cos(t * 0.21) * lerp(0.018, 0.010, work)
136 if (work < 0.02) return px to py
137
138 var gx = px; var gy = py
139 when (archOf(key)) {
140 "gyre" -> if (key == "memory") {
141 val pulse = 1 + sin(t * (2.4 + act * 1.6) - q.rad * 11) * (0.15 + act * 0.10)
142 gx *= pulse; gy *= pulse
143 } else {
144 val roll = sin(t * (1.05 + act * 0.35)) * (0.48 + act * 0.32)
145 val c = cos(roll); val sn = sin(roll)
146 gx = px * c - py * sn * 0.88
147 gy = px * sn * 0.88 + py * c
148 }
149 "strike" -> if (key == "tool") {
150 val rate = 2.7 + act * 2.1
151 val lunge = max(0.0, sin(t * rate)).pow(2)
152 gx += lunge * 0.11
153 if (q.s > 0.60) {
154 val reach = max(0.0, sin(t * rate + q.pod * 0.92)).pow(4) * (0.30 + act * 0.24)
155 gx += cos(q.ang) * reach
156 gy += sin(q.ang) * reach
157 }
158 } else if (key == "code") {
159 val rate = 3.2 + act * 1.8
160 val wave = sin(t * rate - q.tail * 7.5)
161 val bump = 0.11 + act * 0.08
162 gx += cos(q.ang) * wave * bump
163 gy += sin(q.ang) * wave * bump * 1.2
164 gx += max(0.0, wave) * 0.07
165 } else {
166 val rate = 2.15 + act * 1.5
167 val side = (q.pod % 2) * 2 - 1
168 val w = max(0.0, sin(t * rate + q.pod * 0.72)).pow(2)
169 gx += w * 0.055
170 gy += side * w * (0.17 + act * 0.13)
171 }
172 "cross" -> if (key == "browser") {
173 val band = (t * (0.55 + act * 0.35)) % 1 * 1.28 - 0.64
174 val inBand = max(0.0, 1 - abs(q.hy - band) / 0.08)
175 gx += inBand * (0.24 + act * 0.10)
176 gy += inBand * 0.02
177 } else {
178 val two = key == "communication"
179 if (q.s < if (two) 0.44 else 0.32) {
180 val dir = if (two) (if (q.s < 0.22) 1.0 else -1.0) else 1.0
181 val u = (t * (0.38 + act * 0.36) + q.s * 5.2) % 1
182 val going = if (u < 0.5) u * 2 else 2 - u * 2
183 val e = going * going * (3 - 2 * going)
184 gx = lerp(q.hx, dir * 0.80, e)
185 gy = q.hy * (1 - e * 0.38) + sin(going * PI) * 0.11 * dir
186 }
187 }
188 "pod" -> {
189 val n = 6
190 val member = podSlots?.takeIf { it.isNotEmpty() }?.let { it[q.pod % it.size] }
191 val k = member?.first ?: (q.pod % n)
192 val hub = key == "orchestration" && k == 0
193 val spread = 0.30 + act * 0.11
194 val orbit = t * (0.55 + act * 0.28)
195 if (hub) { gx = px * 0.70; gy = py * 0.70 }
196 else {
197 val slots = if (key == "orchestration") n - 1 else n
198 val a = (if (key == "orchestration") k - 1 else k) * (PI * 2 / slots) + orbit + (member?.second ?: 0.0) * 0.04
199 val sc = 0.34
200 gx = q.hx * sc + cos(a) * spread * 1.28
201 gy = q.hy * sc + sin(a) * spread * 0.80
202 }
203 }
204 "tear" -> {
205 val side = if (q.hx + q.hy < 0) -1.0 else 1.0
206 gx += side * (0.24 + (1 - coh) * 0.16)
207 gy += side * 0.15
208 gx += sin(t * 11.4 + q.s * 40) * (0.045 + act * 0.05)
209 gy += cos(t * 9.2 + q.s * 31) * (0.040 + act * 0.045)
210 }
211 "address" -> {
212 val face = 0.90 + att * 0.08
213 val th = 0.70
214 val z = (q.s - 0.5) * 0.42
215 var ax = q.hx * cos(th) + z * sin(th)
216 var ay = q.hy
217 val disc = 0.48 * face
218 ax = lerp(ax, cos(q.ang) * min(0.36, q.rad + 0.06) * 0.95, disc)
219 ay = lerp(ay, sin(q.ang) * min(0.36, q.rad + 0.06) * 1.08, disc)
220 val grow = 1.20 + sin(t * 1.65) * 0.055
221 gx = ax * grow; gy = ay * grow
222 }
223 else -> {
224 val mill = 0.13 + (1 - coh) * 0.10
225 gx = q.hx * 0.52 + sin(t * 0.72 + q.jx) * mill
226 gy = q.hy * 0.52 + cos(t * 0.54 + q.jy) * mill
227 }
228 }
229 if (expressionVersion == 2) fieldTarget(q, t, act, att, key)?.let { gx = it.first; gy = it.second }
230 return lerp(px, gx, work) to lerp(py, gy, work)
231 }
232
233 private fun stillT(key: String) = when (key) {
234 "reasoning" -> 1.15; "memory" -> 0.42; "tool" -> 0.30; "code" -> 0.18
235 "filesystem" -> 0.48; "network" -> 0.72; "browser" -> 0.95
236 "communication" -> 0.58; "agent" -> 1.25; "orchestration" -> 0.85
237 "error" -> 0.35; "human" -> 0.05; "other" -> 0.90; else -> 0.4
238 }
239
240 data class PetParticleCheckpoint(
241 val version: Int, val expressionVersion: Int, val body: List<List<Double>>,
242 val particles: List<List<Double>>, val phase: Double, val clock: Double,
243 val tear: Double, val previous: Int, val current: Int, val color: List<Double>, val frame: Frame,
244 )
245
246 class PetSim(points: List<Pair<Double, Double>>, seed: Int = 0xC0FFEE.toInt(), expressionVersion: Int = 2) {
247 var expressionVersion = expressionVersion; private set
248 val p: List<Particle>
249 private var phase = 0.0
250 private var clock = 0.0
251 private var tear = 0.0
252 private var prev: Int
253 private val col = REST_RGB.copyOf()
254 private var cur: Int
255 var frame = Frame(); private set
256
257 init {
258 require(expressionVersion == 1 || expressionVersion == 2)
259 val rng = Mulberry32(seed)
260 p = points.mapIndexed { i, (hx, hy) ->
261 Particle().apply {
262 this.hx = hx; this.hy = hy
263 x = hx; y = hy; tx = hx; ty = hy
264 s = rng.next(); jx = rng.next() * 6.283; jy = rng.next() * 6.283
265 pod = i % 6
266 ang = atan2(hy, hx)
267 rad = hypot(hx, hy)
268 tail = clamp(((-hx - hy) * 0.5 + 0.22) / 0.62)
269 }
270 }
271 cur = CHANNEL_INDEX["reasoning"]!!
272 prev = cur
273 }
274
275 /** The shared world validates the complete recording first. This projection
276 * also checks body identity and bounds before changing any native particle. */
277 fun restoreValidated(c: PetParticleCheckpoint) {
278 fun finite(v: Double, lo: Double, hi: Double) = v.isFinite() && v in lo..hi
279 require(c.version == 1 && c.expressionVersion in 1..2 && c.body.size == p.size && c.particles.size == p.size)
280 require(c.body.withIndex().all { (i, b) -> b == listOf(p[i].hx, p[i].hy, p[i].s) })
281 require(c.particles.all { row -> row.size == 8 && row.withIndex().all { (i, v) ->
282 finite(v, if (i == 4 || i == 5) 0.0 else -8.0, if (i == 4 || i == 5) 2 * PET_MAX_SECONDS else 8.0)
283 } })
284 require(finite(c.phase, 0.0, PET_MAX_SECONDS) && finite(c.clock, 0.0, PET_MAX_SECONDS) && finite(c.tear, 0.0, 1.0))
285 require(c.previous in CHANNELS.indices && c.current in CHANNELS.indices && c.color.size == 3 && c.color.all { finite(it, 0.0, 255.0) })
286 require(listOf(c.frame.r, c.frame.g, c.frame.b).all { finite(it, 0.0, 255.0) } && finite(c.frame.alpha, 0.0, 1.0) && finite(c.frame.work, 0.0, 1.0))
287 require(c.frame.channel == CHANNELS[c.current].key && c.frame.arch == CHANNELS[c.current].arch)
288 expressionVersion = c.expressionVersion
289 phase = c.phase; clock = c.clock; tear = c.tear; prev = c.previous; cur = c.current
290 c.color.forEachIndexed { i, v -> col[i] = v }; frame = c.frame.copy()
291 p.forEachIndexed { i, q ->
292 val v = c.particles[i]
293 q.x = v[0]; q.y = v[1]; q.vx = v[2]; q.vy = v[3]
294 q.jx = v[4]; q.jy = v[5]; q.tx = v[6]; q.ty = v[7]
295 }
296 }
297
298 /** Advance the sim by dt seconds under `state`. Identical math to PetSim.ts. */
299 fun step(dt: Double, state: PetState, motion: Boolean = true, sensitivity: Double = 1.0,
300 podSlots: List<Pair<Int, Double>>? = null) {
301 fun s(v: Double) = lerp(0.5, v, sensitivity)
302 val act = s(state.activity); val coh = s(state.coherence); val att = s(state.attention)
303 val seen = s(state.observed)
304 phase += dt * (0.18 + act * 0.55) * (if (motion) 1.0 else 0.0)
305 if (motion) clock += dt
306
307 CHANNEL_INDEX[state.channel]?.let { cur = it }
308 val shown = cur
309 val ch = CHANNELS[shown]
310
311 val work = clamp((act - 0.16) / 0.18)
312 val wander = lerp(0.32, 1.0, (1 - coh).pow(1.15))
313
314 if (shown != prev) {
315 if (shown == CHANNEL_INDEX["error"]) tear = 1.0
316 prev = shown
317 }
318 tear = if (motion) max(0.0, tear - dt * 1.6) else 0.0
319
320 val split = (1 - coh).pow(1.6) * 0.16 + tear * 0.10
321 val blur = (1 - coh).pow(1.45) * 0.22 + tear * 0.18
322 val pull = if (motion) 2.2 + coh * 5.2 else 18.0
323 val tGait = if (motion) clock else stillT(ch.key)
324
325 for (q in p) {
326 if (motion) {
327 q.jx += dt * (0.40 + act * 1.1)
328 q.jy += dt * (0.34 + act * 0.9)
329 }
330 val (gx, gy) = gaitTarget(q, tGait, act, coh, att, ch.key, work, expressionVersion, podSlots)
331 val podAng = q.pod * 1.047 + phase * 0.22
332 val tx = gx + sin(q.jx + q.s * 9) * blur * wander + cos(podAng) * split
333 val ty = gy + cos(q.jy + q.s * 7) * blur * wander + sin(podAng) * split * 0.55
334 q.tx = tx; q.ty = ty
335 if (!motion) { q.x = tx; q.y = ty; q.vx = 0.0; q.vy = 0.0; continue }
336 q.vx += (tx - q.x) * pull * dt
337 q.vy += (ty - q.y) * pull * dt
338 q.vx *= 0.90; q.vy *= 0.90
339 val speed = if (motion) 2.6 else 8.0
340 q.x += q.vx * dt * speed
341 q.y += q.vy * dt * speed
342 }
343
344 val want = if (work > 0.35)
345 doubleArrayOf(CHANNELS[shown].r.toDouble(), CHANNELS[shown].g.toDouble(), CHANNELS[shown].b.toDouble())
346 else REST_RGB
347 val k = if (motion) min(1.0, dt * 2.6) else 1.0
348 for (c in 0..2) col[c] += (lerp(UNKNOWN_RGB[c], want[c], seen) - col[c]) * k
349 val lit = clamp(state.lit)
350 val alpha = (0.22 + act * 0.10) * lerp(0.50, 1.0, coh) * lerp(0.55, 1.0, seen) * lerp(0.35, 1.0, lit)
351 frame = Frame(col[0], col[1], col[2],
352 alpha = min(0.92, alpha * 1.85),
353 hollow = seen < 0.92,
354 channel = ch.key, arch = ch.arch, work = work)
355 }
356 }
357
358 fun petDigest(sim: PetSim): String {
359 val grid = IntArray(64 * 32)
360 for (p in sim.p) {
361 val x = floor((p.x + 0.66) / 1.32 * 64).toInt()
362 val y = floor((p.y + 0.66) / 1.32 * 32).toInt()
363 if (x in 0..63 && y in 0..31) grid[y * 64 + x] = min(255, grid[y * 64 + x] + 1)
364 }
365 var hash = 0xcbf29ce484222325UL.toLong()
366 fun mix(n: Int) { hash = (hash xor (n and 255).toLong()) * 0x100000001b3L }
367 for (n in grid) mix(n)
368 mix(sim.frame.r.roundToInt()); mix(sim.frame.g.roundToInt()); mix(sim.frame.b.roundToInt())
369 mix((sim.frame.alpha * 255).roundToInt()); mix(if (sim.frame.hollow) 1 else 0)
370 return hash.toULong().toString(16).padStart(16, '0')
371 }
372
373 /** Body-space → renderer-space, same as PetSim.ts layout(). */
374 data class PetLayout(val scale: Double, val flipX: Double, val ox: Double, val oy: Double, val dot: Double)
375 fun petLayout(w: Double, h: Double, state: PetState): PetLayout {
376 val att = state.attention
377 return PetLayout(
378 scale = min(w * 0.52, h * 0.92) * (1 + att * 0.07),
379 flipX = state.flip,
380 ox = w / 2 + state.roamX * w * 0.30,
381 oy = h / 2 + state.roamY * h * 0.30 + h * att * 0.05,
382 dot = max(1.6, min(w, h) * 0.0092) * (1 + att * 0.18))
383 }
384
384 lines Plain Text