Skip to content

Commit 7331c28

Browse files
wow-mileyclaude
andcommitted
AMPR-183 #530: add ampere-eval Trace primitive (capture, persist, replay)
Introduces the ampere-eval KMP module — the measurement substrate the eval set is built on: - Trace / TraceEvent value types (serializable, ordered) - TraceRecorder that captures an EventSerialBus run stream into a Trace - SQLDelight-backed TraceService (save / load / list) over its own EvalDatabase - TraceCursor for partial replay-to-index-N + branch-point signal Built on the AMPR-188 (#536) recon: corrects the assumed package root to link.socket.ampere.*, uses the real EventSerialBus (not EventSerializerBus), and keeps the trace schema in the eval module's own EvalDatabase so production code never depends on ampere-eval. 14 tests pass (incl. record -> persist -> load -> replay round-trip). I (Claude) wrote this commit on Miley's behalf. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 433a18b commit 7331c28

11 files changed

Lines changed: 828 additions & 0 deletions

File tree

ampere-eval/build.gradle.kts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import com.vanniktech.maven.publish.JavadocJar
2+
import com.vanniktech.maven.publish.KotlinMultiplatform
3+
import com.vanniktech.maven.publish.SonatypeHost
4+
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
5+
import org.jlleitschuh.gradle.ktlint.reporter.ReporterType
6+
7+
plugins {
8+
kotlin("multiplatform")
9+
kotlin("plugin.serialization")
10+
id("app.cash.sqldelight")
11+
id("com.vanniktech.maven.publish")
12+
id("org.jlleitschuh.gradle.ktlint")
13+
}
14+
15+
val ampereVersion: String by project
16+
17+
group = "link.socket"
18+
version = ampereVersion
19+
20+
mavenPublishing {
21+
publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL)
22+
signAllPublications()
23+
24+
configure(KotlinMultiplatform(javadocJar = JavadocJar.Empty()))
25+
26+
coordinates("link.socket", "ampere-eval", version.toString())
27+
28+
pom {
29+
name.set("Ampere Eval")
30+
description.set(
31+
"Measurement substrate for AMPERE: capturable, partially-replayable " +
32+
"Trace of an EventSerialBus run stream, persisted via SQLDelight.",
33+
)
34+
url.set("https://github.com/socket-link/ampere")
35+
inceptionYear.set("2026")
36+
37+
licenses {
38+
license {
39+
name.set("The Apache License, Version 2.0")
40+
url.set("https://www.apache.org/licenses/LICENSE-2.0.txt")
41+
distribution.set("repo")
42+
}
43+
}
44+
45+
developers {
46+
developer {
47+
id.set("socket-link")
48+
name.set("Socket Link")
49+
url.set("https://github.com/socket-link")
50+
}
51+
}
52+
53+
scm {
54+
connection.set("scm:git:git://github.com/socket-link/ampere.git")
55+
developerConnection.set("scm:git:ssh://git@github.com:socket-link/ampere.git")
56+
url.set("https://github.com/socket-link/ampere")
57+
}
58+
59+
issueManagement {
60+
system.set("GitHub Issues")
61+
url.set("https://github.com/socket-link/ampere/issues")
62+
}
63+
}
64+
}
65+
66+
kotlin {
67+
applyDefaultHierarchyTemplate()
68+
69+
jvm {
70+
compilerOptions {
71+
jvmTarget.set(JvmTarget.JVM_21)
72+
}
73+
}
74+
75+
iosX64()
76+
iosArm64()
77+
iosSimulatorArm64()
78+
79+
jvmToolchain(21)
80+
81+
sourceSets {
82+
val commonMain by getting {
83+
dependencies {
84+
api(project(":ampere-core"))
85+
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
86+
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
87+
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1")
88+
implementation("app.cash.sqldelight:runtime:2.2.1")
89+
}
90+
}
91+
val commonTest by getting {
92+
dependencies {
93+
implementation(kotlin("test"))
94+
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
95+
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1")
96+
}
97+
}
98+
val jvmTest by getting {
99+
dependencies {
100+
implementation(kotlin("test"))
101+
implementation("app.cash.sqldelight:sqlite-driver:2.2.1")
102+
}
103+
}
104+
}
105+
}
106+
107+
sqldelight {
108+
databases {
109+
create("EvalDatabase") {
110+
packageName.set("link.socket.ampere.eval.db")
111+
}
112+
}
113+
}
114+
115+
tasks.named<Test>("jvmTest") {
116+
useJUnitPlatform()
117+
}
118+
119+
ktlint {
120+
verbose.set(true)
121+
outputToConsole.set(true)
122+
debug.set(true)
123+
124+
version.set("0.49.1")
125+
126+
additionalEditorconfig.set(
127+
mapOf(
128+
"ktlint_code_style" to "intellij_idea",
129+
),
130+
)
131+
132+
filter {
133+
exclude { element -> element.file.path.contains("build/") }
134+
exclude { element -> element.file.path.contains("generated/") }
135+
}
136+
137+
reporters {
138+
reporter(ReporterType.PLAIN)
139+
reporter(ReporterType.CHECKSTYLE)
140+
}
141+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package link.socket.ampere.eval.trace
2+
3+
import kotlinx.serialization.Serializable
4+
import kotlinx.serialization.json.JsonElement
5+
6+
/**
7+
* A single captured bus event, frozen into an ordered, serializable form.
8+
*
9+
* @property index zero-based position of this event within its [Trace], in emission order.
10+
* @property timestamp epoch milliseconds at which the source event occurred.
11+
* @property type the bus event-type discriminator (the source `Event.eventType`),
12+
* used for human/queryable identification — NOT the kotlinx `"type"` payload
13+
* discriminator (which lives inside [payload]).
14+
* @property payload the full serialized source event as a [JsonElement]; decodable
15+
* back to the original `Event` via `Event.serializer()` with the shared `DEFAULT_JSON`.
16+
*/
17+
@Serializable
18+
data class TraceEvent(
19+
val index: Int,
20+
val timestamp: Long,
21+
val type: String,
22+
val payload: JsonElement,
23+
)
24+
25+
/**
26+
* An ordered, serializable capture of a single run's `EventSerialBus` stream.
27+
*
28+
* A `Trace` is the one measurement primitive the eval set is built on: evals,
29+
* regression gates, and the later reward function are all consumers of a captured,
30+
* replayable event stream. Replay is handled by [TraceCursor].
31+
*
32+
* @property id unique identifier for this trace.
33+
* @property runId the run this trace was recorded for (see RECON-trace.md §3).
34+
* @property arcId the orchestration pathway (Arc) this run belongs to.
35+
* @property createdAt epoch milliseconds when the trace was recorded.
36+
* @property events the captured events, in emission order.
37+
*/
38+
@Serializable
39+
data class Trace(
40+
val id: String,
41+
val runId: String,
42+
val arcId: String,
43+
val createdAt: Long,
44+
val events: List<TraceEvent>,
45+
) {
46+
/** Number of events in the trace. */
47+
val size: Int get() = events.size
48+
49+
/** Events `0..index` inclusive. Delegates to [List.take], so out-of-range is safe. */
50+
fun upTo(index: Int): List<TraceEvent> = events.take(index + 1)
51+
}
52+
53+
/**
54+
* Queryable metadata for a persisted [Trace] without its event blob.
55+
* Returned by `TraceService.list`.
56+
*/
57+
@Serializable
58+
data class TraceSummary(
59+
val id: String,
60+
val runId: String,
61+
val arcId: String,
62+
val createdAt: Long,
63+
val eventCount: Int,
64+
)
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package link.socket.ampere.eval.trace
2+
3+
import kotlinx.serialization.Serializable
4+
5+
/**
6+
* A signal marking where replay stops and a fresh ("branched") execution would
7+
* take over. The handoff begins at [branchIndex]; [replayed] is the exact prefix
8+
* of events to replay before that point.
9+
*
10+
* Eval is the degenerate case where the handoff never fires: `branchAfter(size - 1)`
11+
* replays the whole trace and yields a [branchIndex] one past the last event.
12+
*/
13+
@Serializable
14+
data class BranchPoint(
15+
val replayed: List<TraceEvent>,
16+
val branchIndex: Int,
17+
)
18+
19+
/**
20+
* Read-only cursor over a [Trace] for partial replay. Live re-execution and the
21+
* playback relay are out of scope here (ampere-eval ticket 2).
22+
*/
23+
class TraceCursor(private val trace: Trace) {
24+
25+
/**
26+
* Events `0..index` inclusive. The index is coerced into bounds, so this
27+
* never throws: values `>= size` return the whole trace, and values `< 0`
28+
* (or any index against an empty trace) return an empty list.
29+
*/
30+
fun replayTo(index: Int): List<TraceEvent> =
31+
trace.events.take(coerce(index) + 1)
32+
33+
/**
34+
* A [BranchPoint] whose handoff begins at `index + 1`: [BranchPoint.replayed]
35+
* is events `0..index`, and [BranchPoint.branchIndex] is the coerced
36+
* `index + 1`. `branchAfter(-1)` branches from the very start;
37+
* `branchAfter(size - 1)` replays everything.
38+
*/
39+
fun branchAfter(index: Int): BranchPoint {
40+
val coerced = coerce(index)
41+
return BranchPoint(
42+
replayed = trace.events.take(coerced + 1),
43+
branchIndex = coerced + 1,
44+
)
45+
}
46+
47+
/** Coerce an index into `-1..size-1` (so `+1` lands in `0..size`); `-1` means "before the first event". */
48+
private fun coerce(index: Int): Int = index.coerceIn(-1, (trace.size - 1).coerceAtLeast(-1))
49+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package link.socket.ampere.eval.trace
2+
3+
import kotlinx.coroutines.channels.Channel
4+
import kotlinx.datetime.Clock
5+
import kotlinx.serialization.json.Json
6+
import kotlinx.serialization.json.encodeToJsonElement
7+
import link.socket.ampere.agents.domain.event.Event
8+
import link.socket.ampere.agents.domain.event.EventRegistry
9+
import link.socket.ampere.agents.domain.event.EventType
10+
import link.socket.ampere.agents.events.api.EventHandler
11+
import link.socket.ampere.agents.events.bus.EventSerialBus
12+
import link.socket.ampere.agents.events.subscription.Subscription
13+
import link.socket.ampere.agents.events.utils.generateUUID
14+
import link.socket.ampere.data.DEFAULT_JSON
15+
16+
/**
17+
* Captures a run's `EventSerialBus` stream into a [Trace].
18+
*
19+
* A recorded trajectory *is* the captured bus stream — there is no parallel
20+
* recording channel. The recorder subscribes to every known event type
21+
* ([EventRegistry.allEventTypes], the same source of truth the live relay uses),
22+
* buffers events in emission order for the recording window, and on stop builds
23+
* and persists a [Trace] via [TraceService].
24+
*
25+
* Scoping note (see RECON-trace.md §3): the base `Event` does not carry a
26+
* `runId`, so events are scoped to a run by the *recording window* (start→stop),
27+
* and the supplied `runId` / `arcId` are stamped onto the resulting [Trace] as
28+
* metadata rather than used to filter individual events.
29+
*
30+
* @param bus the `EventSerialBus` to capture from (AMPR-183 calls this `EventSerializerBus`).
31+
* @param traceService persistence boundary the built [Trace] is saved through on stop.
32+
*/
33+
class TraceRecorder(
34+
private val bus: EventSerialBus,
35+
private val traceService: TraceService,
36+
private val json: Json = DEFAULT_JSON,
37+
private val eventTypes: List<EventType> = EventRegistry.allEventTypes,
38+
private val clockMillis: () -> Long = { Clock.System.now().toEpochMilliseconds() },
39+
private val idGenerator: () -> String = { generateUUID() },
40+
) {
41+
/**
42+
* Begin recording. Subscribes to the bus immediately; events published after
43+
* this call are captured until [RecordingHandle.stop].
44+
*/
45+
fun start(runId: String, arcId: String): RecordingHandle {
46+
val buffer = Channel<Event>(Channel.UNLIMITED)
47+
val agentId = "trace-recorder/$runId/${idGenerator()}"
48+
49+
eventTypes.forEach { eventType ->
50+
bus.subscribe(
51+
agentId = agentId,
52+
eventType = eventType,
53+
handler = EventHandler<Event, Subscription> { event, _ ->
54+
buffer.trySend(event)
55+
},
56+
)
57+
}
58+
59+
return RecordingHandle(
60+
traceId = idGenerator(),
61+
runId = runId,
62+
arcId = arcId,
63+
createdAt = clockMillis(),
64+
buffer = buffer,
65+
subscribedTypes = eventTypes,
66+
bus = bus,
67+
traceService = traceService,
68+
json = json,
69+
)
70+
}
71+
}
72+
73+
/**
74+
* Handle to an in-progress recording. Call [stop] exactly once to finalize.
75+
*/
76+
class RecordingHandle internal constructor(
77+
private val traceId: String,
78+
private val runId: String,
79+
private val arcId: String,
80+
private val createdAt: Long,
81+
private val buffer: Channel<Event>,
82+
private val subscribedTypes: List<EventType>,
83+
private val bus: EventSerialBus,
84+
private val traceService: TraceService,
85+
private val json: Json,
86+
) {
87+
/**
88+
* Stop recording, build the [Trace] from buffered events (in emission order),
89+
* persist it via the [TraceService], and return it.
90+
*
91+
* Returns failure if persistence fails. Idempotent only insofar as the
92+
* channel is drained once; call exactly once.
93+
*/
94+
suspend fun stop(): Result<Trace> {
95+
// Stop receiving new events. NOTE: EventSerialBus.unsubscribe removes ALL
96+
// handlers for a type (see RECON-trace.md §1) — fine for eval-controlled
97+
// runs where the recorder is the sole subscriber.
98+
subscribedTypes.forEach { bus.unsubscribe(it) }
99+
buffer.close()
100+
101+
// Dedup by eventId: an event with parentEventTypes is dispatched to more
102+
// than one subscribed type (see RECON-trace.md §1/§2), so the recorder may
103+
// see it once per matching type. Keep the first arrival, preserving order.
104+
val seenEventIds = mutableSetOf<String>()
105+
val captured = buildList {
106+
while (true) {
107+
val event = buffer.tryReceive().getOrNull() ?: break
108+
if (seenEventIds.add(event.eventId)) add(event)
109+
}
110+
}
111+
112+
val events = captured.mapIndexed { index, event ->
113+
TraceEvent(
114+
index = index,
115+
timestamp = event.timestamp.toEpochMilliseconds(),
116+
type = event.eventType,
117+
payload = json.encodeToJsonElement(Event.serializer(), event),
118+
)
119+
}
120+
121+
val trace = Trace(
122+
id = traceId,
123+
runId = runId,
124+
arcId = arcId,
125+
createdAt = createdAt,
126+
events = events,
127+
)
128+
129+
return traceService.save(trace).map { trace }
130+
}
131+
}

0 commit comments

Comments
 (0)