feat(acr): ACR Store 1 — content-hash observation cache for repo-file descriptors (#305)
Adds ObservationStore (core:context) keyed on (repoRoot, path), an in-memory default and a durable SqliteObservationStore (infrastructure:persistence), and wires it into the two SessionOrchestratorArtifacts call sites that re-derive a SourceDescriptor from CAS bytes on every call — a hit on matching content hash skips both the CAS read and the regex extraction. First slice of docs/plans/2026-07-21-acr-knowledge-accretion.md (build order: observations before fixes/plan-shapes). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HMbPmZZjcXhR2crU82zZ8S
This commit is contained in:
@@ -390,6 +390,7 @@ fun main() {
|
||||
semanticReviewer = SemanticReviewerImpl(inferenceRouter),
|
||||
)
|
||||
val decisionJournalRepository = InfrastructureModule.createDecisionJournalRepository(eventStore)
|
||||
val observationStore = InfrastructureModule.createObservationStore()
|
||||
val defaultOrchestrationConfig = OrchestrationConfig(
|
||||
sandboxRoot = sandboxRoot,
|
||||
defaultSystemPromptPath = toolsConfig.defaultSystemPromptPath,
|
||||
@@ -496,6 +497,7 @@ fun main() {
|
||||
compactionService = journalCompactionService,
|
||||
artifactKindRegistry = artifactKindRegistry,
|
||||
repoKnowledgeRetriever = repoKnowledgeRetriever,
|
||||
observationStore = observationStore,
|
||||
readyTaskCounter = com.correx.apps.server.tasks.ProjectReadyTaskCounter(taskService),
|
||||
taskClaimCoordinator = com.correx.apps.server.tasks.DefaultTaskClaimCoordinator(
|
||||
taskService,
|
||||
|
||||
@@ -8,6 +8,8 @@ dependencies {
|
||||
implementation(project(":core:events"))
|
||||
implementation(project(":core:artifacts"))
|
||||
implementation(project(":core:sessions"))
|
||||
testImplementation "org.jetbrains.kotlin:kotlin-test"
|
||||
testImplementation "org.junit.jupiter:junit-jupiter"
|
||||
}
|
||||
|
||||
tasks.named("koverVerify").configure { enabled = false }
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.correx.core.context.observation
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* ACR Store 1 (docs/plans/2026-07-21-acr-knowledge-accretion.md): a fact about a repo file,
|
||||
* keyed on (repoRoot, path) — task-agnostic, any future run benefits. [contentHash] is the
|
||||
* staleness check: a caller re-validates by hash before trusting [descriptorRender], per
|
||||
* invariant #9 (never re-derive from a re-observed environment when the prior observation still
|
||||
* holds). [descriptorRender] is [com.correx.core.sourcedesc.SourceDescriptor.render] output — the
|
||||
* only thing every current call site actually consumes, so that's what's stored, not the object.
|
||||
*/
|
||||
data class Observation(
|
||||
val repoRoot: String,
|
||||
val path: String,
|
||||
val contentHash: String,
|
||||
val descriptorRender: String?,
|
||||
val observedAtMs: Long,
|
||||
)
|
||||
|
||||
interface ObservationStore {
|
||||
suspend fun get(repoRoot: String, path: String): Observation?
|
||||
suspend fun put(observation: Observation)
|
||||
}
|
||||
|
||||
/** Default/test backend — process-lifetime only. Production wiring swaps in a durable store. */
|
||||
class InMemoryObservationStore : ObservationStore {
|
||||
private val entries = ConcurrentHashMap<Pair<String, String>, Observation>()
|
||||
|
||||
override suspend fun get(repoRoot: String, path: String): Observation? =
|
||||
entries[repoRoot to path]
|
||||
|
||||
override suspend fun put(observation: Observation) {
|
||||
entries[observation.repoRoot to observation.path] = observation
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.correx.core.context.observation
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class InMemoryObservationStoreTest {
|
||||
|
||||
@Test
|
||||
fun `put then get returns the stored observation`(): Unit = runBlocking {
|
||||
val store = InMemoryObservationStore()
|
||||
val obs = Observation("repoA", "src/Foo.kt", "hash1", "module=foo", 100L)
|
||||
|
||||
store.put(obs)
|
||||
|
||||
assertEquals(obs, store.get("repoA", "src/Foo.kt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same path in different repos does not collide`(): Unit = runBlocking {
|
||||
val store = InMemoryObservationStore()
|
||||
store.put(Observation("repoA", "src/Foo.kt", "hashA", "a", 1L))
|
||||
store.put(Observation("repoB", "src/Foo.kt", "hashB", "b", 2L))
|
||||
|
||||
assertEquals("hashA", store.get("repoA", "src/Foo.kt")?.contentHash)
|
||||
assertEquals("hashB", store.get("repoB", "src/Foo.kt")?.contentHash)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `put overwrites the prior observation for the same key`(): Unit = runBlocking {
|
||||
val store = InMemoryObservationStore()
|
||||
store.put(Observation("repoA", "src/Foo.kt", "hash1", "old", 1L))
|
||||
store.put(Observation("repoA", "src/Foo.kt", "hash2", "new", 2L))
|
||||
|
||||
assertEquals("hash2", store.get("repoA", "src/Foo.kt")?.contentHash)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown key returns null`(): Unit = runBlocking {
|
||||
assertNull(InMemoryObservationStore().get("repoA", "nope"))
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -7,6 +7,7 @@ import com.correx.core.inference.Tokenizer
|
||||
import com.correx.core.journal.DefaultDecisionJournalRepository
|
||||
import com.correx.core.artifacts.kind.ArtifactKindRegistry
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.context.observation.ObservationStore
|
||||
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||
import com.correx.core.events.events.ArtifactContentStoredEvent
|
||||
import com.correx.core.events.events.ClarificationAnswer
|
||||
@@ -111,7 +112,8 @@ class DefaultSessionOrchestrator(
|
||||
readyTaskCounter: ReadyTaskCounter? = null,
|
||||
taskClaimCoordinator: TaskClaimCoordinator? = null,
|
||||
tuning: OrchestrationTuning = OrchestrationTuning(),
|
||||
) : SessionOrchestrator(repositories, engines, artifactStore, decisionJournalRepository, artifactKindRegistry = artifactKindRegistry, repoKnowledgeRetriever = repoKnowledgeRetriever, readyTaskCounter = readyTaskCounter, taskClaimCoordinator = taskClaimCoordinator, tuning = tuning), ApprovalGateway {
|
||||
observationStore: ObservationStore? = null,
|
||||
) : SessionOrchestrator(repositories, engines, artifactStore, decisionJournalRepository, artifactKindRegistry = artifactKindRegistry, repoKnowledgeRetriever = repoKnowledgeRetriever, readyTaskCounter = readyTaskCounter, taskClaimCoordinator = taskClaimCoordinator, tuning = tuning, observationStore = observationStore), ApprovalGateway {
|
||||
override val tokenizer: Tokenizer? = tokenizer
|
||||
override val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean> =
|
||||
ConcurrentHashMap<SessionId, AtomicBoolean>()
|
||||
|
||||
+2
@@ -8,6 +8,7 @@ import com.correx.core.artifacts.kind.KindInference
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.context.builder.ContextPackBuilder
|
||||
import com.correx.core.context.model.ContextPack
|
||||
import com.correx.core.context.observation.ObservationStore
|
||||
import com.correx.core.events.events.ClarificationAnswer
|
||||
import com.correx.core.events.events.ClarificationAnsweredEvent
|
||||
import com.correx.core.events.events.ClarificationRequestedEvent
|
||||
@@ -202,6 +203,7 @@ abstract class SessionOrchestrator(
|
||||
internal val readyTaskCounter: ReadyTaskCounter? = null,
|
||||
internal val taskClaimCoordinator: TaskClaimCoordinator? = null,
|
||||
internal val tuning: OrchestrationTuning = OrchestrationTuning(),
|
||||
internal val observationStore: ObservationStore? = null,
|
||||
) {
|
||||
internal val log = LoggerFactory.getLogger(this::class.java)
|
||||
internal val eventStore: EventStore = repositories.eventStore
|
||||
|
||||
+4
-5
@@ -207,6 +207,7 @@ internal suspend fun SessionOrchestrator.fileWrittenManifest(sessionId: SessionI
|
||||
.groupBy { it.path }
|
||||
.mapValues { (_, writes) -> writes.last() }
|
||||
if (latestWrites.isEmpty()) return null
|
||||
val repoRoot = workspacePolicy?.workspaceRoot?.toString().orEmpty()
|
||||
return buildString {
|
||||
appendLine(
|
||||
"Files written by the producing stage. Each line gives the authoritative CAS image and a " +
|
||||
@@ -215,9 +216,7 @@ internal suspend fun SessionOrchestrator.fileWrittenManifest(sessionId: SessionI
|
||||
)
|
||||
latestWrites.toSortedMap().forEach { (path, write) ->
|
||||
val hash = write.postImageHash ?: return@forEach
|
||||
val descriptor = artifactStore.get(ArtifactId(hash))
|
||||
?.let { describe(path, it).render() }
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val descriptor = describeCached(repoRoot, path, hash)
|
||||
val stage = invToStage[write.invocationId]?.value
|
||||
append("- $path")
|
||||
stage?.let { append(" [by $it]") }
|
||||
@@ -250,13 +249,13 @@ internal suspend fun SessionOrchestrator.sessionWrittenHits(
|
||||
.groupBy { it.path }
|
||||
.mapValues { (_, writes) -> writes.last() }
|
||||
if (latestWrites.isEmpty()) return emptyList()
|
||||
val repoRoot = workspacePolicy?.workspaceRoot?.toString().orEmpty()
|
||||
return latestWrites.values
|
||||
.sortedByDescending { it.timestampMs }
|
||||
.take(tuning.repoMapInjectTopK)
|
||||
.mapNotNull { write ->
|
||||
val hash = write.postImageHash ?: return@mapNotNull null
|
||||
val descriptor = artifactStore.get(ArtifactId(hash))?.let { describe(write.path, it).render() }
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val descriptor = describeCached(repoRoot, write.path, hash)
|
||||
val text = if (descriptor != null) "${write.path}: $descriptor" else write.path
|
||||
RepoKnowledgeHit(path = write.path, text = text, score = 1.0f)
|
||||
}
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.context.observation.Observation
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.sourcedesc.describe
|
||||
|
||||
/**
|
||||
* [describe]-and-render a written file's CAS bytes, short-circuited by ACR Store 1
|
||||
* (docs/plans/2026-07-21-acr-knowledge-accretion.md) when a prior observation for this
|
||||
* (repoRoot, path) already carries this exact content hash — skips both the CAS read and the
|
||||
* regex extraction. Falls back to a fresh [describe] + records the result for next time. No-op
|
||||
* (always fresh, never recorded) when no [SessionOrchestrator.observationStore] is wired.
|
||||
*/
|
||||
internal suspend fun SessionOrchestrator.describeCached(
|
||||
repoRoot: String,
|
||||
path: String,
|
||||
hash: String,
|
||||
): String? {
|
||||
val store = observationStore
|
||||
val cached = store?.get(repoRoot, path)
|
||||
if (cached != null && cached.contentHash == hash) return cached.descriptorRender
|
||||
val rendered = artifactStore.get(ArtifactId(hash))
|
||||
?.let { describe(path, it).render() }
|
||||
?.takeIf { it.isNotBlank() }
|
||||
store?.put(
|
||||
Observation(
|
||||
repoRoot = repoRoot,
|
||||
path = path,
|
||||
contentHash = hash,
|
||||
descriptorRender = rendered,
|
||||
observedAtMs = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
return rendered
|
||||
}
|
||||
@@ -12,6 +12,7 @@ dependencies {
|
||||
implementation project(":core:approvals")
|
||||
implementation project(":core:sessions")
|
||||
implementation project(":core:config")
|
||||
implementation project(":core:context")
|
||||
implementation project(":infrastructure:inference")
|
||||
implementation project(":infrastructure:inference:commons")
|
||||
implementation project(":infrastructure:inference:llama_cpp")
|
||||
|
||||
@@ -10,11 +10,13 @@ dependencies {
|
||||
implementation(project(":core:sessions"))
|
||||
implementation(project(":core:artifacts"))
|
||||
implementation(project(":core:artifacts-store"))
|
||||
implementation(project(":core:context"))
|
||||
implementation "org.xerial:sqlite-jdbc"
|
||||
implementation "org.slf4j:slf4j-api:2.0.16"
|
||||
testImplementation(testFixtures(project(":testing:contracts")))
|
||||
testImplementation(project(":testing:fixtures"))
|
||||
testImplementation "org.junit.jupiter:junit-jupiter"
|
||||
testImplementation "org.jetbrains.kotlin:kotlin-test"
|
||||
}
|
||||
|
||||
tasks.named("koverVerify").configure { enabled = false }
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.correx.infrastructure.persistence.observation
|
||||
|
||||
import com.correx.core.context.observation.Observation
|
||||
import com.correx.core.context.observation.ObservationStore
|
||||
import com.correx.infrastructure.persistence.util.JDBCHelper.transaction
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.sql.Connection
|
||||
|
||||
/**
|
||||
* Durable backend for ACR Store 1 (docs/plans/2026-07-21-acr-knowledge-accretion.md), so
|
||||
* observations survive a server restart the way the event log itself does. One row per
|
||||
* (repo_root, path); a `put` overwrites the prior observation for that key outright — this store
|
||||
* only ever holds the latest fact, staleness is resolved by the caller comparing content_hash.
|
||||
*/
|
||||
class SqliteObservationStore(private val connection: Connection) : ObservationStore {
|
||||
|
||||
init {
|
||||
connection.createStatement().use { stmt ->
|
||||
stmt.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS observations (
|
||||
repo_root TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
descriptor_render TEXT,
|
||||
observed_at_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (repo_root, path)
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun get(repoRoot: String, path: String): Observation? = withContext(Dispatchers.IO) {
|
||||
connection.prepareStatement(
|
||||
"SELECT content_hash, descriptor_render, observed_at_ms FROM observations " +
|
||||
"WHERE repo_root = ? AND path = ?",
|
||||
).use { stmt ->
|
||||
stmt.setString(1, repoRoot)
|
||||
stmt.setString(2, path)
|
||||
stmt.executeQuery().use { rs ->
|
||||
if (!rs.next()) return@withContext null
|
||||
Observation(
|
||||
repoRoot = repoRoot,
|
||||
path = path,
|
||||
contentHash = rs.getString("content_hash"),
|
||||
descriptorRender = rs.getString("descriptor_render"),
|
||||
observedAtMs = rs.getLong("observed_at_ms"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber") // JDBC positional parameter indices, not data
|
||||
override suspend fun put(observation: Observation) = withContext(Dispatchers.IO) {
|
||||
connection.transaction {
|
||||
connection.prepareStatement(
|
||||
"INSERT INTO observations (repo_root, path, content_hash, descriptor_render, observed_at_ms) " +
|
||||
"VALUES (?, ?, ?, ?, ?) " +
|
||||
"ON CONFLICT(repo_root, path) DO UPDATE SET " +
|
||||
"content_hash = excluded.content_hash, " +
|
||||
"descriptor_render = excluded.descriptor_render, " +
|
||||
"observed_at_ms = excluded.observed_at_ms",
|
||||
).use { stmt ->
|
||||
stmt.setString(1, observation.repoRoot)
|
||||
stmt.setString(2, observation.path)
|
||||
stmt.setString(3, observation.contentHash)
|
||||
stmt.setString(4, observation.descriptorRender)
|
||||
stmt.setLong(5, observation.observedAtMs)
|
||||
stmt.executeUpdate()
|
||||
}
|
||||
}
|
||||
Unit
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.correx.infrastructure.persistence.observation
|
||||
|
||||
import com.correx.core.context.observation.Observation
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.sql.DriverManager
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class SqliteObservationStoreTest {
|
||||
|
||||
private fun store() = SqliteObservationStore(DriverManager.getConnection("jdbc:sqlite::memory:"))
|
||||
|
||||
@Test
|
||||
fun `put then get round-trips through sqlite`(): Unit = runBlocking {
|
||||
val store = store()
|
||||
store.put(Observation("repoA", "src/Foo.kt", "hash1", "module=foo", 100L))
|
||||
|
||||
val fetched = store.get("repoA", "src/Foo.kt")
|
||||
|
||||
assertEquals(Observation("repoA", "src/Foo.kt", "hash1", "module=foo", 100L), fetched)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `put overwrites the prior row for the same key`(): Unit = runBlocking {
|
||||
val store = store()
|
||||
store.put(Observation("repoA", "src/Foo.kt", "hash1", "old", 1L))
|
||||
store.put(Observation("repoA", "src/Foo.kt", "hash2", "new", 2L))
|
||||
|
||||
assertEquals("hash2", store.get("repoA", "src/Foo.kt")?.contentHash)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown key returns null`(): Unit = runBlocking {
|
||||
assertNull(store().get("repoA", "nope"))
|
||||
}
|
||||
}
|
||||
@@ -53,8 +53,10 @@ import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import com.correx.infrastructure.router.turbovec.TurboVecL3MemoryStore
|
||||
import com.correx.infrastructure.router.turbovec.TurboVecSidecarConfig
|
||||
import com.correx.core.context.observation.ObservationStore
|
||||
import com.correx.infrastructure.persistence.SqliteEventStore
|
||||
import com.correx.infrastructure.persistence.artifact.LiveArtifactRepository
|
||||
import com.correx.infrastructure.persistence.observation.SqliteObservationStore
|
||||
import com.correx.infrastructure.tools.DefaultToolRegistry
|
||||
import com.correx.infrastructure.tools.DispatchingToolExecutor
|
||||
import com.correx.infrastructure.tools.SandboxedToolExecutor
|
||||
@@ -106,6 +108,12 @@ object InfrastructureModule {
|
||||
)
|
||||
}
|
||||
|
||||
fun createObservationStore(dbPath: String = defaultDbPath): ObservationStore {
|
||||
val dir = java.io.File(dbPath).parentFile
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
return SqliteObservationStore(DriverManager.getConnection("jdbc:sqlite:$dbPath"))
|
||||
}
|
||||
|
||||
fun createArtifactStore(rootDir: Path = Paths.get(defaultArtifactsRoot)): ArtifactStore {
|
||||
Files.createDirectories(rootDir)
|
||||
val index = SqliteArtifactIndex(rootDir.resolve("index.sqlite"))
|
||||
|
||||
Reference in New Issue
Block a user