diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/choreography/AgentLayer.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/choreography/AgentLayer.kt index 1a8e2eb..26fa987 100644 --- a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/choreography/AgentLayer.kt +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/choreography/AgentLayer.kt @@ -5,6 +5,8 @@ import kotlin.math.acos import kotlin.math.cos import kotlin.math.sin import kotlin.math.sqrt +import link.socket.phosphor.coordinate.CoordinateSpace +import link.socket.phosphor.coordinate.CoordinateTransform import link.socket.phosphor.math.Vector2 import link.socket.phosphor.math.Vector3 import link.socket.phosphor.signal.AgentActivityState @@ -48,6 +50,23 @@ class AgentLayer( private val agents = mutableMapOf() private val spawnProgress = mutableMapOf() + /** + * The coordinate space that agent positions are expressed in. + * + * 3D-aware layouts ([AgentLayoutOrientation.CIRCULAR], [AgentLayoutOrientation.SPHERE], + * [AgentLayoutOrientation.CLUSTERED]) produce positions in [CoordinateSpace.WORLD_CENTERED]. + * 2D layouts and [AgentLayoutOrientation.CUSTOM] use [CoordinateSpace.WORLD_POSITIVE]. + */ + val coordinateSpace: CoordinateSpace + get() = + when (orientation) { + AgentLayoutOrientation.CIRCULAR, + AgentLayoutOrientation.SPHERE, + AgentLayoutOrientation.CLUSTERED, + -> CoordinateSpace.WORLD_CENTERED + else -> CoordinateSpace.WORLD_POSITIVE + } + /** All current agents */ val allAgents: List get() = agents.values.toList() @@ -376,6 +395,57 @@ class AgentLayer( agents.clear() spawnProgress.clear() } + + /** + * Get an agent's 2D position converted to the requested coordinate space. + * + * @param agentId The agent to query + * @param targetSpace The desired coordinate space + * @param worldWidth The world extent along X (needed for conversion) + * @param worldDepth The world extent along Z (needed for conversion) + * @return The position in [targetSpace], or null if agent not found + */ + fun getAgentPositionIn( + agentId: String, + targetSpace: CoordinateSpace, + worldWidth: Float, + worldDepth: Float, + ): Vector2? { + val agent = agents[agentId] ?: return null + return CoordinateTransform.convert( + agent.position, + worldWidth, + worldDepth, + from = coordinateSpace, + to = targetSpace, + ) + } + + /** + * Get an agent's 3D position converted to the requested coordinate space. + * Y (height) is preserved during conversion. + * + * @param agentId The agent to query + * @param targetSpace The desired coordinate space + * @param worldWidth The world extent along X (needed for conversion) + * @param worldDepth The world extent along Z (needed for conversion) + * @return The position in [targetSpace], or null if agent not found + */ + fun getAgentPosition3DIn( + agentId: String, + targetSpace: CoordinateSpace, + worldWidth: Float, + worldDepth: Float, + ): Vector3? { + val agent = agents[agentId] ?: return null + return CoordinateTransform.convert( + agent.position3D, + worldWidth, + worldDepth, + from = coordinateSpace, + to = targetSpace, + ) + } } /** diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/coordinate/CoordinateSpace.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/coordinate/CoordinateSpace.kt new file mode 100644 index 0000000..980e792 --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/coordinate/CoordinateSpace.kt @@ -0,0 +1,138 @@ +package link.socket.phosphor.coordinate + +import link.socket.phosphor.math.Vector2 +import link.socket.phosphor.math.Vector3 + +/** + * Defines the two coordinate spaces used by Phosphor's rendering pipeline. + * + * - [WORLD_CENTERED]: Origin at center of the world. Coordinates range from + * `-size/2` to `+size/2`. Used by [CognitiveWaveform.worldPosition] and + * the 3D camera/projection pipeline. + * + * - [WORLD_POSITIVE]: Origin at the corner (minimum). Coordinates range from + * `0` to `size`. Used by the waveform grid sampling loop and substrate + * density lookups. + */ +enum class CoordinateSpace { + /** Origin at center, e.g. -w/2 .. +w/2 */ + WORLD_CENTERED, + + /** Origin at corner, e.g. 0 .. w */ + WORLD_POSITIVE, +} + +/** + * Converts positions between [CoordinateSpace.WORLD_CENTERED] and + * [CoordinateSpace.WORLD_POSITIVE]. + * + * All conversions are symmetric and round-trip safe: + * ``` + * toCentered(toPositive(x, size), size) == x + * toPositive(toCentered(x, size), size) == x + * ``` + */ +object CoordinateTransform { + /** + * Convert a scalar from [from] space to [CoordinateSpace.WORLD_POSITIVE]. + * + * @param pos The coordinate value to convert + * @param worldSize The extent of the axis (width or depth) + * @param from The source coordinate space + * @return The coordinate in WORLD_POSITIVE space + */ + fun toPositive( + pos: Float, + worldSize: Float, + from: CoordinateSpace, + ): Float = + when (from) { + CoordinateSpace.WORLD_POSITIVE -> pos + CoordinateSpace.WORLD_CENTERED -> pos + worldSize / 2f + } + + /** + * Convert a scalar from [from] space to [CoordinateSpace.WORLD_CENTERED]. + * + * @param pos The coordinate value to convert + * @param worldSize The extent of the axis (width or depth) + * @param from The source coordinate space + * @return The coordinate in WORLD_CENTERED space + */ + fun toCentered( + pos: Float, + worldSize: Float, + from: CoordinateSpace, + ): Float = + when (from) { + CoordinateSpace.WORLD_CENTERED -> pos + CoordinateSpace.WORLD_POSITIVE -> pos - worldSize / 2f + } + + /** + * Convert a [Vector2] position between coordinate spaces. + * + * @param pos The 2D position to convert (x maps to world-X, y maps to world-Z) + * @param worldWidth Extent of the X axis + * @param worldDepth Extent of the Z axis + * @param from Source coordinate space + * @param to Target coordinate space + * @return Converted position + */ + fun convert( + pos: Vector2, + worldWidth: Float, + worldDepth: Float, + from: CoordinateSpace, + to: CoordinateSpace, + ): Vector2 { + if (from == to) return pos + return when (to) { + CoordinateSpace.WORLD_POSITIVE -> + Vector2( + toPositive(pos.x, worldWidth, from), + toPositive(pos.y, worldDepth, from), + ) + CoordinateSpace.WORLD_CENTERED -> + Vector2( + toCentered(pos.x, worldWidth, from), + toCentered(pos.y, worldDepth, from), + ) + } + } + + /** + * Convert a [Vector3] position between coordinate spaces. + * Only X and Z are converted; Y (height) is preserved. + * + * @param pos The 3D position to convert + * @param worldWidth Extent of the X axis + * @param worldDepth Extent of the Z axis + * @param from Source coordinate space + * @param to Target coordinate space + * @return Converted position with Y unchanged + */ + fun convert( + pos: Vector3, + worldWidth: Float, + worldDepth: Float, + from: CoordinateSpace, + to: CoordinateSpace, + ): Vector3 { + if (from == to) return pos + return when (to) { + CoordinateSpace.WORLD_POSITIVE -> + Vector3( + toPositive(pos.x, worldWidth, from), + pos.y, + toPositive(pos.z, worldDepth, from), + ) + CoordinateSpace.WORLD_CENTERED -> + Vector3( + toCentered(pos.x, worldWidth, from), + pos.y, + toCentered(pos.z, worldDepth, from), + ) + } + } +} diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/render/CognitiveWaveform.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/render/CognitiveWaveform.kt index e6a1e7d..de78c13 100644 --- a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/render/CognitiveWaveform.kt +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/render/CognitiveWaveform.kt @@ -3,6 +3,8 @@ package link.socket.phosphor.render import kotlin.math.exp import kotlin.math.sqrt import link.socket.phosphor.choreography.AgentLayer +import link.socket.phosphor.coordinate.CoordinateSpace +import link.socket.phosphor.coordinate.CoordinateTransform import link.socket.phosphor.field.FlowLayer import link.socket.phosphor.field.SubstrateState import link.socket.phosphor.math.Vector3 @@ -30,6 +32,7 @@ class CognitiveWaveform( val gridDepth: Int = 30, val worldWidth: Float = 20f, val worldDepth: Float = 15f, + val agentCoordinateSpace: CoordinateSpace = CoordinateSpace.WORLD_POSITIVE, ) { /** Height values at each grid point. Updated each frame. */ val heights: FloatArray = FloatArray(gridWidth * gridDepth) @@ -171,10 +174,11 @@ class CognitiveWaveform( val radius = defaultInfluenceRadius - // Convert agent's 2D position to world-space fraction - // Agent positions are in screen/grid coordinates; normalize them - val agentWorldX = agent.position.x - val agentWorldZ = agent.position.y + // Convert agent position to positive space (matching the grid sampling loop below) + val agentWorldX = + CoordinateTransform.toPositive(agent.position.x, worldWidth, agentCoordinateSpace) + val agentWorldZ = + CoordinateTransform.toPositive(agent.position.y, worldDepth, agentCoordinateSpace) for (gz in 0 until gridDepth) { for (gx in 0 until gridWidth) { @@ -204,10 +208,10 @@ class CognitiveWaveform( val source = agents.getAgent(connection.sourceAgentId) ?: continue val target = agents.getAgent(connection.targetAgentId) ?: continue - val sx = source.position.x - val sz = source.position.y - val tx = target.position.x - val tz = target.position.y + val sx = CoordinateTransform.toPositive(source.position.x, worldWidth, agentCoordinateSpace) + val sz = CoordinateTransform.toPositive(source.position.y, worldDepth, agentCoordinateSpace) + val tx = CoordinateTransform.toPositive(target.position.x, worldWidth, agentCoordinateSpace) + val tz = CoordinateTransform.toPositive(target.position.y, worldDepth, agentCoordinateSpace) // Direction along the ridge val ridgeDx = tx - sx diff --git a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/choreography/AgentLayerTest.kt b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/choreography/AgentLayerTest.kt index be34ff1..69e157a 100644 --- a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/choreography/AgentLayerTest.kt +++ b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/choreography/AgentLayerTest.kt @@ -1,10 +1,12 @@ package link.socket.phosphor.choreography +import kotlin.math.abs import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import link.socket.phosphor.coordinate.CoordinateSpace import link.socket.phosphor.math.Vector2 import link.socket.phosphor.math.Vector3 import link.socket.phosphor.signal.AgentActivityState @@ -418,6 +420,106 @@ class AgentLayerTest { assertEquals(10f, updated.position.x, 0.01f) assertEquals(20f, updated.position.y, 0.01f) } + + @Test + fun `coordinateSpace is WORLD_CENTERED for 3D layouts`() { + assertEquals( + CoordinateSpace.WORLD_CENTERED, + AgentLayer(100, 100, AgentLayoutOrientation.CIRCULAR).coordinateSpace, + ) + assertEquals( + CoordinateSpace.WORLD_CENTERED, + AgentLayer(100, 100, AgentLayoutOrientation.SPHERE).coordinateSpace, + ) + assertEquals( + CoordinateSpace.WORLD_CENTERED, + AgentLayer(100, 100, AgentLayoutOrientation.CLUSTERED).coordinateSpace, + ) + } + + @Test + fun `coordinateSpace is WORLD_POSITIVE for 2D layouts`() { + assertEquals( + CoordinateSpace.WORLD_POSITIVE, + AgentLayer(100, 30, AgentLayoutOrientation.HORIZONTAL).coordinateSpace, + ) + assertEquals( + CoordinateSpace.WORLD_POSITIVE, + AgentLayer(100, 30, AgentLayoutOrientation.VERTICAL).coordinateSpace, + ) + assertEquals( + CoordinateSpace.WORLD_POSITIVE, + AgentLayer(100, 30, AgentLayoutOrientation.CUSTOM).coordinateSpace, + ) + } + + @Test + fun `getAgentPositionIn converts from centered to positive`() { + val layer = AgentLayer(100, 100, AgentLayoutOrientation.CIRCULAR) + layer.addAgent(AgentVisualState("1", "A", "r", Vector2.ZERO)) + + // Circular layout produces centered coordinates; get position in positive space + val centered = layer.getAgent("1")!!.position + val positive = + layer.getAgentPositionIn( + "1", + CoordinateSpace.WORLD_POSITIVE, + worldWidth = 20f, + worldDepth = 15f, + ) + + assertNotNull(positive) + // Conversion: positive = centered + worldSize/2 + assertEquals(centered.x + 10f, positive.x, 0.01f) + assertEquals(centered.y + 7.5f, positive.y, 0.01f) + } + + @Test + fun `getAgentPositionIn same-space returns unchanged`() { + val layer = AgentLayer(100, 30, AgentLayoutOrientation.CUSTOM) + layer.addAgent(AgentVisualState("1", "A", "r", Vector2(10f, 7.5f))) + + val result = + layer.getAgentPositionIn( + "1", + CoordinateSpace.WORLD_POSITIVE, + worldWidth = 20f, + worldDepth = 15f, + ) + + assertNotNull(result) + assertEquals(10f, result.x, 0.01f) + assertEquals(7.5f, result.y, 0.01f) + } + + @Test + fun `getAgentPosition3DIn preserves Y during conversion`() { + val layer = AgentLayer(100, 100, AgentLayoutOrientation.CIRCULAR) + layer.addAgent(AgentVisualState("1", "A", "r", Vector2.ZERO)) + + // Set a custom 3D position with non-zero Y + layer.setAgentPosition3D("1", Vector3(-5f, 3f, 2f)) + + val positive = + layer.getAgentPosition3DIn( + "1", + CoordinateSpace.WORLD_POSITIVE, + worldWidth = 20f, + worldDepth = 15f, + ) + + assertNotNull(positive) + assertEquals(3f, positive.y, 0.01f) // Y preserved + assertEquals(-5f + 10f, positive.x, 0.01f) // X converted + assertEquals(2f + 7.5f, positive.z, 0.01f) // Z converted + } + + @Test + fun `getAgentPositionIn returns null for unknown agent`() { + val layer = AgentLayer(100, 30) + assertNull(layer.getAgentPositionIn("nonexistent", CoordinateSpace.WORLD_POSITIVE, 20f, 15f)) + assertNull(layer.getAgentPosition3DIn("nonexistent", CoordinateSpace.WORLD_POSITIVE, 20f, 15f)) + } } class AgentVisualState3DTest { diff --git a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/coordinate/CoordinateSpaceIntegrationTest.kt b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/coordinate/CoordinateSpaceIntegrationTest.kt new file mode 100644 index 0000000..3be2cee --- /dev/null +++ b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/coordinate/CoordinateSpaceIntegrationTest.kt @@ -0,0 +1,214 @@ +package link.socket.phosphor.coordinate + +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertTrue +import link.socket.phosphor.choreography.AgentLayer +import link.socket.phosphor.choreography.AgentLayoutOrientation +import link.socket.phosphor.field.SubstrateState +import link.socket.phosphor.math.Vector2 +import link.socket.phosphor.render.CognitiveWaveform +import link.socket.phosphor.signal.AgentActivityState +import link.socket.phosphor.signal.AgentVisualState +import link.socket.phosphor.signal.CognitivePhase + +/** + * Integration tests verifying that coordinate space unification produces + * consistent waveform output regardless of whether agents are expressed + * in WORLD_POSITIVE or WORLD_CENTERED coordinates. + * + * These tests use fixed parameters (no randomness) so results are + * deterministic across platforms. + */ +class CoordinateSpaceIntegrationTest { + private val worldWidth = 20f + private val worldDepth = 15f + private val gridWidth = 20 + private val gridDepth = 15 + + private fun createSubstrate(density: Float = 0.1f): SubstrateState = + SubstrateState.create(gridWidth, gridDepth, density) + + /** + * Full scene: two agents placed at equivalent positions in positive vs + * centered space. The heightfield snapshots should match. + */ + @Test + fun `full scene heightfield matches between positive and centered agents`() { + val substrate = createSubstrate() + + // --- Positive-space scene --- + val positiveWaveform = + CognitiveWaveform( + gridWidth = gridWidth, + gridDepth = gridDepth, + worldWidth = worldWidth, + worldDepth = worldDepth, + agentCoordinateSpace = CoordinateSpace.WORLD_POSITIVE, + ) + val positiveAgents = AgentLayer(gridWidth, gridDepth, AgentLayoutOrientation.CUSTOM) + positiveAgents.addAgent( + AgentVisualState( + id = "spark", + name = "Spark", + role = "reasoning", + position = Vector2(10f, 7.5f), + state = AgentActivityState.PROCESSING, + cognitivePhase = CognitivePhase.EXECUTE, + ), + ) + positiveAgents.addAgent( + AgentVisualState( + id = "jazz", + name = "Jazz", + role = "codegen", + position = Vector2(14f, 5f), + state = AgentActivityState.ACTIVE, + cognitivePhase = CognitivePhase.PLAN, + ), + ) + positiveWaveform.update(substrate, positiveAgents, null, dt = 1f) + + // --- Centered-space scene (same world points) --- + val centeredWaveform = + CognitiveWaveform( + gridWidth = gridWidth, + gridDepth = gridDepth, + worldWidth = worldWidth, + worldDepth = worldDepth, + agentCoordinateSpace = CoordinateSpace.WORLD_CENTERED, + ) + val centeredAgents = AgentLayer(gridWidth, gridDepth, AgentLayoutOrientation.CUSTOM) + // Convert: centered = positive - worldSize/2 + centeredAgents.addAgent( + AgentVisualState( + id = "spark", + name = "Spark", + role = "reasoning", + position = Vector2(10f - worldWidth / 2f, 7.5f - worldDepth / 2f), + state = AgentActivityState.PROCESSING, + cognitivePhase = CognitivePhase.EXECUTE, + ), + ) + centeredAgents.addAgent( + AgentVisualState( + id = "jazz", + name = "Jazz", + role = "codegen", + position = Vector2(14f - worldWidth / 2f, 5f - worldDepth / 2f), + state = AgentActivityState.ACTIVE, + cognitivePhase = CognitivePhase.PLAN, + ), + ) + centeredWaveform.update(substrate, centeredAgents, null, dt = 1f) + + // --- Compare all grid heights --- + var maxDiff = 0f + for (gz in 0 until gridDepth) { + for (gx in 0 until gridWidth) { + val hPos = positiveWaveform.heightAt(gx, gz) + val hCen = centeredWaveform.heightAt(gx, gz) + val diff = abs(hPos - hCen) + if (diff > maxDiff) maxDiff = diff + assertTrue( + diff < 0.01f, + "height mismatch at ($gx,$gz): positive=$hPos, centered=$hCen, diff=$diff", + ) + } + } + } + + /** + * Verify that waveform peaks appear at the expected grid locations + * for agents in both coordinate spaces. + */ + @Test + fun `agent peaks appear at correct grid locations in both spaces`() { + val substrate = createSubstrate() + + // Agent at world center using POSITIVE coords + val wPos = + CognitiveWaveform( + gridWidth = gridWidth, + gridDepth = gridDepth, + worldWidth = worldWidth, + worldDepth = worldDepth, + agentCoordinateSpace = CoordinateSpace.WORLD_POSITIVE, + ) + val aPos = AgentLayer(gridWidth, gridDepth, AgentLayoutOrientation.CUSTOM) + aPos.addAgent( + AgentVisualState( + id = "a", + name = "A", + role = "r", + position = Vector2(worldWidth / 2f, worldDepth / 2f), + state = AgentActivityState.PROCESSING, + ), + ) + wPos.update(substrate, aPos, null, dt = 1f) + + // Agent at world center using CENTERED coords (should be 0,0) + val wCen = + CognitiveWaveform( + gridWidth = gridWidth, + gridDepth = gridDepth, + worldWidth = worldWidth, + worldDepth = worldDepth, + agentCoordinateSpace = CoordinateSpace.WORLD_CENTERED, + ) + val aCen = AgentLayer(gridWidth, gridDepth, AgentLayoutOrientation.CUSTOM) + aCen.addAgent( + AgentVisualState( + id = "a", + name = "A", + role = "r", + position = Vector2(0f, 0f), + state = AgentActivityState.PROCESSING, + ), + ) + wCen.update(substrate, aCen, null, dt = 1f) + + // Grid center + val centerGx = gridWidth / 2 + val centerGz = gridDepth / 2 + + // Both should have a peak at grid center higher than corners + val peakPos = wPos.heightAt(centerGx, centerGz) + val peakCen = wCen.heightAt(centerGx, centerGz) + val cornerPos = wPos.heightAt(0, 0) + val cornerCen = wCen.heightAt(0, 0) + + assertTrue(peakPos > cornerPos * 2, "positive-space peak should be well above corner") + assertTrue(peakCen > cornerCen * 2, "centered-space peak should be well above corner") + assertTrue( + abs(peakPos - peakCen) < 0.01f, + "peaks should match: positive=$peakPos, centered=$peakCen", + ) + } + + /** + * Regression: worldPosition() still returns centered coordinates, + * verifying the rendering pipeline is unaffected. + */ + @Test + fun `worldPosition returns centered coordinates regardless of agentCoordinateSpace`() { + val waveform = + CognitiveWaveform( + gridWidth = gridWidth, + gridDepth = gridDepth, + worldWidth = worldWidth, + worldDepth = worldDepth, + agentCoordinateSpace = CoordinateSpace.WORLD_CENTERED, + ) + + // Grid origin should map to negative world corner + val origin = waveform.worldPosition(0, 0) + assertTrue(origin.x < 0f, "grid origin X should be negative in centered world") + assertTrue(origin.z < 0f, "grid origin Z should be negative in centered world") + + // Grid center should map near world origin + val center = waveform.worldPosition(gridWidth / 2, gridDepth / 2) + assertTrue(abs(center.x) < 1f, "grid center X should be near 0") + assertTrue(abs(center.z) < 1f, "grid center Z should be near 0") + } +} diff --git a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/coordinate/CoordinateTransformTest.kt b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/coordinate/CoordinateTransformTest.kt new file mode 100644 index 0000000..8ac41cb --- /dev/null +++ b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/coordinate/CoordinateTransformTest.kt @@ -0,0 +1,164 @@ +package link.socket.phosphor.coordinate + +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import link.socket.phosphor.math.Vector2 +import link.socket.phosphor.math.Vector3 + +class CoordinateTransformTest { + private val worldWidth = 20f + private val worldDepth = 15f + private val epsilon = 0.0001f + + // --- Scalar round-trip tests --- + + @Test + fun `toPositive then toCentered is identity`() { + val values = listOf(-10f, -5f, 0f, 5f, 10f, 7.5f, -7.5f) + for (v in values) { + val positive = CoordinateTransform.toPositive(v, worldWidth, CoordinateSpace.WORLD_CENTERED) + val roundTrip = CoordinateTransform.toCentered(positive, worldWidth, CoordinateSpace.WORLD_POSITIVE) + assertTrue( + abs(roundTrip - v) < epsilon, + "round-trip centered->positive->centered for $v: got $roundTrip", + ) + } + } + + @Test + fun `toCentered then toPositive is identity`() { + val values = listOf(0f, 5f, 10f, 15f, 20f, 7.5f) + for (v in values) { + val centered = CoordinateTransform.toCentered(v, worldWidth, CoordinateSpace.WORLD_POSITIVE) + val roundTrip = CoordinateTransform.toPositive(centered, worldWidth, CoordinateSpace.WORLD_CENTERED) + assertTrue( + abs(roundTrip - v) < epsilon, + "round-trip positive->centered->positive for $v: got $roundTrip", + ) + } + } + + @Test + fun `same-space conversion is identity`() { + assertEquals(5f, CoordinateTransform.toPositive(5f, worldWidth, CoordinateSpace.WORLD_POSITIVE)) + assertEquals(-3f, CoordinateTransform.toCentered(-3f, worldWidth, CoordinateSpace.WORLD_CENTERED)) + } + + // --- Scalar value correctness --- + + @Test + fun `centered origin maps to positive center`() { + val result = CoordinateTransform.toPositive(0f, worldWidth, CoordinateSpace.WORLD_CENTERED) + assertTrue(abs(result - 10f) < epsilon, "centered 0 -> positive 10, got $result") + } + + @Test + fun `positive origin maps to centered negative half`() { + val result = CoordinateTransform.toCentered(0f, worldWidth, CoordinateSpace.WORLD_POSITIVE) + assertTrue(abs(result - (-10f)) < epsilon, "positive 0 -> centered -10, got $result") + } + + @Test + fun `centered bounds map to positive bounds`() { + val left = CoordinateTransform.toPositive(-10f, worldWidth, CoordinateSpace.WORLD_CENTERED) + val right = CoordinateTransform.toPositive(10f, worldWidth, CoordinateSpace.WORLD_CENTERED) + assertTrue(abs(left) < epsilon, "centered -10 -> positive 0, got $left") + assertTrue(abs(right - 20f) < epsilon, "centered +10 -> positive 20, got $right") + } + + // --- Vector2 tests --- + + @Test + fun `Vector2 round-trip conversion`() { + val original = Vector2(10f, 7.5f) + val centered = + CoordinateTransform.convert( + original, + worldWidth, + worldDepth, + from = CoordinateSpace.WORLD_POSITIVE, + to = CoordinateSpace.WORLD_CENTERED, + ) + val roundTrip = + CoordinateTransform.convert( + centered, + worldWidth, + worldDepth, + from = CoordinateSpace.WORLD_CENTERED, + to = CoordinateSpace.WORLD_POSITIVE, + ) + assertTrue(abs(roundTrip.x - original.x) < epsilon, "x: ${roundTrip.x} != ${original.x}") + assertTrue(abs(roundTrip.y - original.y) < epsilon, "y: ${roundTrip.y} != ${original.y}") + } + + @Test + fun `Vector2 same-space conversion returns same value`() { + val v = Vector2(3f, 4f) + val result = + CoordinateTransform.convert( + v, + worldWidth, + worldDepth, + from = CoordinateSpace.WORLD_POSITIVE, + to = CoordinateSpace.WORLD_POSITIVE, + ) + assertEquals(v, result) + } + + // --- Vector3 tests --- + + @Test + fun `Vector3 conversion preserves Y`() { + val original = Vector3(5f, 42f, -3f) + val converted = + CoordinateTransform.convert( + original, + worldWidth, + worldDepth, + from = CoordinateSpace.WORLD_CENTERED, + to = CoordinateSpace.WORLD_POSITIVE, + ) + assertEquals(42f, converted.y, "Y should be preserved during conversion") + } + + @Test + fun `Vector3 round-trip conversion`() { + val original = Vector3(-5f, 2f, 3f) + val positive = + CoordinateTransform.convert( + original, + worldWidth, + worldDepth, + from = CoordinateSpace.WORLD_CENTERED, + to = CoordinateSpace.WORLD_POSITIVE, + ) + val roundTrip = + CoordinateTransform.convert( + positive, + worldWidth, + worldDepth, + from = CoordinateSpace.WORLD_POSITIVE, + to = CoordinateSpace.WORLD_CENTERED, + ) + assertTrue(abs(roundTrip.x - original.x) < epsilon, "x: ${roundTrip.x} != ${original.x}") + assertTrue(abs(roundTrip.y - original.y) < epsilon, "y: ${roundTrip.y} != ${original.y}") + assertTrue(abs(roundTrip.z - original.z) < epsilon, "z: ${roundTrip.z} != ${original.z}") + } + + // --- Edge cases --- + + @Test + fun `negative values in positive space convert correctly`() { + // This can happen if an agent is placed outside the world bounds + val result = CoordinateTransform.toPositive(-15f, worldWidth, CoordinateSpace.WORLD_CENTERED) + assertTrue(abs(result - (-5f)) < epsilon, "centered -15 -> positive -5, got $result") + } + + @Test + fun `zero world size does not crash`() { + val result = CoordinateTransform.toPositive(0f, 0f, CoordinateSpace.WORLD_CENTERED) + assertEquals(0f, result) + } +} diff --git a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/render/CognitiveWaveformTest.kt b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/render/CognitiveWaveformTest.kt index af8dee1..c9029aa 100644 --- a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/render/CognitiveWaveformTest.kt +++ b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/render/CognitiveWaveformTest.kt @@ -6,6 +6,7 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue import link.socket.phosphor.choreography.AgentLayer import link.socket.phosphor.choreography.AgentLayoutOrientation +import link.socket.phosphor.coordinate.CoordinateSpace import link.socket.phosphor.field.FlowLayer import link.socket.phosphor.field.SubstrateState import link.socket.phosphor.math.Vector2 @@ -268,4 +269,70 @@ class CognitiveWaveformTest { "PROCESSING peak ($processingPeak) should be taller than IDLE ($idlePeak)", ) } + + @Test + fun `centered agent position produces peak aligned with positive equivalent`() { + val substrate = createSubstrate(density = 0.1f) + + // Waveform expecting POSITIVE agent coordinates + val waveformPositive = + CognitiveWaveform( + gridWidth = 20, + gridDepth = 15, + worldWidth = 20f, + worldDepth = 15f, + agentCoordinateSpace = CoordinateSpace.WORLD_POSITIVE, + ) + val agentsPositive = createAgentLayer() + agentsPositive.addAgent( + AgentVisualState( + id = "pos", + name = "Pos", + role = "r", + // positive center + position = Vector2(10f, 7.5f), + state = AgentActivityState.PROCESSING, + ), + ) + waveformPositive.update(substrate, agentsPositive, null, dt = 1f) + + // Waveform expecting CENTERED agent coordinates + val waveformCentered = + CognitiveWaveform( + gridWidth = 20, + gridDepth = 15, + worldWidth = 20f, + worldDepth = 15f, + agentCoordinateSpace = CoordinateSpace.WORLD_CENTERED, + ) + val agentsCentered = createAgentLayer() + agentsCentered.addAgent( + AgentVisualState( + id = "cen", + name = "Cen", + role = "r", + // Same point in centered coords: 10 - 20/2 = 0, 7.5 - 15/2 = 0 + position = Vector2(0f, 0f), + state = AgentActivityState.PROCESSING, + ), + ) + waveformCentered.update(substrate, agentsCentered, null, dt = 1f) + + // Both should produce the same peak at the same grid location + val peakGx = 10 + val peakGz = 7 + val peakPositive = waveformPositive.heightAt(peakGx, peakGz) + val peakCentered = waveformCentered.heightAt(peakGx, peakGz) + + assertTrue( + abs(peakPositive - peakCentered) < 0.1f, + "peaks should align: positive=$peakPositive, centered=$peakCentered", + ) + + // Both peaks should be higher than edges + val edgePositive = waveformPositive.heightAt(0, 0) + val edgeCentered = waveformCentered.heightAt(0, 0) + assertTrue(peakPositive > edgePositive, "positive peak should be above edge") + assertTrue(peakCentered > edgeCentered, "centered peak should be above edge") + } }