From 3630bd4ca0fec10975b193fa4eba518a4d4b4abf Mon Sep 17 00:00:00 2001 From: Miley Chandonnet Date: Sun, 1 Mar 2026 15:33:13 -0500 Subject: [PATCH] AMPR-144 #443 bridge cost metadata into Phosphor Codex wrote this commit. Upgrade Ampere to Phosphor 0.3.0 and map provider completion telemetry into HEAT, INTENSITY, and DENSITY emitter metadata with covering tests. --- ampere-cli/build.gradle.kts | 2 +- .../kotlin/link/socket/ampere/DemoCommand.kt | 6 +- .../cli/hybrid/HybridDashboardRenderer.kt | 13 ++-- .../cli/hybrid/WatchStateAnimationBridge.kt | 28 ++++++- .../ampere/cli/render/AmperePhosphorBridge.kt | 59 +++++++++++++++ .../ampere/cli/render/CostNormalizer.kt | 35 +++++++++ .../ampere/cli/render/WaveformPaneRenderer.kt | 5 +- .../cli/watch/presentation/WatchPresenter.kt | 34 ++++++++- .../cli/watch/presentation/WatchViewState.kt | 17 ++++- .../link/socket/ampere/DemoCommandTest.kt | 6 +- .../hybrid/WatchStateAnimationBridgeTest.kt | 46 +++++++++++- .../cli/render/AmperePhosphorBridgeTest.kt | 73 +++++++++++++++++++ .../ampere/cli/render/CostNormalizerTest.kt | 51 +++++++++++++ .../cli/render/WaveformPaneRendererTest.kt | 5 +- ampere-compose/build.gradle.kts | 2 +- ampere-desktop/build.gradle.kts | 2 +- 16 files changed, 358 insertions(+), 26 deletions(-) create mode 100644 ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/render/AmperePhosphorBridge.kt create mode 100644 ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/render/CostNormalizer.kt create mode 100644 ampere-cli/src/jvmTest/kotlin/link/socket/ampere/cli/render/AmperePhosphorBridgeTest.kt create mode 100644 ampere-cli/src/jvmTest/kotlin/link/socket/ampere/cli/render/CostNormalizerTest.kt diff --git a/ampere-cli/build.gradle.kts b/ampere-cli/build.gradle.kts index 6b684af4..2fbf0295 100644 --- a/ampere-cli/build.gradle.kts +++ b/ampere-cli/build.gradle.kts @@ -84,7 +84,7 @@ kotlin { val jvmMain by getting { dependencies { implementation(project(":ampere-core")) - implementation("link.socket:phosphor-core:0.2.3") + implementation("link.socket:phosphor-core:0.3.0") // CLI argument parsing implementation("com.github.ajalt.clikt:clikt:4.4.0") diff --git a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/DemoCommand.kt b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/DemoCommand.kt index d0168840..12acb531 100644 --- a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/DemoCommand.kt +++ b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/DemoCommand.kt @@ -11,7 +11,6 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.runBlocking import link.socket.phosphor.choreography.AgentLayer import link.socket.phosphor.choreography.AgentLayoutOrientation -import link.socket.phosphor.bridge.CognitiveEmitterBridge import link.socket.phosphor.emitter.EmitterManager import link.socket.phosphor.field.FlowLayer import link.socket.phosphor.field.SubstrateState @@ -19,6 +18,7 @@ import link.socket.phosphor.timeline.PlaybackState import link.socket.phosphor.timeline.TimelineController import link.socket.phosphor.timeline.TimelineEvent import link.socket.phosphor.timeline.WaveformDemoTimeline +import link.socket.ampere.cli.render.AmperePhosphorBridge import link.socket.ampere.cli.render.WaveformPaneRenderer import link.socket.ampere.repl.TerminalFactory @@ -68,14 +68,14 @@ class WaveformDemoSubcommand : CliktCommand( val agents = AgentLayer(width, contentHeight, AgentLayoutOrientation.CIRCULAR) val flow = FlowLayer(width, contentHeight) val emitters = EmitterManager() - val emitterBridge = CognitiveEmitterBridge(emitters) + val emitterBridge = AmperePhosphorBridge(emitters) val substrate = SubstrateState.create(width, contentHeight, baseDensity = 0.2f) // Create waveform renderer val waveformPane = WaveformPaneRenderer( agentLayer = agents, emitterManager = emitters, - cognitiveEmitterBridge = emitterBridge + amperePhosphorBridge = emitterBridge ) // Build timeline and controller diff --git a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/hybrid/HybridDashboardRenderer.kt b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/hybrid/HybridDashboardRenderer.kt index 67d3b9d4..ecc88eae 100644 --- a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/hybrid/HybridDashboardRenderer.kt +++ b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/hybrid/HybridDashboardRenderer.kt @@ -1,11 +1,11 @@ package link.socket.ampere.cli.hybrid import com.github.ajalt.mordant.terminal.Terminal -import link.socket.phosphor.bridge.CognitiveEmitterBridge import link.socket.phosphor.emitter.EmitterManager import link.socket.phosphor.field.ParticleSystem import link.socket.ampere.cli.animation.render.AmperePalette import link.socket.ampere.cli.animation.render.CompositeRenderer +import link.socket.ampere.cli.render.AmperePhosphorBridge import link.socket.phosphor.field.SubstrateAnimator import link.socket.phosphor.field.SubstrateGlyphs import link.socket.phosphor.field.SubstrateState @@ -67,7 +67,7 @@ class HybridDashboardRenderer( // 3D waveform pipeline private lateinit var emitterManager: EmitterManager - private lateinit var cognitiveEmitterBridge: CognitiveEmitterBridge + private lateinit var amperePhosphorBridge: AmperePhosphorBridge /** * The waveform pane renderer for the middle pane. Callers can pass this @@ -149,19 +149,22 @@ class HybridDashboardRenderer( // Initialize waveform pipeline emitterManager = EmitterManager() - cognitiveEmitterBridge = CognitiveEmitterBridge(emitterManager) + amperePhosphorBridge = AmperePhosphorBridge(emitterManager) if (config.enableWaveform) { val wfPane = WaveformPaneRenderer( agentLayer = bridge.agentLayer, emitterManager = emitterManager, - cognitiveEmitterBridge = cognitiveEmitterBridge + amperePhosphorBridge = amperePhosphorBridge ) waveformPane = wfPane // Wire cognitive events from bridge to emitter bridge bridge.onCognitiveEvent = { event, position -> - cognitiveEmitterBridge.onCognitiveEvent(event, position) + amperePhosphorBridge.onCognitiveEvent(event, position) + } + bridge.onProviderTelemetry = { telemetry, position -> + amperePhosphorBridge.onProviderCallCompleted(telemetry, position) } } diff --git a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/hybrid/WatchStateAnimationBridge.kt b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/hybrid/WatchStateAnimationBridge.kt index 4c0ef486..400d56d7 100644 --- a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/hybrid/WatchStateAnimationBridge.kt +++ b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/hybrid/WatchStateAnimationBridge.kt @@ -19,6 +19,7 @@ import link.socket.phosphor.field.SubstrateState import link.socket.phosphor.math.Vector2 import link.socket.ampere.cli.watch.presentation.AgentState import link.socket.ampere.cli.watch.presentation.EventSignificance +import link.socket.ampere.cli.watch.presentation.ProviderCallTelemetrySummary import link.socket.ampere.cli.watch.presentation.WatchViewState /** @@ -44,6 +45,7 @@ class WatchStateAnimationBridge( private val maxParticles: Int = 30 ) { private var previousEventCount = 0 + private var previousProviderTelemetryIds = emptySet() private var previousAgentStates = mapOf() private val burstEmitter = BurstEmitter() @@ -69,6 +71,11 @@ class WatchStateAnimationBridge( */ var onCognitiveEvent: ((CognitiveEvent, Vector3) -> Unit)? = null + /** + * Callback invoked when provider telemetry should generate metadata-rich emitters. + */ + var onProviderTelemetry: ((ProviderCallTelemetrySummary, Vector3) -> Unit)? = null + /** * Update animation state based on current watch state. * @@ -89,14 +96,17 @@ class WatchStateAnimationBridge( // Update substrate hotspots based on active agents var result = updateHotspotsFromAgentActivity(viewState, substrate) + // Sync agent positions before event-driven emitters use them. + syncAgentLayer(viewState) + // Detect new significant events and spawn particles detectNewEvents(viewState) + detectProviderTelemetry(viewState) // Detect agent state transitions and trigger pulses detectStateTransitions(viewState, result)?.let { result = it } - // Sync agent layer from view state and run choreographer - syncAgentLayer(viewState) + // Run phase-based choreography on the synchronized agent layer result = choreographer.update(agentLayer, result, deltaSeconds) // Apply ambient animation @@ -110,6 +120,8 @@ class WatchStateAnimationBridge( // Track state for next frame previousEventCount = viewState.recentSignificantEvents.size + previousProviderTelemetryIds = viewState.recentProviderTelemetry + .mapTo(linkedSetOf()) { it.eventId } previousAgentStates = viewState.agentStates.mapValues { it.value.currentState } return result @@ -259,6 +271,18 @@ class WatchStateAnimationBridge( } } + private fun detectProviderTelemetry(viewState: WatchViewState) { + if (viewState.recentProviderTelemetry.isEmpty()) return + + viewState.recentProviderTelemetry + .asReversed() + .filter { it.eventId !in previousProviderTelemetryIds } + .forEach { telemetry -> + val agentPos = agentLayer.getAgent(telemetry.agentId)?.position3D ?: Vector3.ZERO + onProviderTelemetry?.invoke(telemetry, agentPos) + } + } + private fun detectStateTransitions( viewState: WatchViewState, substrate: SubstrateState diff --git a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/render/AmperePhosphorBridge.kt b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/render/AmperePhosphorBridge.kt new file mode 100644 index 00000000..03feca27 --- /dev/null +++ b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/render/AmperePhosphorBridge.kt @@ -0,0 +1,59 @@ +package link.socket.ampere.cli.render + +import link.socket.ampere.agents.domain.cognition.sparks.CognitivePhase as AmperePhase +import link.socket.ampere.cli.watch.presentation.ProviderCallTelemetrySummary +import link.socket.phosphor.bridge.CognitiveEmitterBridge +import link.socket.phosphor.bridge.CognitiveEvent +import link.socket.phosphor.emitter.EmitterEffect +import link.socket.phosphor.emitter.EmitterManager +import link.socket.phosphor.emitter.MetadataKeys +import link.socket.phosphor.math.Vector3 +import link.socket.phosphor.palette.AsciiLuminancePalette +import link.socket.phosphor.palette.CognitiveColorRamp +import link.socket.phosphor.signal.CognitivePhase as PhosphorPhase + +/** + * Preserves the existing cognitive emitter choreography and adds Ampere-specific + * provider-call metadata channels for Phosphor 0.3.0. + */ +class AmperePhosphorBridge( + private val emitterManager: EmitterManager, + private val cognitiveEmitterBridge: CognitiveEmitterBridge = CognitiveEmitterBridge(emitterManager) +) { + fun onCognitiveEvent(event: CognitiveEvent, agentPosition: Vector3) { + cognitiveEmitterBridge.onCognitiveEvent(event, agentPosition) + } + + fun onProviderCallCompleted( + event: ProviderCallTelemetrySummary, + agentPosition: Vector3, + currentTime: Float = 0f + ) { + emitterManager.emit( + effect = effectForPhase(event.cognitivePhase), + position = agentPosition, + currentTime = currentTime, + metadata = metadataFor(event) + ) + } + + internal fun metadataFor(event: ProviderCallTelemetrySummary): Map = buildMap { + event.estimatedCost?.let { put(MetadataKeys.HEAT, CostNormalizer.normalizeCost(it)) } + event.totalTokens?.let { put(MetadataKeys.DENSITY, CostNormalizer.normalizeTokens(it)) } + put(MetadataKeys.INTENSITY, CostNormalizer.normalizeLatency(event.latencyMs)) + } + + private fun effectForPhase(phase: AmperePhase?): EmitterEffect = when (phase) { + AmperePhase.PERCEIVE -> EmitterEffect.HeightPulse() + AmperePhase.PLAN -> EmitterEffect.ColorWash( + colorRamp = CognitiveColorRamp.forPhase(PhosphorPhase.PLAN) + ) + AmperePhase.EXECUTE -> EmitterEffect.SparkBurst( + palette = AsciiLuminancePalette.EXECUTE + ) + AmperePhase.LEARN -> EmitterEffect.ColorWash( + colorRamp = CognitiveColorRamp.forPhase(PhosphorPhase.EVALUATE) + ) + null -> EmitterEffect.HeightPulse() + } +} diff --git a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/render/CostNormalizer.kt b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/render/CostNormalizer.kt new file mode 100644 index 00000000..6528891c --- /dev/null +++ b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/render/CostNormalizer.kt @@ -0,0 +1,35 @@ +package link.socket.ampere.cli.render + +import kotlin.math.log10 + +/** + * Normalizes provider telemetry into 0.0-1.0 metadata channels for Phosphor. + */ +object CostNormalizer { + fun normalizeCost(costUsd: Double): Float { + if (costUsd <= 0.0) return 0f + return normalizeLog1p(value = costUsd, pivot = 0.0001, ceiling = 0.2) + } + + fun normalizeLatency(latencyMs: Long): Float { + if (latencyMs <= 0L) return 0f + return normalizeLog1p(value = latencyMs.toDouble(), pivot = 100.0, ceiling = 15_000.0) + } + + fun normalizeTokens(totalTokens: Int): Float { + if (totalTokens <= 0) return 0f + return normalizeLog1p(value = totalTokens.toDouble(), pivot = 250.0, ceiling = 150_000.0) + } + + private fun normalizeLog1p( + value: Double, + pivot: Double, + ceiling: Double + ): Float { + val capped = value.coerceIn(0.0, ceiling) + if (capped == 0.0) return 0f + + val normalized = log10(1.0 + capped / pivot) / log10(1.0 + ceiling / pivot) + return normalized.toFloat().coerceIn(0f, 1f) + } +} diff --git a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/render/WaveformPaneRenderer.kt b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/render/WaveformPaneRenderer.kt index d2f6a5a8..8d955445 100644 --- a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/render/WaveformPaneRenderer.kt +++ b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/render/WaveformPaneRenderer.kt @@ -2,7 +2,6 @@ package link.socket.ampere.cli.render import link.socket.phosphor.choreography.AgentLayer import link.socket.phosphor.choreography.AgentLayoutOrientation -import link.socket.phosphor.bridge.CognitiveEmitterBridge import link.socket.phosphor.bridge.CognitiveEvent import link.socket.phosphor.emitter.EmitterManager import link.socket.phosphor.field.FlowLayer @@ -33,7 +32,7 @@ import link.socket.ampere.cli.layout.PaneRenderer class WaveformPaneRenderer( private val agentLayer: AgentLayer, private val emitterManager: EmitterManager, - private val cognitiveEmitterBridge: CognitiveEmitterBridge + private val amperePhosphorBridge: AmperePhosphorBridge ) : PaneRenderer { private var cameraOrbit = CameraOrbit( @@ -79,7 +78,7 @@ class WaveformPaneRenderer( * Fire a cognitive event through the emitter bridge. */ fun onCognitiveEvent(event: CognitiveEvent, agentPosition: Vector3) { - cognitiveEmitterBridge.onCognitiveEvent(event, agentPosition) + amperePhosphorBridge.onCognitiveEvent(event, agentPosition) } override fun render(width: Int, height: Int): List { diff --git a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/watch/presentation/WatchPresenter.kt b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/watch/presentation/WatchPresenter.kt index 65efcb09..294e08fc 100644 --- a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/watch/presentation/WatchPresenter.kt +++ b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/watch/presentation/WatchPresenter.kt @@ -17,6 +17,7 @@ import link.socket.ampere.agents.domain.event.MeetingEvent import link.socket.ampere.agents.domain.event.MemoryEvent import link.socket.ampere.agents.domain.event.MessageEvent import link.socket.ampere.agents.domain.event.ProductEvent +import link.socket.ampere.agents.domain.event.ProviderCallCompletedEvent import link.socket.ampere.agents.domain.event.SparkAppliedEvent import link.socket.ampere.agents.domain.event.SparkEvent import link.socket.ampere.agents.domain.event.SparkRemovedEvent @@ -45,6 +46,7 @@ class WatchPresenter( // Mutable state - only modified by event handler private val agentStates = mutableMapOf() private val significantEvents = mutableListOf() + private val providerTelemetry = mutableListOf() private var systemVitals = SystemVitals() private var eventCollectionJob: Job? = null @@ -98,6 +100,10 @@ class WatchPresenter( addSignificantEvent(event, significance) } + if (event is ProviderCallCompletedEvent) { + addProviderTelemetry(agentId, event) + } + // Update system vitals updateSystemVitals(significance) @@ -229,6 +235,31 @@ class WatchPresenter( invalidateCache() } + private fun addProviderTelemetry(agentId: String, event: ProviderCallCompletedEvent) { + val totalTokens = listOfNotNull(event.usage.inputTokens, event.usage.outputTokens) + .takeIf { it.isNotEmpty() } + ?.sum() + + providerTelemetry.add( + 0, + ProviderCallTelemetrySummary( + eventId = event.eventId, + agentId = agentId, + cognitivePhase = event.cognitivePhase, + latencyMs = event.latencyMs, + estimatedCost = event.usage.estimatedCost, + totalTokens = totalTokens, + success = event.success + ) + ) + + while (providerTelemetry.size > 20) { + providerTelemetry.removeLast() + } + + invalidateCache() + } + private fun updateSystemVitals(significance: EventSignificance) { val activeCount = agentStates.count { !it.value.isIdle } val hasCritical = significance == EventSignificance.CRITICAL @@ -372,7 +403,8 @@ class WatchPresenter( val viewState = WatchViewState( systemVitals = systemVitals, agentStates = agentStates.toMap(), // Immutable copy - recentSignificantEvents = significantEvents.toList() // Immutable copy + recentSignificantEvents = significantEvents.toList(), // Immutable copy + recentProviderTelemetry = providerTelemetry.toList() ) cachedViewState = viewState diff --git a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/watch/presentation/WatchViewState.kt b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/watch/presentation/WatchViewState.kt index 9689e9a5..eb1ffb82 100644 --- a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/watch/presentation/WatchViewState.kt +++ b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/watch/presentation/WatchViewState.kt @@ -1,6 +1,7 @@ package link.socket.ampere.cli.watch.presentation import kotlinx.datetime.Instant +import link.socket.ampere.agents.domain.cognition.sparks.CognitivePhase /** * Immutable snapshot of watch state for rendering. @@ -8,7 +9,8 @@ import kotlinx.datetime.Instant data class WatchViewState( val systemVitals: SystemVitals, val agentStates: Map, - val recentSignificantEvents: List + val recentSignificantEvents: List, + val recentProviderTelemetry: List = emptyList() ) /** @@ -22,3 +24,16 @@ data class SignificantEventSummary( val summaryText: String, val significance: EventSignificance ) + +/** + * Telemetry summary for bridging provider calls into Phosphor emitter metadata. + */ +data class ProviderCallTelemetrySummary( + val eventId: String, + val agentId: String, + val cognitivePhase: CognitivePhase?, + val latencyMs: Long, + val estimatedCost: Double?, + val totalTokens: Int?, + val success: Boolean +) diff --git a/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/DemoCommandTest.kt b/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/DemoCommandTest.kt index c1b1924c..db1cd90e 100644 --- a/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/DemoCommandTest.kt +++ b/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/DemoCommandTest.kt @@ -2,13 +2,13 @@ package link.socket.ampere import link.socket.phosphor.choreography.AgentLayer import link.socket.phosphor.choreography.AgentLayoutOrientation -import link.socket.phosphor.bridge.CognitiveEmitterBridge import link.socket.phosphor.emitter.EmitterManager import link.socket.phosphor.field.FlowLayer import link.socket.phosphor.field.SubstrateState import link.socket.phosphor.timeline.TimelineController import link.socket.phosphor.timeline.TimelineEvent import link.socket.phosphor.timeline.WaveformDemoTimeline +import link.socket.ampere.cli.render.AmperePhosphorBridge import link.socket.ampere.cli.render.WaveformPaneRenderer import kotlin.test.Test import kotlin.test.assertEquals @@ -27,13 +27,13 @@ class DemoCommandTest { val agents = AgentLayer(width, height, AgentLayoutOrientation.CIRCULAR) val flow = FlowLayer(width, height) val emitters = EmitterManager() - val emitterBridge = CognitiveEmitterBridge(emitters) + val emitterBridge = AmperePhosphorBridge(emitters) val substrate = SubstrateState.create(width, height, baseDensity = 0.2f) val waveformPane = WaveformPaneRenderer( agentLayer = agents, emitterManager = emitters, - cognitiveEmitterBridge = emitterBridge + amperePhosphorBridge = emitterBridge ) val timeline = WaveformDemoTimeline.build(agents, flow, emitters) diff --git a/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/cli/hybrid/WatchStateAnimationBridgeTest.kt b/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/cli/hybrid/WatchStateAnimationBridgeTest.kt index b331bc17..9b77731b 100644 --- a/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/cli/hybrid/WatchStateAnimationBridgeTest.kt +++ b/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/cli/hybrid/WatchStateAnimationBridgeTest.kt @@ -1,12 +1,14 @@ package link.socket.ampere.cli.hybrid import kotlinx.datetime.Clock +import link.socket.ampere.agents.domain.cognition.sparks.CognitivePhase import link.socket.phosphor.field.ParticleSystem import link.socket.phosphor.field.SubstrateAnimator import link.socket.phosphor.field.SubstrateState import link.socket.ampere.cli.watch.presentation.AgentActivityState import link.socket.ampere.cli.watch.presentation.AgentState import link.socket.ampere.cli.watch.presentation.EventSignificance +import link.socket.ampere.cli.watch.presentation.ProviderCallTelemetrySummary import link.socket.ampere.cli.watch.presentation.SignificantEventSummary import link.socket.ampere.cli.watch.presentation.SystemVitals import link.socket.ampere.cli.watch.presentation.WatchViewState @@ -36,7 +38,8 @@ class WatchStateAnimationBridgeTest { private fun createViewState( agents: Map = emptyMap(), - events: List = emptyList() + events: List = emptyList(), + telemetry: List = emptyList() ): WatchViewState { return WatchViewState( systemVitals = SystemVitals( @@ -45,7 +48,8 @@ class WatchStateAnimationBridgeTest { lastSignificantEventTime = now ), agentStates = agents, - recentSignificantEvents = events + recentSignificantEvents = events, + recentProviderTelemetry = telemetry ) } @@ -77,6 +81,18 @@ class WatchStateAnimationBridgeTest { ) } + private fun createTelemetry(agentId: String): ProviderCallTelemetrySummary { + return ProviderCallTelemetrySummary( + eventId = "provider-${System.nanoTime()}", + agentId = agentId, + cognitivePhase = CognitivePhase.EXECUTE, + latencyMs = 900, + estimatedCost = 0.01, + totalTokens = 4_000, + success = true + ) + } + @Test fun `null viewState returns ambient-updated substrate`() { val (bridge, substrate, _) = createBridge() @@ -192,6 +208,32 @@ class WatchStateAnimationBridgeTest { assertTrue(particles.count <= 30, "Particles should be bounded at max 30, got ${particles.count}") } + @Test + fun `provider telemetry callback fires for new provider event`() { + val (bridge, substrate, _) = createBridge() + val agents = mapOf( + "agent-1" to createAgent("agent-1", AgentState.WORKING, idle = false) + ) + val telemetry = mutableListOf() + + bridge.onProviderTelemetry = { summary, _ -> + telemetry += summary + } + + bridge.update(createViewState(agents = agents), substrate, 0.1f) + bridge.update( + createViewState( + agents = agents, + telemetry = listOf(createTelemetry("agent-1")) + ), + substrate, + 0.1f + ) + + assertEquals(1, telemetry.size) + assertEquals("agent-1", telemetry.single().agentId) + } + @Test fun `choreographer triggers on cognitive phase transition`() { val (bridge, substrate, particles) = createBridge() diff --git a/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/cli/render/AmperePhosphorBridgeTest.kt b/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/cli/render/AmperePhosphorBridgeTest.kt new file mode 100644 index 00000000..d09138dd --- /dev/null +++ b/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/cli/render/AmperePhosphorBridgeTest.kt @@ -0,0 +1,73 @@ +package link.socket.ampere.cli.render + +import link.socket.ampere.agents.domain.cognition.sparks.CognitivePhase +import link.socket.ampere.cli.watch.presentation.ProviderCallTelemetrySummary +import link.socket.phosphor.emitter.EmitterManager +import link.socket.phosphor.emitter.MetadataKeys +import link.socket.phosphor.math.Vector3 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class AmperePhosphorBridgeTest { + @Test + fun `provider telemetry emits metadata rich effect`() { + val emitterManager = EmitterManager() + val bridge = AmperePhosphorBridge(emitterManager) + val telemetry = ProviderCallTelemetrySummary( + eventId = "evt-provider-1", + agentId = "agent-1", + cognitivePhase = CognitivePhase.EXECUTE, + latencyMs = 1_200, + estimatedCost = 0.015, + totalTokens = 6_000, + success = true + ) + + bridge.onProviderCallCompleted(telemetry, Vector3(2f, 0f, 3f), currentTime = 1.5f) + + assertEquals(1, emitterManager.activeCount) + val instance = emitterManager.instances.single() + assertEquals(1.5f, instance.activatedAt) + assertEquals(Vector3(2f, 0f, 3f), instance.position) + assertTrue(instance.metadata.containsKey(MetadataKeys.HEAT)) + assertTrue(instance.metadata.containsKey(MetadataKeys.INTENSITY)) + assertTrue(instance.metadata.containsKey(MetadataKeys.DENSITY)) + assertEquals( + CostNormalizer.normalizeCost(0.015), + instance.metadata.getValue(MetadataKeys.HEAT) + ) + assertEquals( + CostNormalizer.normalizeLatency(1_200), + instance.metadata.getValue(MetadataKeys.INTENSITY) + ) + assertEquals( + CostNormalizer.normalizeTokens(6_000), + instance.metadata.getValue(MetadataKeys.DENSITY) + ) + } + + @Test + fun `provider telemetry without cost still emits latency metadata`() { + val emitterManager = EmitterManager() + val bridge = AmperePhosphorBridge(emitterManager) + val telemetry = ProviderCallTelemetrySummary( + eventId = "evt-provider-2", + agentId = "agent-2", + cognitivePhase = null, + latencyMs = 250, + estimatedCost = null, + totalTokens = null, + success = false + ) + + bridge.onProviderCallCompleted(telemetry, Vector3.ZERO) + + val instance = emitterManager.instances.single() + assertEquals(setOf(MetadataKeys.INTENSITY), instance.metadata.keys) + assertEquals( + CostNormalizer.normalizeLatency(250), + instance.metadata.getValue(MetadataKeys.INTENSITY) + ) + } +} diff --git a/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/cli/render/CostNormalizerTest.kt b/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/cli/render/CostNormalizerTest.kt new file mode 100644 index 00000000..27f2ed21 --- /dev/null +++ b/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/cli/render/CostNormalizerTest.kt @@ -0,0 +1,51 @@ +package link.socket.ampere.cli.render + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CostNormalizerTest { + @Test + fun `cost normalization uses broad logarithmic bands`() { + val haikuCost = CostNormalizer.normalizeCost(0.0001) + val sonnetCost = CostNormalizer.normalizeCost(0.01) + val opusCost = CostNormalizer.normalizeCost(0.1) + + assertTrue(haikuCost in 0.05f..0.2f, "Expected low-cost call to stay subtle, got $haikuCost") + assertTrue(sonnetCost in 0.45f..0.7f, "Expected mid-cost call near the middle, got $sonnetCost") + assertTrue(opusCost in 0.75f..0.95f, "Expected high-cost call to read as hot, got $opusCost") + } + + @Test + fun `latency normalization spans interactive to slow calls`() { + val fast = CostNormalizer.normalizeLatency(100) + val medium = CostNormalizer.normalizeLatency(1_000) + val slow = CostNormalizer.normalizeLatency(10_000) + + assertTrue(fast in 0.1f..0.2f, "Expected fast call to stay low intensity, got $fast") + assertTrue(medium in 0.4f..0.6f, "Expected medium call near midpoint, got $medium") + assertTrue(slow in 0.85f..0.95f, "Expected slow call to read as intense, got $slow") + } + + @Test + fun `token normalization distinguishes sparse and dense calls`() { + val sparse = CostNormalizer.normalizeTokens(100) + val medium = CostNormalizer.normalizeTokens(5_000) + val dense = CostNormalizer.normalizeTokens(100_000) + + assertTrue(sparse in 0.02f..0.1f, "Expected sparse call to remain faint, got $sparse") + assertTrue(medium in 0.4f..0.6f, "Expected medium call near midpoint, got $medium") + assertTrue(dense in 0.85f..0.95f, "Expected dense call to saturate strongly, got $dense") + } + + @Test + fun `zero and extreme values stay clamped`() { + assertEquals(0f, CostNormalizer.normalizeCost(0.0)) + assertEquals(0f, CostNormalizer.normalizeLatency(0)) + assertEquals(0f, CostNormalizer.normalizeTokens(0)) + + assertEquals(1f, CostNormalizer.normalizeCost(10.0)) + assertEquals(1f, CostNormalizer.normalizeLatency(60_000)) + assertEquals(1f, CostNormalizer.normalizeTokens(500_000)) + } +} diff --git a/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/cli/render/WaveformPaneRendererTest.kt b/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/cli/render/WaveformPaneRendererTest.kt index cef5f4cd..d88076da 100644 --- a/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/cli/render/WaveformPaneRendererTest.kt +++ b/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/cli/render/WaveformPaneRendererTest.kt @@ -5,7 +5,6 @@ import link.socket.phosphor.choreography.AgentLayer import link.socket.phosphor.choreography.AgentLayoutOrientation import link.socket.phosphor.signal.AgentVisualState import link.socket.phosphor.signal.CognitivePhase -import link.socket.phosphor.bridge.CognitiveEmitterBridge import link.socket.phosphor.emitter.EmitterManager import link.socket.phosphor.math.Vector3 import link.socket.phosphor.render.AsciiCell @@ -19,12 +18,12 @@ class WaveformPaneRendererTest { private val agentLayer = AgentLayer(80, 24, AgentLayoutOrientation.CUSTOM) private val emitterManager = EmitterManager() - private val cognitiveEmitterBridge = CognitiveEmitterBridge(emitterManager) + private val amperePhosphorBridge = AmperePhosphorBridge(emitterManager) private val renderer = WaveformPaneRenderer( agentLayer = agentLayer, emitterManager = emitterManager, - cognitiveEmitterBridge = cognitiveEmitterBridge + amperePhosphorBridge = amperePhosphorBridge ) @Test diff --git a/ampere-compose/build.gradle.kts b/ampere-compose/build.gradle.kts index 94149f42..81992f41 100644 --- a/ampere-compose/build.gradle.kts +++ b/ampere-compose/build.gradle.kts @@ -23,7 +23,7 @@ kotlin { sourceSets { val commonMain by getting { dependencies { - implementation("link.socket:phosphor-core:0.2.3") + implementation("link.socket:phosphor-core:0.3.0") implementation(compose.runtime) implementation(compose.foundation) implementation(compose.ui) diff --git a/ampere-desktop/build.gradle.kts b/ampere-desktop/build.gradle.kts index 40f82b30..5c260621 100644 --- a/ampere-desktop/build.gradle.kts +++ b/ampere-desktop/build.gradle.kts @@ -21,7 +21,7 @@ kotlin { implementation(project(":ampere-core")) implementation(project(":ampere-compose")) implementation(project(":ampere-cli")) - implementation("link.socket:phosphor-core:0.2.3") + implementation("link.socket:phosphor-core:0.3.0") implementation(compose.desktop.currentOs) } }