From 5b396494aaefbaa3fa3e6cafdff84a1d234fb053 Mon Sep 17 00:00:00 2001 From: Miley Chandonnet Date: Wed, 4 Mar 2026 19:26:36 -0600 Subject: [PATCH] Add CognitiveSceneRuntime orchestration API --- docs/COGNITIVE_SCENE_RUNTIME_MIGRATION.md | 61 ++++ .../choreography/CognitiveChoreographer.kt | 14 +- .../link/socket/phosphor/field/FlowLayer.kt | 4 +- .../phosphor/runtime/CognitiveSceneRuntime.kt | 297 ++++++++++++++++++ .../phosphor/runtime/SceneConfiguration.kt | 122 +++++++ .../socket/phosphor/runtime/SceneSnapshot.kt | 181 +++++++++++ .../runtime/CognitiveSceneRuntimeTest.kt | 145 +++++++++ .../phosphor/runtime/SceneSnapshotTest.kt | 69 ++++ 8 files changed, 886 insertions(+), 7 deletions(-) create mode 100644 docs/COGNITIVE_SCENE_RUNTIME_MIGRATION.md create mode 100644 phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/CognitiveSceneRuntime.kt create mode 100644 phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/SceneConfiguration.kt create mode 100644 phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/SceneSnapshot.kt create mode 100644 phosphor-core/src/commonTest/kotlin/link/socket/phosphor/runtime/CognitiveSceneRuntimeTest.kt create mode 100644 phosphor-core/src/commonTest/kotlin/link/socket/phosphor/runtime/SceneSnapshotTest.kt diff --git a/docs/COGNITIVE_SCENE_RUNTIME_MIGRATION.md b/docs/COGNITIVE_SCENE_RUNTIME_MIGRATION.md new file mode 100644 index 0000000..0e6c2c3 --- /dev/null +++ b/docs/COGNITIVE_SCENE_RUNTIME_MIGRATION.md @@ -0,0 +1,61 @@ +# CognitiveSceneRuntime Migration Notes + +`CognitiveSceneRuntime` centralizes scene orchestration in one deterministic `update(dt)` call. + +## Before + +Consumers manually advanced every subsystem in the frame loop: + +1. choreographer +2. substrate animator +3. agent layer +4. emitters +5. particles +6. flow +7. waveform +8. camera + +## After + +Consumers call `runtime.update(dtSeconds)` and render from the returned `SceneSnapshot`. + +```kotlin +val runtime = CognitiveSceneRuntime(sceneConfiguration) + +fun onFrame(deltaSeconds: Float) { + val snapshot = runtime.update(deltaSeconds) + renderSnapshot(snapshot) +} +``` + +## Thin Adapter Pattern + +Use an adapter at the application boundary to translate runtime snapshots into your UI model. + +```kotlin +data class DashboardFrame( + val agents: List, + val particles: List, + val waveform: FloatArray?, +) + +class RuntimeAdapter( + private val runtime: CognitiveSceneRuntime, +) { + fun nextFrame(dtSeconds: Float): DashboardFrame { + val snapshot = runtime.update(dtSeconds) + return DashboardFrame( + agents = snapshot.agentStates, + particles = snapshot.particleStates, + waveform = snapshot.waveformHeightField, + ) + } +} +``` + +## Notes + +- `CognitiveSceneRuntime` is timing-agnostic. You own timers and frame pacing. +- Existing subsystem APIs remain available for direct use. +- For deterministic replay, reuse identical `SceneConfiguration.seed` and `dt` sequence. +- Disable unused systems with configuration toggles to avoid allocations and update work. diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/choreography/CognitiveChoreographer.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/choreography/CognitiveChoreographer.kt index a3581d8..bc16646 100644 --- a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/choreography/CognitiveChoreographer.kt +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/choreography/CognitiveChoreographer.kt @@ -4,6 +4,7 @@ import kotlin.math.PI import kotlin.math.cos import kotlin.math.roundToInt import kotlin.math.sin +import kotlin.random.Random import link.socket.phosphor.field.BurstEmitter import link.socket.phosphor.field.EmitterConfig import link.socket.phosphor.field.ParticleAttractor @@ -38,6 +39,7 @@ import link.socket.phosphor.signal.CognitivePhase class CognitiveChoreographer( private val particles: ParticleSystem, private val substrateAnimator: SubstrateAnimator, + private val random: Random = Random.Default, ) { private val previousPhases = mutableMapOf() @@ -205,7 +207,7 @@ class CognitiveChoreographer( // Direction points inward toward agent val inwardAngle = angle + PI // Reverse direction - val emitter = StreamEmitter() + val emitter = StreamEmitter(random) val config = EmitterConfig( type = ParticleType.MOTE, @@ -272,7 +274,7 @@ class CognitiveChoreographer( agent.position.y + sin(angle).toFloat() * PLAN_CLUSTER_RADIUS, ) - val emitter = BurstEmitter() + val emitter = BurstEmitter(random) val config = EmitterConfig( type = ParticleType.MOTE, @@ -323,7 +325,7 @@ class CognitiveChoreographer( ): SubstrateState { if (canSpawn(agent.id)) { // High-speed focused stream outward from agent - val emitter = StreamEmitter() + val emitter = StreamEmitter(random) val config = EmitterConfig( type = ParticleType.SPARK, @@ -393,7 +395,7 @@ class CognitiveChoreographer( // Spawn single ambient mote (subtle sign of life) if (canSpawn(agent.id)) { - val emitter = BurstEmitter() + val emitter = BurstEmitter(random) val config = EmitterConfig( type = ParticleType.MOTE, @@ -418,7 +420,7 @@ class CognitiveChoreographer( position: Vector2, count: Int, ) { - val emitter = BurstEmitter() + val emitter = BurstEmitter(random) val config = EmitterConfig( type = ParticleType.SPARK, @@ -433,7 +435,7 @@ class CognitiveChoreographer( private fun spawnTrailFromAgent(position: Vector2) { // Spawn trail particles radiating gently outward (executed action afterimage) - val emitter = BurstEmitter() + val emitter = BurstEmitter(random) val config = EmitterConfig( type = ParticleType.TRAIL, diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/FlowLayer.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/FlowLayer.kt index f908f0c..7488351 100644 --- a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/FlowLayer.kt +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/FlowLayer.kt @@ -1,6 +1,7 @@ package link.socket.phosphor.field import kotlin.math.roundToInt +import kotlin.random.Random import link.socket.phosphor.choreography.AgentLayer import link.socket.phosphor.math.Vector2 @@ -20,6 +21,7 @@ import link.socket.phosphor.math.Vector2 class FlowLayer( val width: Int, val height: Int, + private val random: Random = Random.Default, ) { private val connections = mutableMapOf() private var trailParticles = mutableListOf() @@ -187,7 +189,7 @@ class FlowLayer( if (currentToken != null) { // Spawn trail particle - if (kotlin.random.Random.nextFloat() < trailSpawnRate) { + if (random.nextFloat() < trailSpawnRate) { val trailParticle = Particle( position = currentToken.position, diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/CognitiveSceneRuntime.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/CognitiveSceneRuntime.kt new file mode 100644 index 0000000..6a2fe2b --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/CognitiveSceneRuntime.kt @@ -0,0 +1,297 @@ +package link.socket.phosphor.runtime + +import kotlin.random.Random +import link.socket.phosphor.choreography.AgentLayer +import link.socket.phosphor.choreography.AgentLayoutOrientation +import link.socket.phosphor.choreography.CognitiveChoreographer +import link.socket.phosphor.emitter.EmitterEffect +import link.socket.phosphor.emitter.EmitterManager +import link.socket.phosphor.field.FlowConnection +import link.socket.phosphor.field.FlowLayer +import link.socket.phosphor.field.Particle +import link.socket.phosphor.field.ParticleSystem +import link.socket.phosphor.field.SubstrateAnimator +import link.socket.phosphor.field.SubstrateState +import link.socket.phosphor.math.Vector3 +import link.socket.phosphor.render.Camera +import link.socket.phosphor.render.CameraOrbit +import link.socket.phosphor.render.CognitiveWaveform +import link.socket.phosphor.signal.AgentVisualState +import link.socket.phosphor.signal.CognitivePhase + +/** + * Deterministic orchestration API for Phosphor scene subsystems. + * + * This runtime owns the update ordering only. It does not own threading or frame timing. + */ +class CognitiveSceneRuntime( + val configuration: SceneConfiguration, +) { + val agents: AgentLayer = + AgentLayer( + width = configuration.width, + height = configuration.height, + orientation = configuration.agentLayout, + ) + + val substrateAnimator: SubstrateAnimator = SubstrateAnimator(seed = configuration.seed) + val particles: ParticleSystem? = if (configuration.enableParticles) ParticleSystem() else null + val flow: FlowLayer? = + if (configuration.enableFlow) { + FlowLayer(configuration.width, configuration.height, random = seededRandom(0x5F37_59DF)) + } else { + null + } + val waveform: CognitiveWaveform? = + if (configuration.enableWaveform) { + CognitiveWaveform( + gridWidth = configuration.waveform.gridWidth ?: configuration.width, + gridDepth = configuration.waveform.gridDepth ?: configuration.height, + worldWidth = configuration.waveform.worldWidth ?: configuration.width.toFloat(), + worldDepth = configuration.waveform.worldDepth ?: configuration.height.toFloat(), + agentCoordinateSpace = configuration.coordinateSpace, + ) + } else { + null + } + val cameraOrbit: CameraOrbit? = + if (configuration.enableCamera) { + CameraOrbit( + radius = configuration.cameraOrbit.radius, + height = configuration.cameraOrbit.height, + orbitSpeed = configuration.cameraOrbit.orbitSpeed, + wobbleAmplitude = configuration.cameraOrbit.wobbleAmplitude, + wobbleFrequency = configuration.cameraOrbit.wobbleFrequency, + ) + } else { + null + } + val emitters: EmitterManager? = if (configuration.enableEmitters) EmitterManager() else null + val choreographer: CognitiveChoreographer? = + particles?.let { + CognitiveChoreographer( + particles = it, + substrateAnimator = substrateAnimator, + random = seededRandom(0x1A2B_3C4D), + ) + } + + private var substrateState: SubstrateState = + SubstrateState.create( + width = configuration.width, + height = configuration.height, + ) + + private var frameIndex: Long = 0 + private var elapsedTimeSeconds: Float = 0f + private var latestSnapshot: SceneSnapshot = captureSnapshot(camera = cameraOrbit?.currentCamera()) + + private val queuedEmitterEffects = mutableListOf() + + init { + addInitialAgents() + initializeConnections() + latestSnapshot = captureSnapshot(camera = cameraOrbit?.currentCamera()) + } + + /** + * Queue an emitter effect to be fired during the next update's emission pass. + */ + fun emit( + effect: EmitterEffect, + position: Vector3, + metadata: Map = emptyMap(), + ) { + if (emitters == null) return + queuedEmitterEffects += QueuedEmitterEffect(effect, position, metadata) + } + + /** + * Snapshot current runtime state without advancing simulation. + */ + fun snapshot(): SceneSnapshot = latestSnapshot + + /** + * Advance the scene by [deltaTimeSeconds] and return an immutable snapshot. + */ + fun update(deltaTimeSeconds: Float): SceneSnapshot { + require(deltaTimeSeconds >= 0f) { + "deltaTimeSeconds must be >= 0, got $deltaTimeSeconds" + } + + var updatedSubstrate = substrateState + + // 1) Choreographer phase advance. + if (choreographer != null) { + updatedSubstrate = choreographer.update(agents, updatedSubstrate, deltaTimeSeconds) + } + + // 2) Ambient substrate animation. + updatedSubstrate = substrateAnimator.updateAmbient(updatedSubstrate, deltaTimeSeconds) + + // 3) Agent state update. + agents.update(deltaTimeSeconds) + + // 4) Emitter emission pass and lifecycle update. + if (emitters != null) { + flushQueuedEmitterEffects() + emitters.update(deltaTimeSeconds) + } + + // 5) Particle simulation. + if (particles != null) { + particles.update(deltaTimeSeconds) + particles.updateSubstrate(updatedSubstrate) + } + + // 6) Flow field advection. + if (flow != null) { + flow.update(deltaTimeSeconds) + updatedSubstrate = flow.updateSubstrate(updatedSubstrate) + } + + // 7) Waveform sampling. + waveform?.update(updatedSubstrate, agents, flow, deltaTimeSeconds) + + // 8) Camera orbit. + val camera = cameraOrbit?.update(deltaTimeSeconds) + + substrateState = updatedSubstrate + frameIndex += 1 + elapsedTimeSeconds += deltaTimeSeconds + + latestSnapshot = captureSnapshot(camera) + return latestSnapshot + } + + private fun addInitialAgents() { + configuration.agents.forEach { descriptor -> + agents.addAgent(descriptor.toVisualState()) + if (configuration.agentLayout == AgentLayoutOrientation.CUSTOM) { + agents.setAgentPosition(descriptor.id, descriptor.position) + descriptor.position3D?.let { agents.setAgentPosition3D(descriptor.id, it) } + } + } + } + + private fun initializeConnections() { + val flowLayer = flow ?: return + configuration.initialConnections.forEach { descriptor -> + val source = agents.getAgent(descriptor.sourceAgentId) ?: return@forEach + val target = agents.getAgent(descriptor.targetAgentId) ?: return@forEach + val id = + flowLayer.createConnection( + sourceAgentId = source.id, + targetAgentId = target.id, + sourcePosition = source.position, + targetPosition = target.position, + ) + if (descriptor.startHandoff) { + flowLayer.startHandoff(id) + } + } + } + + private fun flushQueuedEmitterEffects() { + val manager = emitters ?: return + if (queuedEmitterEffects.isEmpty()) return + + queuedEmitterEffects.forEach { queued -> + manager.emit( + effect = queued.effect, + position = queued.position, + currentTime = elapsedTimeSeconds, + metadata = queued.metadata, + ) + } + queuedEmitterEffects.clear() + } + + private fun captureSnapshot(camera: Camera?): SceneSnapshot { + val sortedAgents = agents.allAgents.sortedBy(AgentVisualState::id) + val copiedSubstrate = substrateState.deepCopy() + val copiedParticles = + particles?.getParticles()?.map(Particle::toParticleState) + ?: emptyList() + val copiedConnections = + flow?.allConnections + ?.sortedBy(FlowConnection::id) + ?.map(FlowConnection::deepCopy) + ?: emptyList() + val flowFieldState = + if (flow != null) { + FlowFieldState( + width = copiedSubstrate.width, + height = copiedSubstrate.height, + vectors = copiedSubstrate.flowField.toList(), + ) + } else { + null + } + + val waveformHeights = waveform?.heights?.copyOf() + + return SceneSnapshot( + frameIndex = frameIndex, + elapsedTimeSeconds = elapsedTimeSeconds, + coordinateSpace = configuration.coordinateSpace, + agentStates = sortedAgents, + substrateState = copiedSubstrate, + particleStates = copiedParticles, + flowConnections = copiedConnections, + flowField = flowFieldState, + waveformHeightField = waveformHeights, + waveformGridWidth = waveform?.gridWidth, + waveformGridDepth = waveform?.gridDepth, + cameraTransform = camera?.toCameraTransform(), + emitterStates = emitters?.instances?.map { it.toEmitterState() } ?: emptyList(), + choreographyPhase = dominantPhase(sortedAgents), + ) + } + + private fun dominantPhase(agentStates: List): CognitivePhase { + if (agentStates.isEmpty()) return CognitivePhase.NONE + + return agentStates + .asSequence() + .map { it.cognitivePhase } + .filter { it != CognitivePhase.NONE } + .groupingBy { it } + .eachCount() + .entries + .sortedWith( + compareByDescending> { it.value } + .thenBy { it.key.ordinal }, + ) + .firstOrNull() + ?.key + ?: CognitivePhase.NONE + } + + private fun seededRandom(salt: Int): Random { + val seed = (configuration.seed xor salt.toLong()).hashCode() + return Random(seed) + } + + private data class QueuedEmitterEffect( + val effect: EmitterEffect, + val position: Vector3, + val metadata: Map, + ) +} + +private fun SubstrateState.deepCopy(): SubstrateState = + copy( + densityField = densityField.copyOf(), + flowField = flowField.copyOf(), + activityHotspots = activityHotspots.toList(), + ) + +private fun FlowConnection.deepCopy(): FlowConnection = + copy( + taskToken = + taskToken?.copy( + trailParticles = taskToken.trailParticles.map(Particle::copy), + ), + path = path.toList(), + ) diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/SceneConfiguration.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/SceneConfiguration.kt new file mode 100644 index 0000000..7092982 --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/SceneConfiguration.kt @@ -0,0 +1,122 @@ +package link.socket.phosphor.runtime + +import link.socket.phosphor.choreography.AgentLayoutOrientation +import link.socket.phosphor.coordinate.CoordinateSpace +import link.socket.phosphor.math.Vector2 +import link.socket.phosphor.math.Vector3 +import link.socket.phosphor.signal.AgentActivityState +import link.socket.phosphor.signal.AgentVisualState +import link.socket.phosphor.signal.CognitivePhase + +/** + * Configuration for [CognitiveSceneRuntime]. + */ +data class SceneConfiguration( + val width: Int, + val height: Int, + val agents: List = emptyList(), + val initialConnections: List = emptyList(), + val enableWaveform: Boolean = true, + val enableParticles: Boolean = true, + val enableFlow: Boolean = true, + val enableEmitters: Boolean = true, + val enableCamera: Boolean = true, + val coordinateSpace: CoordinateSpace = CoordinateSpace.WORLD_CENTERED, + val seed: Long = 0L, + val agentLayout: AgentLayoutOrientation = AgentLayoutOrientation.CUSTOM, + val waveform: WaveformConfiguration = WaveformConfiguration(), + val cameraOrbit: CameraOrbitConfiguration = CameraOrbitConfiguration(), +) { + init { + require(width > 0) { "width must be > 0, got $width" } + require(height > 0) { "height must be > 0, got $height" } + } +} + +/** + * Declarative description of an initial agent. + */ +data class AgentDescriptor( + val id: String, + val name: String = id, + val role: String = "reasoning", + val position: Vector2, + val position3D: Vector3? = null, + val state: AgentActivityState = AgentActivityState.IDLE, + val statusText: String = "", + val cognitivePhase: CognitivePhase = CognitivePhase.NONE, + val phaseProgress: Float = 0f, +) { + init { + require(id.isNotBlank()) { "id cannot be blank" } + } + + fun toVisualState(): AgentVisualState = + AgentVisualState( + id = id, + name = name, + role = role, + position = position, + position3D = position3D ?: Vector3(position.x, 0f, position.y), + state = state, + statusText = statusText, + cognitivePhase = cognitivePhase, + phaseProgress = phaseProgress, + ) +} + +/** + * Declarative connection graph for optional flow simulation. + */ +data class FlowConnectionDescriptor( + val sourceAgentId: String, + val targetAgentId: String, + val startHandoff: Boolean = false, +) { + init { + require(sourceAgentId.isNotBlank()) { "sourceAgentId cannot be blank" } + require(targetAgentId.isNotBlank()) { "targetAgentId cannot be blank" } + } +} + +/** + * Optional waveform tuning. + */ +data class WaveformConfiguration( + val gridWidth: Int? = null, + val gridDepth: Int? = null, + val worldWidth: Float? = null, + val worldDepth: Float? = null, +) { + init { + require(gridWidth == null || gridWidth > 0) { + "gridWidth must be > 0 when provided, got $gridWidth" + } + require(gridDepth == null || gridDepth > 0) { + "gridDepth must be > 0 when provided, got $gridDepth" + } + require(worldWidth == null || worldWidth > 0f) { + "worldWidth must be > 0 when provided, got $worldWidth" + } + require(worldDepth == null || worldDepth > 0f) { + "worldDepth must be > 0 when provided, got $worldDepth" + } + } +} + +/** + * Optional camera orbit tuning. + */ +data class CameraOrbitConfiguration( + val radius: Float = 15f, + val height: Float = 8f, + val orbitSpeed: Float = 0.1f, + val wobbleAmplitude: Float = 0.5f, + val wobbleFrequency: Float = 0.3f, +) { + init { + require(radius > 0f) { "radius must be > 0, got $radius" } + require(height >= 0f) { "height must be >= 0, got $height" } + require(wobbleFrequency >= 0f) { "wobbleFrequency must be >= 0, got $wobbleFrequency" } + } +} diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/SceneSnapshot.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/SceneSnapshot.kt new file mode 100644 index 0000000..ea65a86 --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/SceneSnapshot.kt @@ -0,0 +1,181 @@ +package link.socket.phosphor.runtime + +import link.socket.phosphor.coordinate.CoordinateSpace +import link.socket.phosphor.emitter.EmitterInstance +import link.socket.phosphor.field.FlowConnection +import link.socket.phosphor.field.Particle +import link.socket.phosphor.field.ParticleAttractor +import link.socket.phosphor.field.ParticleType +import link.socket.phosphor.field.SubstrateState +import link.socket.phosphor.math.Vector2 +import link.socket.phosphor.math.Vector3 +import link.socket.phosphor.render.Camera +import link.socket.phosphor.signal.AgentVisualState +import link.socket.phosphor.signal.CognitivePhase + +/** + * Immutable scene output after one [CognitiveSceneRuntime.update] tick. + */ +data class SceneSnapshot( + val frameIndex: Long, + val elapsedTimeSeconds: Float, + val coordinateSpace: CoordinateSpace, + val agentStates: List, + val substrateState: SubstrateState, + val particleStates: List, + val flowConnections: List, + val flowField: FlowFieldState?, + val waveformHeightField: FloatArray?, + val waveformGridWidth: Int?, + val waveformGridDepth: Int?, + val cameraTransform: CameraTransform?, + val emitterStates: List, + val choreographyPhase: CognitivePhase, +) { + init { + if (waveformHeightField != null) { + require(waveformGridWidth != null && waveformGridDepth != null) { + "waveform grid dimensions are required when waveformHeightField is present" + } + require(waveformHeightField.size == waveformGridWidth * waveformGridDepth) { + "waveformHeightField size (${waveformHeightField.size}) must equal gridWidth * gridDepth " + + "(${waveformGridWidth * waveformGridDepth})" + } + } else { + require(waveformGridWidth == null && waveformGridDepth == null) { + "waveform grid dimensions must be null when waveformHeightField is null" + } + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SceneSnapshot) return false + + if (frameIndex != other.frameIndex) return false + if (elapsedTimeSeconds != other.elapsedTimeSeconds) return false + if (coordinateSpace != other.coordinateSpace) return false + if (agentStates != other.agentStates) return false + if (substrateState != other.substrateState) return false + if (particleStates != other.particleStates) return false + if (flowConnections != other.flowConnections) return false + if (flowField != other.flowField) return false + if (!(waveformHeightField?.contentEquals(other.waveformHeightField) ?: (other.waveformHeightField == null))) { + return false + } + if (waveformGridWidth != other.waveformGridWidth) return false + if (waveformGridDepth != other.waveformGridDepth) return false + if (cameraTransform != other.cameraTransform) return false + if (emitterStates != other.emitterStates) return false + if (choreographyPhase != other.choreographyPhase) return false + + return true + } + + override fun hashCode(): Int { + var result = frameIndex.hashCode() + result = 31 * result + elapsedTimeSeconds.hashCode() + result = 31 * result + coordinateSpace.hashCode() + result = 31 * result + agentStates.hashCode() + result = 31 * result + substrateState.hashCode() + result = 31 * result + particleStates.hashCode() + result = 31 * result + flowConnections.hashCode() + result = 31 * result + (flowField?.hashCode() ?: 0) + result = 31 * result + (waveformHeightField?.contentHashCode() ?: 0) + result = 31 * result + (waveformGridWidth ?: 0) + result = 31 * result + (waveformGridDepth ?: 0) + result = 31 * result + (cameraTransform?.hashCode() ?: 0) + result = 31 * result + emitterStates.hashCode() + result = 31 * result + choreographyPhase.hashCode() + return result + } +} + +/** + * Snapshot of the substrate flow vectors. + */ +data class FlowFieldState( + val width: Int, + val height: Int, + val vectors: List, +) { + init { + require(width > 0) { "width must be > 0, got $width" } + require(height > 0) { "height must be > 0, got $height" } + require(vectors.size == width * height) { + "vectors size (${vectors.size}) must equal width * height (${width * height})" + } + } +} + +/** + * Immutable particle payload for snapshot consumers. + */ +data class ParticleState( + val position: Vector2, + val velocity: Vector2, + val life: Float, + val type: ParticleType, + val glyph: Char, + val age: Float, + val attractor: ParticleAttractor? = null, +) + +/** + * Snapshot of an active emitter instance. + */ +data class EmitterState( + val effectName: String, + val position: Vector3, + val activatedAt: Float, + val age: Float, + val duration: Float, + val radius: Float, + val metadata: Map, +) + +/** + * Camera state after an update tick. + */ +data class CameraTransform( + val position: Vector3, + val target: Vector3, + val up: Vector3, + val fovY: Float, + val near: Float, + val far: Float, + val projectionType: Camera.ProjectionType, +) + +internal fun Particle.toParticleState(): ParticleState = + ParticleState( + position = position, + velocity = velocity, + life = life, + type = type, + glyph = glyph, + age = age, + attractor = attractor, + ) + +internal fun EmitterInstance.toEmitterState(): EmitterState = + EmitterState( + effectName = effect.name, + position = position, + activatedAt = activatedAt, + age = age, + duration = effect.activeDuration(metadata), + radius = effect.radius, + metadata = metadata, + ) + +internal fun Camera.toCameraTransform(): CameraTransform = + CameraTransform( + position = position, + target = target, + up = up, + fovY = fovY, + near = near, + far = far, + projectionType = projectionType, + ) diff --git a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/runtime/CognitiveSceneRuntimeTest.kt b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/runtime/CognitiveSceneRuntimeTest.kt new file mode 100644 index 0000000..b52b3ad --- /dev/null +++ b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/runtime/CognitiveSceneRuntimeTest.kt @@ -0,0 +1,145 @@ +package link.socket.phosphor.runtime + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import link.socket.phosphor.choreography.AgentLayoutOrientation +import link.socket.phosphor.emitter.EmitterEffect +import link.socket.phosphor.math.Vector2 +import link.socket.phosphor.math.Vector3 +import link.socket.phosphor.signal.AgentActivityState +import link.socket.phosphor.signal.CognitivePhase + +class CognitiveSceneRuntimeTest { + @Test + fun `update is deterministic with identical seed and dt sequence`() { + val configuration = populatedConfiguration(seed = 4242L) + val runtimeA = CognitiveSceneRuntime(configuration) + val runtimeB = CognitiveSceneRuntime(configuration) + + runtimeA.emit(EmitterEffect.SparkBurst(), Vector3(14f, 0f, 9f)) + runtimeB.emit(EmitterEffect.SparkBurst(), Vector3(14f, 0f, 9f)) + + var finalA = runtimeA.snapshot() + var finalB = runtimeB.snapshot() + + repeat(60) { + finalA = runtimeA.update(1f / 60f) + finalB = runtimeB.update(1f / 60f) + } + + assertEquals(finalA, finalB) + } + + @Test + fun `different seeds diverge for stochastic subsystems`() { + val runtimeA = CognitiveSceneRuntime(populatedConfiguration(seed = 11L)) + val runtimeB = CognitiveSceneRuntime(populatedConfiguration(seed = 99L)) + + var finalA = runtimeA.snapshot() + var finalB = runtimeB.snapshot() + + repeat(30) { + finalA = runtimeA.update(1f / 60f) + finalB = runtimeB.update(1f / 60f) + } + + assertNotEquals(finalA, finalB) + } + + @Test + fun `disabled subsystems are skipped and produce null or empty snapshot fields`() { + val runtime = + CognitiveSceneRuntime( + SceneConfiguration( + width = 24, + height = 12, + agents = + listOf( + AgentDescriptor( + id = "spark", + position = Vector2(12f, 6f), + state = AgentActivityState.PROCESSING, + cognitivePhase = CognitivePhase.PERCEIVE, + ), + ), + enableWaveform = false, + enableParticles = false, + enableFlow = false, + enableEmitters = false, + enableCamera = false, + ), + ) + + val snapshot = runtime.update(1f / 30f) + + assertNull(runtime.waveform) + assertNull(runtime.particles) + assertNull(runtime.flow) + assertNull(runtime.emitters) + assertNull(runtime.cameraOrbit) + + assertNull(snapshot.waveformHeightField) + assertNull(snapshot.waveformGridWidth) + assertNull(snapshot.waveformGridDepth) + assertTrue(snapshot.particleStates.isEmpty()) + assertTrue(snapshot.flowConnections.isEmpty()) + assertNull(snapshot.flowField) + assertTrue(snapshot.emitterStates.isEmpty()) + assertNull(snapshot.cameraTransform) + } + + @Test + fun `frame index increments each update`() { + val runtime = CognitiveSceneRuntime(populatedConfiguration(seed = 7L)) + + val first = runtime.update(0.1f) + val second = runtime.update(0.1f) + + assertEquals(first.frameIndex + 1, second.frameIndex) + assertTrue(second.elapsedTimeSeconds > first.elapsedTimeSeconds) + } + + private fun populatedConfiguration(seed: Long): SceneConfiguration { + return SceneConfiguration( + width = 28, + height = 18, + seed = seed, + agentLayout = AgentLayoutOrientation.CUSTOM, + agents = + listOf( + AgentDescriptor( + id = "spark", + name = "Spark", + role = "reasoning", + position = Vector2(8f, 9f), + state = AgentActivityState.PROCESSING, + cognitivePhase = CognitivePhase.PERCEIVE, + ), + AgentDescriptor( + id = "jazz", + name = "Jazz", + role = "codegen", + position = Vector2(20f, 9f), + state = AgentActivityState.PROCESSING, + cognitivePhase = CognitivePhase.PLAN, + ), + ), + initialConnections = + listOf( + FlowConnectionDescriptor( + sourceAgentId = "spark", + targetAgentId = "jazz", + startHandoff = true, + ), + ), + enableWaveform = true, + enableParticles = true, + enableFlow = true, + enableEmitters = true, + enableCamera = true, + ) + } +} diff --git a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/runtime/SceneSnapshotTest.kt b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/runtime/SceneSnapshotTest.kt new file mode 100644 index 0000000..412d4bd --- /dev/null +++ b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/runtime/SceneSnapshotTest.kt @@ -0,0 +1,69 @@ +package link.socket.phosphor.runtime + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import link.socket.phosphor.coordinate.CoordinateSpace +import link.socket.phosphor.field.SubstrateState +import link.socket.phosphor.signal.CognitivePhase + +class SceneSnapshotTest { + @Test + fun `waveform height field uses content equality`() { + val first = buildSnapshot(floatArrayOf(0.1f, 0.2f, 0.3f, 0.4f)) + val second = buildSnapshot(floatArrayOf(0.1f, 0.2f, 0.3f, 0.4f)) + + assertEquals(first, second) + assertEquals(first.hashCode(), second.hashCode()) + } + + @Test + fun `waveform height field detects content changes`() { + val first = buildSnapshot(floatArrayOf(0.1f, 0.2f, 0.3f, 0.4f)) + val second = buildSnapshot(floatArrayOf(0.1f, 0.2f, 0.35f, 0.4f)) + + assertNotEquals(first, second) + } + + @Test + fun `waveform dimensions are required when height field is present`() { + assertFailsWith { + SceneSnapshot( + frameIndex = 0, + elapsedTimeSeconds = 0f, + coordinateSpace = CoordinateSpace.WORLD_CENTERED, + agentStates = emptyList(), + substrateState = SubstrateState.create(2, 2), + particleStates = emptyList(), + flowConnections = emptyList(), + flowField = null, + waveformHeightField = floatArrayOf(0f, 0f, 0f, 0f), + waveformGridWidth = null, + waveformGridDepth = null, + cameraTransform = null, + emitterStates = emptyList(), + choreographyPhase = CognitivePhase.NONE, + ) + } + } + + private fun buildSnapshot(waveformHeightField: FloatArray): SceneSnapshot { + return SceneSnapshot( + frameIndex = 12, + elapsedTimeSeconds = 1.2f, + coordinateSpace = CoordinateSpace.WORLD_CENTERED, + agentStates = emptyList(), + substrateState = SubstrateState.create(2, 2), + particleStates = emptyList(), + flowConnections = emptyList(), + flowField = null, + waveformHeightField = waveformHeightField, + waveformGridWidth = 2, + waveformGridDepth = 2, + cameraTransform = null, + emitterStates = emptyList(), + choreographyPhase = CognitivePhase.NONE, + ) + } +}