Skip to content

Commit faf9097

Browse files
wow-mileyMiley Chandonnetclaude
authored
AMPR-174 #503: EscalationConsidered telemetry (incl. AMPR-173 #502 base) (#515)
I implemented EscalationConsidered as a sibling CognitiveEvent emitted on every uncertainty evaluation — including near-misses — with a `fired: Boolean` discriminator and Urgency.LOW for high-volume telemetry. UncertaintyEscalationEvaluator publishes Considered first, then EscalationFired when the threshold trips (causal order at the publish site; bus dispatch remains concurrent). Because AMPR-173 #502 (EscalationFired) is not yet merged, I stacked its implementation as the base of this commit so AMPR-174 #503 has a publish site to extend. The AMPR-173 base adds the CognitiveEvent sealed interface, EscalationFired data class, evaluator scaffold, EventRegistry/AgentEventApi hooks, CLI rendering, serialization tests, and docs; AMPR-174 layers the Considered variant on top of all of those surfaces. Closes #503 Co-authored-by: Miley Chandonnet <miley@Mileys-Mac-mini.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a3e8808 commit faf9097

15 files changed

Lines changed: 329 additions & 3 deletions

File tree

ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/watch/presentation/EventCategorizer.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ object EventCategorizer {
102102
is GitEvent.Committed,
103103
is GitEvent.Pushed,
104104
is GitEvent.FilesStaged,
105+
is CognitiveEvent.EscalationConsidered,
105106
is MemoryEvent.KnowledgeRecalled,
106107
is MemoryEvent.KnowledgeStored,
107108
is NotificationEvent<*>,

ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/watch/presentation/WatchPresenter.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,10 @@ class WatchPresenter(
375375
is CognitiveEvent.EscalationFired -> {
376376
"Uncertainty escalation: ${event.uncertaintyValue} >= ${event.threshold}"
377377
}
378+
is CognitiveEvent.EscalationConsidered -> {
379+
val direction = if (event.fired) ">=" else "<"
380+
"Uncertainty considered: ${event.uncertaintyValue} $direction ${event.threshold}"
381+
}
378382
is MemoryEvent.KnowledgeRecalled -> {
379383
val count = event.resultsFound
380384
if (count > 0) "Recalled $count items (relevance: ${String.format("%.0f%%", event.averageRelevance * 100)})"

ampere-cli/src/jvmMain/kotlin/link/socket/ampere/renderer/EventRenderer.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ class EventRenderer(
136136
is Event.QuestionRaised -> "" to magenta
137137
is Event.TaskCreated -> "📋" to green
138138
is CognitiveEvent.EscalationFired -> "" to red
139+
is CognitiveEvent.EscalationConsidered -> "" to gray
139140
is TaskEvent -> "📋" to green
140141
is FileSystemEvent -> "📄" to cyan
141142
is GitEvent -> "🪾" to cyan

ampere-cli/src/jvmTest/kotlin/link/socket/ampere/cli/watch/presentation/EventCategorizerTest.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,23 @@ class EventCategorizerTest {
8787
assertEquals(EventSignificance.CRITICAL, significance)
8888
}
8989

90+
@Test
91+
fun `ROUTINE - EscalationConsidered events are routine telemetry`() {
92+
val event = CognitiveEvent.EscalationConsidered(
93+
eventId = "evt-escalation-considered",
94+
timestamp = Clock.System.now(),
95+
eventSource = EventSource.Agent("agent-test"),
96+
agentId = "agent-test",
97+
uncertaintyValue = 0.42,
98+
threshold = 0.7,
99+
fired = false,
100+
cognitivePhase = null,
101+
)
102+
103+
val significance = EventCategorizer.categorize(event)
104+
assertEquals(EventSignificance.ROUTINE, significance)
105+
}
106+
90107
@Test
91108
fun `SIGNIFICANT - TaskCreated events are significant`() {
92109
val event = Event.TaskCreated(

ampere-cli/src/jvmTest/kotlin/link/socket/ampere/renderer/EventRendererTest.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,22 @@ class EventRendererTest {
227227
cognitivePhase = null,
228228
)
229229

230+
private fun escalationConsideredEvent(
231+
eventId: String = "evt-escalation-considered-1",
232+
timestamp: Instant = Clock.System.now(),
233+
source: EventSource = EventSource.Agent("agent-test"),
234+
fired: Boolean = false,
235+
): CognitiveEvent.EscalationConsidered = CognitiveEvent.EscalationConsidered(
236+
eventId = eventId,
237+
timestamp = timestamp,
238+
eventSource = source,
239+
agentId = "agent-test",
240+
uncertaintyValue = 0.42,
241+
threshold = 0.7,
242+
fired = fired,
243+
cognitivePhase = null,
244+
)
245+
230246
@Test
231247
fun `render TaskCreated event shows task ID, description, and assignment`() {
232248
val output = captureTerminalOutput { _, renderer ->
@@ -427,6 +443,12 @@ class EventRendererTest {
427443
}
428444
assertContains(escalationOutput, "")
429445
assertContains(escalationOutput, "EscalationFired")
446+
447+
val consideredOutput = captureTerminalOutput { _, renderer ->
448+
renderer.render(escalationConsideredEvent())
449+
}
450+
assertContains(consideredOutput, "")
451+
assertContains(consideredOutput, "EscalationConsidered")
430452
}
431453

432454
@Test

ampere-cli/src/jvmTest/kotlin/link/socket/ampere/util/EventTypeParserTest.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ class EventTypeParserTest {
5151
@Test
5252
fun `parse handles CognitiveEvent types`() {
5353
assertNotNull(EventTypeParser.parse("EscalationFired"))
54+
assertNotNull(EventTypeParser.parse("EscalationConsidered"))
5455
}
5556

5657
@Test
@@ -165,6 +166,7 @@ class EventTypeParserTest {
165166
assertTrue(allTypes.contains(Event.QuestionRaised.EVENT_TYPE))
166167
assertTrue(allTypes.contains(Event.CodeSubmitted.EVENT_TYPE))
167168
assertTrue(allTypes.contains(CognitiveEvent.EscalationFired.EVENT_TYPE))
169+
assertTrue(allTypes.contains(CognitiveEvent.EscalationConsidered.EVENT_TYPE))
168170

169171
// Should have meeting events
170172
assertTrue(allTypes.contains(MeetingEvent.MeetingScheduled.EVENT_TYPE))

ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/event/CognitiveEvent.kt

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,55 @@ sealed interface CognitiveEvent : Event {
5555
const val EVENT_TYPE: EventType = "EscalationFired"
5656
}
5757
}
58+
59+
/**
60+
* Emitted on every uncertainty evaluation, including near-misses that do not trip the threshold.
61+
*
62+
* Use this event for telemetry — uncertainty trajectory, calibration analysis, "about to fire"
63+
* warnings — not for action signals. When an evaluation also fires, an [EscalationFired] is
64+
* published immediately after this event (causal order at the publish site; subscribers cannot
65+
* assume cross-event handler ordering because bus dispatch is concurrent).
66+
*
67+
* Volume warning: uncertainty may be evaluated on every LLM call or tool invocation, producing
68+
* thousands of events per agent run. Subscribe only if you have a real use for the data;
69+
* non-telemetry consumers should filter this event out. Urgency is [Urgency.LOW] for the same
70+
* reason.
71+
*/
72+
@Serializable
73+
@SerialName("CognitiveEvent.EscalationConsidered")
74+
data class EscalationConsidered(
75+
override val eventId: EventId,
76+
override val timestamp: Instant,
77+
override val eventSource: EventSource,
78+
override val urgency: Urgency = Urgency.LOW,
79+
override val agentId: AgentId,
80+
val uncertaintyValue: Double,
81+
val threshold: Double,
82+
/** True iff this evaluation also produced an [EscalationFired]. */
83+
val fired: Boolean,
84+
val cognitivePhase: CognitivePhase?,
85+
) : CognitiveEvent {
86+
87+
override val eventType: EventType = EVENT_TYPE
88+
89+
override fun getSummary(
90+
formatUrgency: (Urgency) -> String,
91+
formatSource: (EventSource) -> String,
92+
): String = buildString {
93+
append("Escalation considered for $agentId: uncertainty ")
94+
append(uncertaintyValue.formatRatio())
95+
append(if (fired) " >= threshold " else " < threshold ")
96+
append(threshold.formatRatio())
97+
cognitivePhase?.let { append(" [${it.name}]") }
98+
append(if (fired) " (fired)" else " (near-miss)")
99+
append(" ${formatUrgency(urgency)}")
100+
append(" from ${formatSource(eventSource)}")
101+
}
102+
103+
companion object {
104+
const val EVENT_TYPE: EventType = "EscalationConsidered"
105+
}
106+
}
58107
}
59108

60109
private fun Double.formatRatio(): String {

ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/event/EventRegistry.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ object EventRegistry {
3232

3333
// CognitiveEvent types
3434
CognitiveEvent.EscalationFired.EVENT_TYPE,
35+
CognitiveEvent.EscalationConsidered.EVENT_TYPE,
3536

3637
// MeetingEvent types
3738
MeetingEvent.MeetingScheduled.EVENT_TYPE,

ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/events/api/AgentEventApi.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,26 @@ class AgentEventApi(
221221
}
222222
}
223223

224+
/**
225+
* Subscribe to every uncertainty evaluation, including near-misses.
226+
*
227+
* High-volume telemetry — fires on every evaluation, potentially thousands per agent run.
228+
* Subscribe only for telemetry, calibration analysis, or near-miss UI. For action signals
229+
* use [onEscalationFired] instead.
230+
*/
231+
fun onEscalationConsidered(
232+
filter: EventFilter<CognitiveEvent.EscalationConsidered> = EventFilter.noFilter(),
233+
handler: suspend (CognitiveEvent.EscalationConsidered, Subscription?) -> Unit,
234+
): Subscription =
235+
eventSerialBus.subscribe<CognitiveEvent.EscalationConsidered, EventSubscription.ByEventClassType>(
236+
agentId = agentId,
237+
eventType = CognitiveEvent.EscalationConsidered.EVENT_TYPE,
238+
) { event, subscription ->
239+
if (filter.execute(event)) {
240+
handler(event, subscription)
241+
}
242+
}
243+
224244
/** Retrieve all events since the provided timestamp, or all if null. */
225245
suspend fun getRecentEvents(
226246
since: Instant?,

ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/events/escalation/UncertaintyEscalationEvaluator.kt

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,17 @@ import link.socket.ampere.agents.events.api.AgentEventApi
1010
import link.socket.ampere.agents.events.utils.generateUUID
1111

1212
/**
13-
* Evaluates uncertainty against an escalation threshold and publishes the threshold-fired signal.
13+
* Evaluates uncertainty against an escalation threshold and publishes:
14+
* - [CognitiveEvent.EscalationConsidered] on every evaluation (high-volume telemetry)
15+
* - [CognitiveEvent.EscalationFired] when the uncertainty meets or exceeds the threshold
16+
*
17+
* When both events publish, `EscalationConsidered` is emitted first so consumers tracking
18+
* near-misses see the causal order at the publish site. Bus dispatch is concurrent, so
19+
* subscribers cannot rely on handler-level cross-event ordering.
1420
*/
1521
class UncertaintyEscalationEvaluator(
1622
private val agentId: AgentId,
23+
private val publishEscalationConsidered: suspend (CognitiveEvent.EscalationConsidered) -> Unit,
1724
private val publishEscalationFired: suspend (CognitiveEvent.EscalationFired) -> Unit,
1825
private val clock: Clock = Clock.System,
1926
) {
@@ -23,12 +30,14 @@ class UncertaintyEscalationEvaluator(
2330
clock: Clock = Clock.System,
2431
) : this(
2532
agentId = agentEventApi.agentId,
33+
publishEscalationConsidered = { event -> agentEventApi.publish(event) },
2634
publishEscalationFired = { event -> agentEventApi.publish(event) },
2735
clock = clock,
2836
)
2937

3038
/**
3139
* @return true when the threshold fired and an [CognitiveEvent.EscalationFired] was published.
40+
* An [CognitiveEvent.EscalationConsidered] is always published, regardless of return value.
3241
*/
3342
suspend fun evaluate(
3443
uncertaintyValue: Double,
@@ -44,15 +53,32 @@ class UncertaintyEscalationEvaluator(
4453
"threshold must be in [0.0, 1.0], was $threshold"
4554
}
4655

47-
if (uncertaintyValue < threshold) {
56+
val fired = uncertaintyValue >= threshold
57+
val source = EventSource.Agent(agentId)
58+
val consideredTimestamp = clock.now()
59+
60+
publishEscalationConsidered(
61+
CognitiveEvent.EscalationConsidered(
62+
eventId = generateUUID(agentId),
63+
timestamp = consideredTimestamp,
64+
eventSource = source,
65+
agentId = agentId,
66+
uncertaintyValue = uncertaintyValue,
67+
threshold = threshold,
68+
fired = fired,
69+
cognitivePhase = cognitivePhase,
70+
),
71+
)
72+
73+
if (!fired) {
4874
return false
4975
}
5076

5177
publishEscalationFired(
5278
CognitiveEvent.EscalationFired(
5379
eventId = generateUUID(agentId),
5480
timestamp = clock.now(),
55-
eventSource = EventSource.Agent(agentId),
81+
eventSource = source,
5682
urgency = urgency,
5783
agentId = agentId,
5884
uncertaintyValue = uncertaintyValue,

0 commit comments

Comments
 (0)