diff --git a/dice-report/src/main/kotlin/com/embabel/dice/report/OntologicalSemanticLinkDiscoverer.kt b/dice-report/src/main/kotlin/com/embabel/dice/report/OntologicalSemanticLinkDiscoverer.kt new file mode 100644 index 00000000..d3fbe007 --- /dev/null +++ b/dice-report/src/main/kotlin/com/embabel/dice/report/OntologicalSemanticLinkDiscoverer.kt @@ -0,0 +1,167 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.dice.report + +import com.embabel.dice.common.DiceMetadataKeys +import com.embabel.dice.proposition.Proposition +import com.embabel.dice.proposition.PropositionStatus +import com.embabel.dice.taxonomy.Taxonomy +import org.jetbrains.annotations.ApiStatus +import org.slf4j.LoggerFactory + +/** + * Discovers adjacency-invisible entity links grounded in a shared taxonomy ancestor. + * + * Category assignments come only from ACTIVE propositions. A candidate is suppressed + * when its endpoints are directly co-mentioned or share an entity neighbour. + */ +@ApiStatus.Experimental +class OntologicalSemanticLinkDiscoverer( + private val taxonomy: Taxonomy, + private val maxAncestorLevels: Int = 2, +) : SemanticLinkDiscoverer { + + private val logger = LoggerFactory.getLogger(OntologicalSemanticLinkDiscoverer::class.java) + + init { + require(maxAncestorLevels >= 0) { + "maxAncestorLevels must not be negative: $maxAncestorLevels" + } + } + + @ApiStatus.Experimental + override fun discover(propositions: List): List { + val active = propositions.filter { it.status == PropositionStatus.ACTIVE } + logger.debug( + "Ontological discovery: {} proposition(s) in ({} active)", + propositions.size, + active.size, + ) + + val categoryEvidence = categoryEvidence(active) + val neighbours = adjacency(active) + val entities = categoryEvidence.keys.sorted() + val links = mutableListOf() + + for (i in entities.indices) { + for (j in i + 1 until entities.size) { + val a = entities[i] + val b = entities[j] + val aNeighbours = neighbours[a].orEmpty() + val bNeighbours = neighbours[b].orEmpty() + if (b in aNeighbours) continue + if (aNeighbours.any { it in bNeighbours }) continue + + val connection = bestConnection( + categoryEvidence.getValue(a), + categoryEvidence.getValue(b), + ) ?: continue + links += SemanticLink( + sourceEntityId = a, + targetEntityId = b, + connectingEntityIds = emptyList(), + kind = LinkKind.INFERRED, + sourcePropositionIds = ( + categoryEvidence.getValue(a).getValue(connection.categoryA) + + categoryEvidence.getValue(b).getValue(connection.categoryB) + ).sorted(), + reviewStatus = ReviewStatus.CANDIDATE, + confidence = 1.0 / (1 + maxOf(connection.levelsA, connection.levelsB)), + rationale = "shared taxonomy ancestor '${connection.ancestorLabel}' " + + "($a via ${connection.categoryA}, $b via ${connection.categoryB})", + ) + } + } + + val result = links.sortedWith( + compareBy( + { it.sourceEntityId }, + { it.targetEntityId }, + ), + ) + logger.debug("Ontological discovery: {} indirect link(s) found", result.size) + return result + } + + private fun categoryEvidence( + active: List, + ): Map>> { + val evidence = linkedMapOf>>() + for (proposition in active) { + val categoryId = proposition.metadata[DiceMetadataKeys.TAXONOMY_NODE] as? String ?: continue + for (entityId in proposition.mentions.mapNotNull { it.resolvedId }.distinct()) { + evidence + .getOrPut(entityId) { linkedMapOf() } + .getOrPut(categoryId) { linkedSetOf() } + .add(proposition.id) + } + } + return evidence + } + + private fun adjacency( + propositions: List, + ): Map> { + val neighbours = linkedMapOf>() + for (proposition in propositions) { + val ids = proposition.mentions.mapNotNull { it.resolvedId }.distinct() + for (i in ids.indices) { + for (j in i + 1 until ids.size) { + neighbours.getOrPut(ids[i]) { linkedSetOf() }.add(ids[j]) + neighbours.getOrPut(ids[j]) { linkedSetOf() }.add(ids[i]) + } + } + } + return neighbours + } + + private fun bestConnection( + categoriesA: Map>, + categoriesB: Map>, + ): AncestryConnection? = categoriesA.keys + .flatMap { categoryA -> + categoriesB.keys.mapNotNull { categoryB -> + val ancestor = taxonomy.sharedAncestor(categoryA, categoryB, maxAncestorLevels) + ?: return@mapNotNull null + AncestryConnection( + categoryA = categoryA, + categoryB = categoryB, + ancestorId = ancestor.id, + ancestorLabel = ancestor.label, + levelsA = taxonomy.levelsTo(categoryA, ancestor.id), + levelsB = taxonomy.levelsTo(categoryB, ancestor.id), + ) + } + } + .minWithOrNull( + compareBy( + { maxOf(it.levelsA, it.levelsB) }, + { it.levelsA + it.levelsB }, + { it.ancestorId }, + { it.categoryA }, + { it.categoryB }, + ), + ) + + private data class AncestryConnection( + val categoryA: String, + val categoryB: String, + val ancestorId: String, + val ancestorLabel: String, + val levelsA: Int, + val levelsB: Int, + ) +} diff --git a/dice-report/src/main/kotlin/com/embabel/dice/report/SemanticLinkStore.kt b/dice-report/src/main/kotlin/com/embabel/dice/report/SemanticLinkStore.kt new file mode 100644 index 00000000..60d03656 --- /dev/null +++ b/dice-report/src/main/kotlin/com/embabel/dice/report/SemanticLinkStore.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.dice.report + +import com.embabel.agent.core.ContextId +import org.jetbrains.annotations.ApiStatus +import java.util.concurrent.ConcurrentHashMap + +/** + * Persistence port for reviewable [SemanticLink]s. + */ +@ApiStatus.Experimental +interface SemanticLinkStore { + + /** + * Return the natural key for [link], independent of endpoint order. + */ + @ApiStatus.Experimental + fun idOf(link: SemanticLink): String = listOf( + minOf(link.sourceEntityId, link.targetEntityId), + maxOf(link.sourceEntityId, link.targetEntityId), + link.kind.name, + ).joinToString("|") + + /** + * Upsert [link] in [contextId]. + * + * An existing accepted or rejected review decision is retained. + */ + @ApiStatus.Experimental + fun record(contextId: ContextId, link: SemanticLink): SemanticLink + + /** + * Find links in [contextId], optionally restricted to [status]. + */ + @ApiStatus.Experimental + fun find(contextId: ContextId, status: ReviewStatus? = null): List + + /** + * Update the review status of [id] in [contextId], returning `null` when absent. + */ + @ApiStatus.Experimental + fun updateReviewStatus(contextId: ContextId, id: String, status: ReviewStatus): SemanticLink? +} + +/** + * Thread-safe, in-process [SemanticLinkStore] for tests and single-node applications. + */ +@ApiStatus.Experimental +class InMemorySemanticLinkStore : SemanticLinkStore { + + private val linksByContext = ConcurrentHashMap>() + + @ApiStatus.Experimental + override fun record(contextId: ContextId, link: SemanticLink): SemanticLink { + val stored = linksByContext + .computeIfAbsent(contextId) { ConcurrentHashMap() } + .compute(idOf(link)) { _, existing -> + val refreshed = link.detached() + when (existing?.reviewStatus) { + ReviewStatus.ACCEPTED, + ReviewStatus.REJECTED, + -> refreshed.copy(reviewStatus = existing.reviewStatus) + + else -> refreshed + } + }!! + return stored.detached() + } + + @ApiStatus.Experimental + override fun find(contextId: ContextId, status: ReviewStatus?): List = + linksByContext[contextId] + ?.values + ?.asSequence() + ?.filter { status == null || it.reviewStatus == status } + ?.sortedBy(::idOf) + ?.map { it.detached() } + ?.toList() + ?: emptyList() + + @ApiStatus.Experimental + override fun updateReviewStatus( + contextId: ContextId, + id: String, + status: ReviewStatus, + ): SemanticLink? = linksByContext[contextId] + ?.computeIfPresent(id) { _, link -> link.copy(reviewStatus = status) } + ?.detached() + + private fun SemanticLink.detached(): SemanticLink = copy( + connectingEntityIds = connectingEntityIds.toList(), + sourcePropositionIds = sourcePropositionIds.toList(), + ) +} diff --git a/dice-report/src/test/kotlin/com/embabel/dice/report/InMemorySemanticLinkStoreTest.kt b/dice-report/src/test/kotlin/com/embabel/dice/report/InMemorySemanticLinkStoreTest.kt new file mode 100644 index 00000000..fbcd6d7c --- /dev/null +++ b/dice-report/src/test/kotlin/com/embabel/dice/report/InMemorySemanticLinkStoreTest.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.dice.report + +import com.embabel.agent.core.ContextId +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +internal class InMemorySemanticLinkStoreTest { + + private val contextA = ContextId("context-a") + private val contextB = ContextId("context-b") + + @Test + fun `record stores a candidate and refreshes evidence without resurrecting a rejection`() { + val store = InMemorySemanticLinkStore() + val candidate = link(confidence = 0.4, rationale = "initial") + + assertEquals(ReviewStatus.CANDIDATE, store.record(contextA, candidate).reviewStatus) + + val id = store.idOf(candidate) + store.updateReviewStatus(contextA, id, ReviewStatus.REJECTED) + val refreshed = store.record( + contextA, + link(confidence = 0.9, rationale = "refreshed"), + ) + + assertEquals(ReviewStatus.REJECTED, refreshed.reviewStatus) + assertEquals(0.9, refreshed.confidence) + assertEquals("refreshed", refreshed.rationale) + assertEquals(listOf(refreshed), store.find(contextA)) + } + + @Test + fun `record preserves an accepted review decision`() { + val store = InMemorySemanticLinkStore() + val candidate = link() + val id = store.idOf(candidate) + store.record(contextA, candidate) + store.updateReviewStatus(contextA, id, ReviewStatus.ACCEPTED) + + val refreshed = store.record(contextA, link(confidence = 0.8)) + + assertEquals(ReviewStatus.ACCEPTED, refreshed.reviewStatus) + assertEquals(0.8, refreshed.confidence) + } + + @Test + fun `find filters by review status and isolates contexts`() { + val store = InMemorySemanticLinkStore() + val linkA = link() + val linkB = link(source = "entity-c", target = "entity-d") + store.record(contextA, linkA) + store.record(contextA, linkB) + store.record(contextB, linkA.copy(reviewStatus = ReviewStatus.ACCEPTED)) + store.updateReviewStatus(contextA, store.idOf(linkB), ReviewStatus.REJECTED) + + assertEquals(listOf(linkA), store.find(contextA, ReviewStatus.CANDIDATE)) + assertEquals( + listOf(ReviewStatus.CANDIDATE, ReviewStatus.REJECTED), + store.find(contextA).map(SemanticLink::reviewStatus), + ) + assertEquals(listOf(ReviewStatus.ACCEPTED), store.find(contextB).map(SemanticLink::reviewStatus)) + } + + @Test + fun `update returns null for unknown ids and cannot cross context boundaries`() { + val store = InMemorySemanticLinkStore() + val candidate = link() + val id = store.idOf(candidate) + store.record(contextA, candidate) + + assertNull(store.updateReviewStatus(contextA, "missing", ReviewStatus.REJECTED)) + assertNull(store.updateReviewStatus(contextB, id, ReviewStatus.REJECTED)) + assertEquals(ReviewStatus.CANDIDATE, store.find(contextA).single().reviewStatus) + } + + @Test + fun `reversed endpoints share one natural key and one stored row`() { + val store = InMemorySemanticLinkStore() + val forward = link(source = "entity-a", target = "entity-b", rationale = "forward") + val reversed = link(source = "entity-b", target = "entity-a", rationale = "reversed") + + assertEquals(store.idOf(forward), store.idOf(reversed)) + store.record(contextA, forward) + store.record(contextA, reversed) + + assertEquals(1, store.find(contextA).size) + assertEquals("reversed", store.find(contextA).single().rationale) + } + + private fun link( + source: String = "entity-a", + target: String = "entity-b", + confidence: Double = 0.5, + rationale: String? = null, + ): SemanticLink = SemanticLink( + sourceEntityId = source, + targetEntityId = target, + connectingEntityIds = listOf("bridge"), + sourcePropositionIds = listOf("proposition-1"), + confidence = confidence, + rationale = rationale, + ) +} diff --git a/dice-report/src/test/kotlin/com/embabel/dice/report/OntologicalSemanticLinkDiscovererTest.kt b/dice-report/src/test/kotlin/com/embabel/dice/report/OntologicalSemanticLinkDiscovererTest.kt new file mode 100644 index 00000000..19c70bc9 --- /dev/null +++ b/dice-report/src/test/kotlin/com/embabel/dice/report/OntologicalSemanticLinkDiscovererTest.kt @@ -0,0 +1,161 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.dice.report + +import com.embabel.agent.core.ContextId +import com.embabel.dice.common.DiceMetadataKeys +import com.embabel.dice.proposition.EntityMention +import com.embabel.dice.proposition.MentionRole +import com.embabel.dice.proposition.Proposition +import com.embabel.dice.proposition.PropositionStatus +import com.embabel.dice.taxonomy.Taxonomy +import com.embabel.dice.taxonomy.TaxonomyNode +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class OntologicalSemanticLinkDiscovererTest { + + private val contextId = ContextId("test") + private val taxonomy = Taxonomy( + listOf( + TaxonomyNode(id = "root", label = "Root"), + TaxonomyNode(id = "vendors", label = "Vendors", parentId = "root"), + TaxonomyNode(id = "robotics", label = "Robotics", parentId = "vendors"), + TaxonomyNode(id = "biotech", label = "Biotech", parentId = "vendors"), + ), + ) + + @Test + fun `emits a candidate for an adjacency-invisible shared ancestor`() { + val roboticsEvidence = proposition("robotics-evidence", "Zeta", categoryId = "robotics") + val biotechEvidence = proposition("biotech-evidence", "Alpha", categoryId = "biotech") + + val link = OntologicalSemanticLinkDiscoverer(taxonomy) + .discover(listOf(roboticsEvidence, biotechEvidence)) + .single() + + assertEquals("Alpha", link.sourceEntityId) + assertEquals("Zeta", link.targetEntityId) + assertEquals(LinkKind.INFERRED, link.kind) + assertEquals(ReviewStatus.CANDIDATE, link.reviewStatus) + assertEquals(emptyList(), link.connectingEntityIds) + assertEquals(listOf("biotech-evidence", "robotics-evidence"), link.sourcePropositionIds) + assertEquals(0.5, link.confidence) + assertEquals( + "shared taxonomy ancestor 'Vendors' (Alpha via biotech, Zeta via robotics)", + link.rationale, + ) + } + + @Test + fun `does not emit a directly co-mentioned pair`() { + val roboticsEvidence = proposition("robotics-evidence", "A", categoryId = "robotics") + val biotechEvidence = proposition("biotech-evidence", "B", categoryId = "biotech") + val direct = proposition("direct", "A", "B") + + val links = OntologicalSemanticLinkDiscoverer(taxonomy) + .discover(listOf(roboticsEvidence, biotechEvidence, direct)) + + assertTrue(links.isEmpty()) + } + + @Test + fun `does not emit a pair sharing an entity neighbour`() { + val roboticsEvidence = proposition("robotics-evidence", "A", categoryId = "robotics") + val biotechEvidence = proposition("biotech-evidence", "B", categoryId = "biotech") + val aToX = proposition("a-to-x", "A", "X") + val xToB = proposition("x-to-b", "X", "B") + + val links = OntologicalSemanticLinkDiscoverer(taxonomy) + .discover(listOf(roboticsEvidence, biotechEvidence, aToX, xToB)) + + assertTrue(links.isEmpty()) + } + + @Test + fun `ignores stale category evidence`() { + val roboticsEvidence = proposition("robotics-evidence", "A", categoryId = "robotics") + val staleBiotechEvidence = proposition( + "biotech-evidence", + "B", + categoryId = "biotech", + status = PropositionStatus.STALE, + ) + + val links = OntologicalSemanticLinkDiscoverer(taxonomy) + .discover(listOf(roboticsEvidence, staleBiotechEvidence)) + + assertTrue(links.isEmpty()) + } + + @Test + fun `stale adjacency does not suppress a candidate grounded in active evidence`() { + val roboticsEvidence = proposition("robotics-evidence", "A", categoryId = "robotics") + val biotechEvidence = proposition("biotech-evidence", "B", categoryId = "biotech") + val staleDirect = proposition( + "stale-direct", + "A", + "B", + status = PropositionStatus.STALE, + ) + + val links = OntologicalSemanticLinkDiscoverer(taxonomy) + .discover(listOf(roboticsEvidence, biotechEvidence, staleDirect)) + + assertEquals(1, links.size) + assertEquals(listOf("biotech-evidence", "robotics-evidence"), links.single().sourcePropositionIds) + } + + @Test + fun `does not emit when the shared ancestor is beyond the configured bound`() { + val roboticsEvidence = proposition("robotics-evidence", "A", categoryId = "robotics") + val biotechEvidence = proposition("biotech-evidence", "B", categoryId = "biotech") + + val links = OntologicalSemanticLinkDiscoverer(taxonomy, maxAncestorLevels = 0) + .discover(listOf(roboticsEvidence, biotechEvidence)) + + assertTrue(links.isEmpty()) + } + + private fun proposition( + id: String, + vararg entityIds: String, + categoryId: String? = null, + status: PropositionStatus = PropositionStatus.ACTIVE, + ): Proposition { + val proposition = Proposition( + id = id, + contextId = contextId, + text = entityIds.joinToString(" relates to "), + mentions = entityIds.map { + EntityMention( + span = it, + type = "Entity", + resolvedId = it, + role = MentionRole.SUBJECT, + ) + }, + confidence = 0.9, + status = status, + ) + return if (categoryId == null) { + proposition + } else { + proposition.withMetadataValue(DiceMetadataKeys.TAXONOMY_NODE, categoryId) + } + } +} diff --git a/dice-storage/pom.xml b/dice-storage/pom.xml index 46bc43e0..80e4ff47 100644 --- a/dice-storage/pom.xml +++ b/dice-storage/pom.xml @@ -30,6 +30,10 @@ com.embabel.dice dice + + com.embabel.dice + dice-report + @@ -222,4 +226,4 @@ - \ No newline at end of file + diff --git a/dice-storage/src/main/kotlin/com/embabel/dice/storage/DrivineSemanticLinkStore.kt b/dice-storage/src/main/kotlin/com/embabel/dice/storage/DrivineSemanticLinkStore.kt new file mode 100644 index 00000000..49d5c822 --- /dev/null +++ b/dice-storage/src/main/kotlin/com/embabel/dice/storage/DrivineSemanticLinkStore.kt @@ -0,0 +1,203 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.dice.storage + +import com.embabel.agent.core.ContextId +import com.embabel.dice.report.LinkKind +import com.embabel.dice.report.ReviewStatus +import com.embabel.dice.report.SemanticLink +import com.embabel.dice.report.SemanticLinkStore +import org.drivine.manager.PersistenceManager +import org.drivine.query.QuerySpecification +import org.jetbrains.annotations.ApiStatus +import org.slf4j.LoggerFactory +import org.springframework.transaction.annotation.Transactional +import java.time.Instant + +/** + * Drivine / Neo4j [SemanticLinkStore]: persists reviewable semantic links as flat + * `(:SemanticLink)` nodes so review decisions survive process restarts. + * + * A natural link id is unique within its context; both values form the persistence key. + * Every value, including free-text rationale, travels as a query parameter. Rediscovery + * refreshes the evidence properties while leaving an existing review status untouched. + * Reads are corrupt-row-tolerant: an unreadable node is logged and skipped. + */ +@ApiStatus.Experimental +@Transactional +class DrivineSemanticLinkStore( + private val persistenceManager: PersistenceManager, +) : SemanticLinkStore { + + private val logger = LoggerFactory.getLogger(DrivineSemanticLinkStore::class.java) + + @ApiStatus.Experimental + @Transactional + override fun record(contextId: ContextId, link: SemanticLink): SemanticLink { + val id = idOf(link) + val now = Instant.now().toString() + logger.debug("Recording semantic link {} for context {}", id, contextId.value) + + persistenceManager.execute( + QuerySpecification.withStatement( + """ + MERGE (l:SemanticLink {id: ${'$'}id, contextId: ${'$'}contextId}) + ON CREATE SET l += ${'$'}props, + l.reviewStatus = ${'$'}status, + l.createdAt = ${'$'}now + ON MATCH SET l += ${'$'}props, + l.revisedAt = ${'$'}now + """.trimIndent(), + ).bind( + mapOf( + "id" to id, + "contextId" to contextId.value, + "props" to bindProperties(link), + "status" to link.reviewStatus.name, + "now" to now, + ), + ), + ) + + return checkNotNull(findById(contextId, id)) { + "SemanticLink $id was not readable after it was recorded" + } + } + + @ApiStatus.Experimental + @Transactional(readOnly = true) + override fun find(contextId: ContextId, status: ReviewStatus?): List { + val statement = if (status == null) { + """ + MATCH (l:SemanticLink {contextId: ${'$'}contextId}) + RETURN l + ORDER BY l.id + """.trimIndent() + } else { + """ + MATCH (l:SemanticLink {contextId: ${'$'}contextId}) + WHERE l.reviewStatus = ${'$'}status + RETURN l + ORDER BY l.id + """.trimIndent() + } + val parameters = buildMap { + put("contextId", contextId.value) + status?.let { put("status", it.name) } + } + return readableLinks(statement, parameters) + } + + @ApiStatus.Experimental + @Transactional + override fun updateReviewStatus( + contextId: ContextId, + id: String, + status: ReviewStatus, + ): SemanticLink? { + logger.debug( + "Updating semantic link {} to review status {} for context {}", + id, + status, + contextId.value, + ) + val rows = queryRows( + """ + MATCH (l:SemanticLink {id: ${'$'}id, contextId: ${'$'}contextId}) + SET l.reviewStatus = ${'$'}status, + l.revisedAt = ${'$'}now + RETURN l + """.trimIndent(), + mapOf( + "id" to id, + "contextId" to contextId.value, + "status" to status.name, + "now" to Instant.now().toString(), + ), + ) + return rows.firstNotNullOfOrNull(::readableLink) + } + + private fun bindProperties(link: SemanticLink): Map = + mapOf( + "sourceEntityId" to link.sourceEntityId, + "targetEntityId" to link.targetEntityId, + "kind" to link.kind.name, + "confidence" to link.confidence, + "rationale" to link.rationale, + "connectingEntityIds" to link.connectingEntityIds, + "sourcePropositionIds" to link.sourcePropositionIds, + ) + + private fun findById(contextId: ContextId, id: String): SemanticLink? = + readableLinks( + """ + MATCH (l:SemanticLink {id: ${'$'}id, contextId: ${'$'}contextId}) + RETURN l + """.trimIndent(), + mapOf("id" to id, "contextId" to contextId.value), + ).firstOrNull() + + private fun readableLinks(statement: String, parameters: Map): List = + queryRows(statement, parameters).mapNotNull(::readableLink) + + private fun readableLink(row: Map<*, *>): SemanticLink? = + runCatching { + SemanticLink( + sourceEntityId = row.requiredString("sourceEntityId"), + targetEntityId = row.requiredString("targetEntityId"), + connectingEntityIds = row.requiredStringList("connectingEntityIds"), + kind = LinkKind.valueOf(row.requiredString("kind")), + sourcePropositionIds = row.requiredStringList("sourcePropositionIds"), + reviewStatus = ReviewStatus.valueOf(row.requiredString("reviewStatus")), + confidence = row.requiredNumber("confidence").toDouble(), + rationale = row.optionalString("rationale"), + ) + }.onFailure { + logger.warn("Skipping unreadable SemanticLink row: {}", it.message) + }.getOrNull() + + private fun Map<*, *>.requiredString(property: String): String = + (this[property] as? String) + ?.takeIf { it.isNotBlank() } + ?: error("SemanticLink row missing or invalid $property") + + private fun Map<*, *>.requiredStringList(property: String): List { + val values = this[property] as? List<*> + ?: error("SemanticLink row missing or invalid $property") + return values.map { value -> + value as? String ?: error("SemanticLink row has non-string value in $property") + } + } + + private fun Map<*, *>.requiredNumber(property: String): Number = + this[property] as? Number + ?: error("SemanticLink row missing or invalid $property") + + private fun Map<*, *>.optionalString(property: String): String? = + when (val value = this[property]) { + null -> null + is String -> value + else -> error("SemanticLink row has invalid $property") + } + + private fun queryRows(statement: String, parameters: Map): List> { + val specification = QuerySpecification.withStatement(statement).let { + if (parameters.isEmpty()) it else it.bind(parameters) + } + return persistenceManager.query(specification).filterIsInstance>() + } +} diff --git a/dice-storage/src/test/kotlin/com/embabel/dice/storage/DrivineSemanticLinkStoreIntegrationTest.kt b/dice-storage/src/test/kotlin/com/embabel/dice/storage/DrivineSemanticLinkStoreIntegrationTest.kt new file mode 100644 index 00000000..00023f8a --- /dev/null +++ b/dice-storage/src/test/kotlin/com/embabel/dice/storage/DrivineSemanticLinkStoreIntegrationTest.kt @@ -0,0 +1,259 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.dice.storage + +import com.embabel.agent.core.ContextId +import com.embabel.dice.report.LinkKind +import com.embabel.dice.report.ReviewStatus +import com.embabel.dice.report.SemanticLink +import org.drivine.manager.PersistenceManager +import org.drivine.query.QuerySpecification +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean + +/** + * Integration tests for [DrivineSemanticLinkStore] against the module's real Neo4j + * testcontainer harness. + */ +@SpringBootTest(classes = [TestApplication::class, SemanticLinkStoreTestConfiguration::class]) +internal class DrivineSemanticLinkStoreIntegrationTest { + + @Autowired + private lateinit var linkStore: DrivineSemanticLinkStore + + @Autowired + private lateinit var persistenceManager: PersistenceManager + + @BeforeEach + fun createSchema() { + persistenceManager.execute( + QuerySpecification.withStatement( + """ + CREATE CONSTRAINT semantic_link_context_id_unique IF NOT EXISTS + FOR (l:SemanticLink) REQUIRE (l.contextId, l.id) IS UNIQUE + """.trimIndent(), + ), + ) + persistenceManager.execute( + QuerySpecification.withStatement( + """ + CREATE INDEX semantic_link_context_id IF NOT EXISTS + FOR (l:SemanticLink) ON (l.contextId) + """.trimIndent(), + ), + ) + } + + @AfterEach + fun cleanUp() { + persistenceManager.execute( + QuerySpecification.withStatement("MATCH (l:SemanticLink) DETACH DELETE l"), + ) + } + + @Test + fun `record and find round-trip every scalar and string-list property`() { + val contextId = ContextId("ctx-round-trip") + val link = semanticLink( + sourceEntityId = "entity-a", + targetEntityId = "entity-b", + connectingEntityIds = listOf("bridge-1", "bridge-2"), + sourcePropositionIds = listOf("prop-1", "prop-2"), + confidence = 0.82, + rationale = "A and B share two supporting paths", + ) + + assertEquals(link, linkStore.record(contextId, link)) + assertEquals(listOf(link), linkStore.find(contextId)) + assertEquals(listOf(link), linkStore.find(contextId, ReviewStatus.CANDIDATE)) + assertEquals(emptyList(), linkStore.find(contextId, ReviewStatus.ACCEPTED)) + } + + @Test + fun `rediscovery preserves rejected status while refreshing evidence`() { + val contextId = ContextId("ctx-sticky") + val original = semanticLink(rationale = "first rationale", confidence = 0.55) + val id = linkStore.idOf(original) + + linkStore.record(contextId, original) + val rejected = linkStore.updateReviewStatus(contextId, id, ReviewStatus.REJECTED) + assertEquals(ReviewStatus.REJECTED, rejected?.reviewStatus) + + val rediscovered = original.copy( + connectingEntityIds = listOf("new-bridge"), + sourcePropositionIds = listOf("new-proposition"), + confidence = 0.91, + rationale = "refreshed rationale", + ) + val stored = linkStore.record(contextId, rediscovered) + + assertEquals(ReviewStatus.REJECTED, stored.reviewStatus) + assertEquals(listOf("new-bridge"), stored.connectingEntityIds) + assertEquals(listOf("new-proposition"), stored.sourcePropositionIds) + assertEquals(0.91, stored.confidence) + assertEquals("refreshed rationale", stored.rationale) + } + + @Test + fun `reversed endpoints merge to the same natural id`() { + val contextId = ContextId("ctx-reversed") + val forward = semanticLink(sourceEntityId = "alpha", targetEntityId = "omega") + val reversed = semanticLink(sourceEntityId = "omega", targetEntityId = "alpha") + + assertEquals(linkStore.idOf(forward), linkStore.idOf(reversed)) + linkStore.record(contextId, forward) + linkStore.record(contextId, reversed) + + assertEquals(listOf(reversed), linkStore.find(contextId)) + } + + @Test + fun `injection-shaped rationale round-trips as data without affecting other nodes`() { + val contextId = ContextId("ctx-injection") + val injectionShapedRationale = "'} MATCH (n) DETACH DELETE n //" + val adversarial = semanticLink( + sourceEntityId = "adversarial-a", + targetEntityId = "adversarial-b", + rationale = injectionShapedRationale, + ) + val sentinel = semanticLink( + sourceEntityId = "sentinel-a", + targetEntityId = "sentinel-b", + rationale = "must survive", + ) + + linkStore.record(contextId, sentinel) + linkStore.record(contextId, adversarial) + + val links = linkStore.find(contextId) + assertEquals(2, links.size) + assertEquals(injectionShapedRationale, links.single { it.sourceEntityId == "adversarial-a" }.rationale) + assertEquals("must survive", links.single { it.sourceEntityId == "sentinel-a" }.rationale) + } + + @Test + fun `the same natural link and its review lifecycle remain independent across contexts`() { + val contextA = ContextId("ctx-a") + val contextB = ContextId("ctx-b") + val original = semanticLink( + sourceEntityId = "shared-a", + targetEntityId = "shared-b", + ) + val id = linkStore.idOf(original) + + linkStore.record(contextA, original.copy(rationale = "context A initial")) + linkStore.record(contextB, original.copy(rationale = "context B initial")) + linkStore.updateReviewStatus(contextA, id, ReviewStatus.REJECTED) + linkStore.updateReviewStatus(contextB, id, ReviewStatus.ACCEPTED) + + val rediscoveredA = original.copy( + sourcePropositionIds = listOf("prop-a-refreshed"), + confidence = 0.81, + rationale = "context A refreshed", + ) + val rediscoveredB = original.copy( + sourcePropositionIds = listOf("prop-b-refreshed"), + confidence = 0.92, + rationale = "context B refreshed", + ) + val storedA = linkStore.record(contextA, rediscoveredA) + val storedB = linkStore.record(contextB, rediscoveredB) + + assertEquals(ReviewStatus.REJECTED, storedA.reviewStatus) + assertEquals(ReviewStatus.ACCEPTED, storedB.reviewStatus) + assertEquals( + listOf(rediscoveredA.copy(reviewStatus = ReviewStatus.REJECTED)), + linkStore.find(contextA), + ) + assertEquals( + listOf(rediscoveredB.copy(reviewStatus = ReviewStatus.ACCEPTED)), + linkStore.find(contextB), + ) + } + + @Test + fun `find warns and skips a corrupt row without hiding readable links`() { + val contextId = ContextId("ctx-corrupt") + val valid = semanticLink(sourceEntityId = "valid-a", targetEntityId = "valid-b") + linkStore.record(contextId, valid) + persistenceManager.execute( + QuerySpecification.withStatement( + """ + CREATE (:SemanticLink { + id: ${'$'}id, + contextId: ${'$'}contextId, + sourceEntityId: ${'$'}sourceEntityId, + targetEntityId: ${'$'}targetEntityId, + kind: ${'$'}kind, + reviewStatus: ${'$'}reviewStatus, + confidence: ${'$'}confidence, + rationale: ${'$'}rationale, + connectingEntityIds: ${'$'}connectingEntityIds, + sourcePropositionIds: ${'$'}sourcePropositionIds, + createdAt: ${'$'}now + }) + """.trimIndent(), + ).bind( + mapOf( + "id" to "corrupt|row|INFERRED", + "contextId" to contextId.value, + "sourceEntityId" to "corrupt", + "targetEntityId" to "row", + "kind" to "NOT_A_LINK_KIND", + "reviewStatus" to ReviewStatus.CANDIDATE.name, + "confidence" to 0.5, + "rationale" to "bad enum", + "connectingEntityIds" to emptyList(), + "sourcePropositionIds" to listOf("prop-corrupt"), + "now" to "2026-07-28T00:00:00Z", + ), + ), + ) + + assertEquals(listOf(valid), linkStore.find(contextId)) + } + + private fun semanticLink( + sourceEntityId: String = "source", + targetEntityId: String = "target", + connectingEntityIds: List = listOf("bridge"), + sourcePropositionIds: List = listOf("prop"), + confidence: Double = 0.75, + rationale: String? = "rationale", + ) = SemanticLink( + sourceEntityId = sourceEntityId, + targetEntityId = targetEntityId, + connectingEntityIds = connectingEntityIds, + kind = LinkKind.INFERRED, + sourcePropositionIds = sourcePropositionIds, + confidence = confidence, + rationale = rationale, + ) +} + +@TestConfiguration(proxyBeanMethods = false) +internal class SemanticLinkStoreTestConfiguration { + + @Bean + fun semanticLinkStore(persistenceManager: PersistenceManager): DrivineSemanticLinkStore = + DrivineSemanticLinkStore(persistenceManager) +} diff --git a/dice/src/main/kotlin/com/embabel/dice/common/DiceMetadataKeys.kt b/dice/src/main/kotlin/com/embabel/dice/common/DiceMetadataKeys.kt index f2f48bf7..d627e2bb 100644 --- a/dice/src/main/kotlin/com/embabel/dice/common/DiceMetadataKeys.kt +++ b/dice/src/main/kotlin/com/embabel/dice/common/DiceMetadataKeys.kt @@ -15,6 +15,8 @@ */ package com.embabel.dice.common +import org.jetbrains.annotations.ApiStatus + /** * Well-known metadata keys used by DICE for values cached on a proposition's metadata map. * @@ -41,6 +43,20 @@ object DiceMetadataKeys { */ const val METAMODEL_VERSION = "dice.metamodel.version" + /** + * Identifier of the caller-authored taxonomy category assigned to a proposition. + */ + @ApiStatus.Experimental + const val TAXONOMY_NODE = "dice.taxonomy.node" + + /** + * Version of the taxonomy content used to assign [TAXONOMY_NODE]. + * + * This is independent of [METAMODEL_VERSION], which describes the extraction shape. + */ + @ApiStatus.Experimental + const val TAXONOMY_VERSION = "dice.taxonomy.version" + /** * Human-readable reason a proposition was quarantined due to schema drift. * diff --git a/dice/src/main/kotlin/com/embabel/dice/query/taxonomy/TaxonomyQuery.kt b/dice/src/main/kotlin/com/embabel/dice/query/taxonomy/TaxonomyQuery.kt new file mode 100644 index 00000000..addcb792 --- /dev/null +++ b/dice/src/main/kotlin/com/embabel/dice/query/taxonomy/TaxonomyQuery.kt @@ -0,0 +1,101 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.dice.query.taxonomy + +import com.embabel.agent.core.ContextId +import com.embabel.dice.common.DiceMetadataKeys +import com.embabel.dice.proposition.PropositionQuery +import com.embabel.dice.proposition.PropositionStatus +import com.embabel.dice.proposition.PropositionStore +import com.embabel.dice.taxonomy.Taxonomy +import org.jetbrains.annotations.ApiStatus + +/** + * Portable taxonomy-axis queries over proposition data. + * + * Version 1 loads ACTIVE propositions and derives taxonomy membership in memory so it works with + * every [PropositionStore]. Native query push-down is a stated follow-up for stores that can support + * it without changing this facade. + * + * @param store the backing proposition store + * @param taxonomy the taxonomy used to validate category identifiers and compare their lineages + * @param contextId optional scope; when present, queries are confined to this context + */ +@ApiStatus.Experimental +class TaxonomyQuery( + private val store: PropositionStore, + private val taxonomy: Taxonomy, + private val contextId: ContextId? = null, +) { + + /** + * Distinct valid taxonomy node identifiers evidenced by ACTIVE propositions mentioning + * [entityId]. + * + * An unknown entity has no categories. + */ + @ApiStatus.Experimental + fun categoriesOf(entityId: String): Set = + categorizedEntities()[entityId].orEmpty() + + /** + * Entity identifiers whose evidenced categories share an ancestor with a category of + * [entityId] within [maxLevels], excluding [entityId] itself. + * + * The starting category is level zero. A negative [maxLevels] is treated as zero, and an + * unknown entity produces an empty set. + */ + @ApiStatus.Experimental + fun entitiesSharingAncestor(entityId: String, maxLevels: Int): Set { + val categoriesByEntity = categorizedEntities() + val sourceCategories = categoriesByEntity[entityId].orEmpty() + if (sourceCategories.isEmpty()) { + return emptySet() + } + val levelBound = maxLevels.coerceAtLeast(0) + return categoriesByEntity + .asSequence() + .filter { (candidateId) -> candidateId != entityId } + .filter { (_, candidateCategories) -> + sourceCategories.any { sourceCategory -> + candidateCategories.any { candidateCategory -> + taxonomy.sharedAncestor(sourceCategory, candidateCategory, levelBound) != null + } + } + } + .mapTo(linkedSetOf()) { (candidateId) -> candidateId } + } + + private fun categorizedEntities(): Map> { + val query = (contextId?.let(PropositionQuery::forContextId) ?: PropositionQuery()) + .withStatus(PropositionStatus.ACTIVE) + val categoriesByEntity = linkedMapOf>() + store.query(query).forEach { proposition -> + val categoryId = proposition.metadata[DiceMetadataKeys.TAXONOMY_NODE] as? String + ?: return@forEach + if (taxonomy.node(categoryId) == null) { + return@forEach + } + proposition.mentions + .mapNotNull { it.resolvedId } + .distinct() + .forEach { entityId -> + categoriesByEntity.getOrPut(entityId) { linkedSetOf() } += categoryId + } + } + return categoriesByEntity + } +} diff --git a/dice/src/main/kotlin/com/embabel/dice/taxonomy/Taxonomy.kt b/dice/src/main/kotlin/com/embabel/dice/taxonomy/Taxonomy.kt new file mode 100644 index 00000000..9d0cb724 --- /dev/null +++ b/dice/src/main/kotlin/com/embabel/dice/taxonomy/Taxonomy.kt @@ -0,0 +1,169 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.dice.taxonomy + +import org.jetbrains.annotations.ApiStatus + +/** + * A caller-authored category in a [Taxonomy]. + * + * @param id Stable identifier used in metadata and parent links. + * @param label Human-readable category name. + * @param parentId Identifier of the parent category, or `null` for a root. + * @param keywords Terms that can help a classifier recognize this category. + */ +@ApiStatus.Experimental +data class TaxonomyNode( + val id: String, + val label: String, + val parentId: String? = null, + val keywords: List = emptyList(), +) + +/** + * An immutable, validated hierarchy of caller-authored categories. + * + * Nodes retain declaration order. Every identifier must be nonblank and unique, parent + * links must resolve within this taxonomy, and the resulting hierarchy must be acyclic. + */ +@ApiStatus.Experimental +class Taxonomy( + nodes: List, + /** + * Version of the category content and assignments. + * + * This does not version the metamodel used to shape extracted propositions. + */ + @get:ApiStatus.Experimental + val version: String = "1", +) { + + internal val nodes: List = nodes.toList() + + private val nodesById: Map + + init { + val indexed = LinkedHashMap() + this.nodes.forEach { node -> + require(node.id.isNotBlank()) { + "Taxonomy node id '${node.id}' must not be blank" + } + require(indexed.putIfAbsent(node.id, node) == null) { + "Duplicate taxonomy node id '${node.id}'" + } + } + nodesById = indexed + + this.nodes.forEach { node -> + val parentId = node.parentId + require(parentId == null || parentId in nodesById) { + "Taxonomy node '${node.id}' has unknown parent '$parentId'" + } + } + validateAcyclic() + } + + /** + * Find a category by its stable identifier. + */ + @ApiStatus.Experimental + fun node(id: String): TaxonomyNode? = nodesById[id] + + /** + * Walk toward the root, returning the nearest parents first. + * + * An unknown identifier has no ancestors. + */ + @ApiStatus.Experimental + fun ancestors(id: String, maxLevels: Int = Int.MAX_VALUE): List { + require(maxLevels >= 0) { "maxLevels must not be negative: $maxLevels" } + val ancestors = mutableListOf() + var current = nodesById[id] + while (ancestors.size < maxLevels) { + current = current?.parentId?.let(nodesById::get) ?: break + ancestors += current + } + return ancestors + } + + /** + * Find the lowest category shared by both paths toward the root. + * + * Each starting category is included at level zero. An unknown identifier has no + * shared ancestor. + */ + @ApiStatus.Experimental + fun sharedAncestor(a: String, b: String, maxLevels: Int): TaxonomyNode? { + require(maxLevels >= 0) { "maxLevels must not be negative: $maxLevels" } + val bLineage = lineage(b, maxLevels).mapTo(mutableSetOf()) { it.id } + return lineage(a, maxLevels).firstOrNull { it.id in bLineage } + } + + /** + * Count the parent links from a category to one of its ancestors. + * + * @throws IllegalArgumentException when either identifier is unknown or the target + * is not on the path to the root. + */ + @ApiStatus.Experimental + fun levelsTo(id: String, ancestorId: String): Int { + var current = nodesById[id] + var levels = 0 + while (current != null) { + if (current.id == ancestorId) { + return levels + } + current = current.parentId?.let(nodesById::get) + levels++ + } + throw IllegalArgumentException( + "Taxonomy node '$ancestorId' is not an ancestor of '$id'", + ) + } + + private fun lineage(id: String, maxLevels: Int): List { + val start = nodesById[id] ?: return emptyList() + return buildList { + add(start) + addAll(ancestors(id, maxLevels)) + } + } + + private fun validateAcyclic() { + val states = mutableMapOf() + + fun visit(id: String) { + when (states[id]) { + VisitState.VISITING -> throw IllegalArgumentException( + "Taxonomy cycle contains node '$id'", + ) + + VisitState.VISITED -> return + null -> Unit + } + states[id] = VisitState.VISITING + nodesById.getValue(id).parentId?.let(::visit) + states[id] = VisitState.VISITED + } + + nodes.forEach { visit(it.id) } + } + + private enum class VisitState { + VISITING, + VISITED, + } +} diff --git a/dice/src/main/kotlin/com/embabel/dice/taxonomy/TaxonomyClassifier.kt b/dice/src/main/kotlin/com/embabel/dice/taxonomy/TaxonomyClassifier.kt new file mode 100644 index 00000000..5cfaf4dc --- /dev/null +++ b/dice/src/main/kotlin/com/embabel/dice/taxonomy/TaxonomyClassifier.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.dice.taxonomy + +import com.embabel.dice.proposition.Proposition +import org.jetbrains.annotations.ApiStatus + +/** + * Classifies propositions against a caller-provided taxonomy. + */ +@ApiStatus.Experimental +fun interface TaxonomyClassifier { + + /** + * Return the best taxonomy node identifier for [proposition], or `null` to abstain. + */ + fun classify(proposition: Proposition, taxonomy: Taxonomy): String? +} + +/** + * Classifies propositions by matching taxonomy labels and keywords in proposition text. + * + * Matches are case-insensitive whole words. When multiple nodes match, the deepest node + * wins; declaration order breaks ties. + */ +@ApiStatus.Experimental +class LexicalTaxonomyClassifier : TaxonomyClassifier { + + override fun classify(proposition: Proposition, taxonomy: Taxonomy): String? = + taxonomy.nodes + .filter { node -> + (node.keywords + node.label) + .filter(String::isNotBlank) + .any { term -> + Regex( + pattern = "(?U)(? taxonomy.ancestors(node.id).size } + ?.id +} diff --git a/dice/src/main/kotlin/com/embabel/dice/taxonomy/TaxonomyDecorationPass.kt b/dice/src/main/kotlin/com/embabel/dice/taxonomy/TaxonomyDecorationPass.kt new file mode 100644 index 00000000..4695665a --- /dev/null +++ b/dice/src/main/kotlin/com/embabel/dice/taxonomy/TaxonomyDecorationPass.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.dice.taxonomy + +import com.embabel.agent.core.ContextId +import com.embabel.dice.common.DiceMetadataKeys +import com.embabel.dice.operations.consolidation.ConsolidationPass +import com.embabel.dice.operations.consolidation.ConsolidationPassResult +import com.embabel.dice.proposition.Proposition +import org.jetbrains.annotations.ApiStatus + +/** + * Decorates propositions with classifications from a caller-provided [Taxonomy]. + * + * Already-current classifications are left untouched. The pass reports decorated copies + * for the consolidation orchestrator to save and never writes to a repository directly. + */ +@ApiStatus.Experimental +class TaxonomyDecorationPass( + private val taxonomy: Taxonomy, + private val classifier: TaxonomyClassifier, +) : ConsolidationPass { + + @get:ApiStatus.Experimental + override val name: String = "taxonomy" + + @ApiStatus.Experimental + override fun run( + contextId: ContextId, + propositions: List, + ): ConsolidationPassResult { + var skipped = 0 + val decorated = buildList { + propositions.forEach { proposition -> + if (proposition.isCurrentDecoration()) { + skipped++ + return@forEach + } + + val nodeId = classifier.classify(proposition, taxonomy) + if (nodeId == null || taxonomy.node(nodeId) == null) { + skipped++ + return@forEach + } + + add( + proposition + .withMetadataValue(DiceMetadataKeys.TAXONOMY_NODE, nodeId) + .withMetadataValue(DiceMetadataKeys.TAXONOMY_VERSION, taxonomy.version), + ) + } + } + + return if (decorated.isEmpty()) { + ConsolidationPassResult.NoOp( + passName = name, + reason = "No taxonomy decorations changed", + ) + } else { + ConsolidationPassResult.Changed( + passName = name, + propositionsToSave = decorated, + skipped = skipped, + summary = "Decorated ${decorated.size} propositions", + ) + } + } + + private fun Proposition.isCurrentDecoration(): Boolean = + metadata.containsKey(DiceMetadataKeys.TAXONOMY_NODE) && + metadata[DiceMetadataKeys.TAXONOMY_VERSION] == taxonomy.version +} diff --git a/dice/src/test/kotlin/com/embabel/dice/query/taxonomy/TaxonomyQueryTest.kt b/dice/src/test/kotlin/com/embabel/dice/query/taxonomy/TaxonomyQueryTest.kt new file mode 100644 index 00000000..46cb806b --- /dev/null +++ b/dice/src/test/kotlin/com/embabel/dice/query/taxonomy/TaxonomyQueryTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.dice.query.taxonomy + +import com.embabel.agent.core.ContextId +import com.embabel.dice.common.DiceMetadataKeys +import com.embabel.dice.proposition.EntityMention +import com.embabel.dice.proposition.MentionRole +import com.embabel.dice.proposition.Proposition +import com.embabel.dice.proposition.PropositionStatus +import com.embabel.dice.proposition.store.InMemoryPropositionRepository +import com.embabel.dice.taxonomy.Taxonomy +import com.embabel.dice.taxonomy.TaxonomyNode +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class TaxonomyQueryTest { + + private val contextId = ContextId("taxonomy-query-test") + private val otherContextId = ContextId("other-taxonomy-query-test") + + private val taxonomy = Taxonomy( + listOf( + TaxonomyNode(id = "animals", label = "Animals"), + TaxonomyNode(id = "mammals", label = "Mammals", parentId = "animals"), + TaxonomyNode(id = "cats", label = "Cats", parentId = "mammals"), + TaxonomyNode(id = "siamese", label = "Siamese", parentId = "cats"), + TaxonomyNode(id = "dogs", label = "Dogs", parentId = "mammals"), + TaxonomyNode(id = "beagle", label = "Beagle", parentId = "dogs"), + TaxonomyNode(id = "birds", label = "Birds", parentId = "animals"), + TaxonomyNode(id = "sparrow", label = "Sparrow", parentId = "birds"), + ), + ) + + @Test + fun `categories are derived from active context-scoped propositions`() { + val store = InMemoryPropositionRepository() + store.save(proposition("cat-1", "cat", "siamese")) + store.save(proposition("cat-2", "cat", "cats")) + store.save(proposition("cat-duplicate", "cat", "cats")) + store.save(proposition("inactive", "cat", "mammals", status = PropositionStatus.SUPERSEDED)) + store.save(proposition("bogus", "cat", "not-in-taxonomy")) + store.save(proposition("other-context", "cat", "animals", contextId = otherContextId)) + + val categories = TaxonomyQuery(store, taxonomy, contextId).categoriesOf("cat") + + assertEquals(setOf("siamese", "cats"), categories) + } + + @Test + fun `entities sharing an ancestor are bounded by taxonomy levels`() { + val store = InMemoryPropositionRepository() + store.save(proposition("cat", "cat", "siamese")) + store.save(proposition("dog", "dog", "beagle")) + store.save(proposition("bird", "bird", "sparrow")) + + val query = TaxonomyQuery(store, taxonomy, contextId) + + assertEquals(setOf("dog"), query.entitiesSharingAncestor("cat", maxLevels = 2)) + assertTrue(query.entitiesSharingAncestor("cat", maxLevels = 1).isEmpty()) + assertEquals(setOf("dog", "bird"), query.entitiesSharingAncestor("cat", maxLevels = 3)) + } + + @Test + fun `unknown entities return empty sets`() { + val query = TaxonomyQuery(InMemoryPropositionRepository(), taxonomy, contextId) + + assertTrue(query.categoriesOf("unknown").isEmpty()) + assertTrue(query.entitiesSharingAncestor("unknown", maxLevels = 2).isEmpty()) + } + + private fun proposition( + id: String, + entityId: String, + taxonomyNodeId: String, + status: PropositionStatus = PropositionStatus.ACTIVE, + contextId: ContextId = this.contextId, + ): Proposition = + Proposition( + id = id, + contextId = contextId, + text = "$entityId is a $taxonomyNodeId", + mentions = listOf( + EntityMention( + span = entityId, + type = "Entity", + resolvedId = entityId, + role = MentionRole.SUBJECT, + ), + ), + confidence = 0.9, + status = status, + metadata = mapOf(DiceMetadataKeys.TAXONOMY_NODE to taxonomyNodeId), + ) +} diff --git a/dice/src/test/kotlin/com/embabel/dice/taxonomy/LexicalTaxonomyClassifierTest.kt b/dice/src/test/kotlin/com/embabel/dice/taxonomy/LexicalTaxonomyClassifierTest.kt new file mode 100644 index 00000000..0be86abf --- /dev/null +++ b/dice/src/test/kotlin/com/embabel/dice/taxonomy/LexicalTaxonomyClassifierTest.kt @@ -0,0 +1,123 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.dice.taxonomy + +import com.embabel.agent.core.ContextId +import com.embabel.dice.proposition.Proposition +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class LexicalTaxonomyClassifierTest { + + private val classifier = LexicalTaxonomyClassifier() + + @Test + fun `returns the node whose keyword matches`() { + val taxonomy = Taxonomy( + listOf( + TaxonomyNode(id = "science", label = "Science", keywords = listOf("physics")), + TaxonomyNode(id = "art", label = "Art"), + ), + ) + + assertThat(classifier.classify(proposition("Physics explains motion"), taxonomy)) + .isEqualTo("science") + } + + @Test + fun `returns the deepest node when multiple nodes match`() { + val taxonomy = Taxonomy( + listOf( + TaxonomyNode(id = "technology", label = "Technology"), + TaxonomyNode( + id = "software", + label = "Software", + parentId = "technology", + keywords = listOf("code"), + ), + ), + ) + + assertThat(classifier.classify(proposition("Technology depends on code"), taxonomy)) + .isEqualTo("software") + } + + @Test + fun `returns the first declared node when matching nodes have equal depth`() { + val taxonomy = Taxonomy( + listOf( + TaxonomyNode(id = "first", label = "First", keywords = listOf("shared")), + TaxonomyNode(id = "second", label = "Second", keywords = listOf("shared")), + ), + ) + + assertThat(classifier.classify(proposition("A shared keyword"), taxonomy)) + .isEqualTo("first") + } + + @Test + fun `abstains when no node matches`() { + val taxonomy = Taxonomy( + listOf(TaxonomyNode(id = "science", label = "Science")), + ) + + assertThat(classifier.classify(proposition("A completely unrelated statement"), taxonomy)) + .isNull() + } + + @Test + fun `does not match a keyword inside another word`() { + val taxonomy = Taxonomy( + listOf(TaxonomyNode(id = "art", label = "Creativity", keywords = listOf("art"))), + ) + + assertThat(classifier.classify(proposition("Start here"), taxonomy)).isNull() + } + + @Test + fun `does not match before unicode word continuations`() { + val taxonomy = Taxonomy( + listOf(TaxonomyNode(id = "art", label = "Creativity", keywords = listOf("art"))), + ) + + listOf( + "art\u0301ist", + "art\u203Fist", + "art\u200Dist", + ).forEach { text -> + assertThat(classifier.classify(proposition(text), taxonomy)) + .describedAs("classification for %s", text) + .isNull() + } + } + + @Test + fun `matches escaped regex metacharacters as a complete term`() { + val taxonomy = Taxonomy( + listOf(TaxonomyNode(id = "cpp", label = "C plus plus", keywords = listOf("c++"))), + ) + + assertThat(classifier.classify(proposition("C++ is a programming language"), taxonomy)) + .isEqualTo("cpp") + } + + private fun proposition(text: String) = Proposition( + contextId = ContextId("test"), + text = text, + mentions = emptyList(), + confidence = 1.0, + ) +} diff --git a/dice/src/test/kotlin/com/embabel/dice/taxonomy/TaxonomyDecorationPassTest.kt b/dice/src/test/kotlin/com/embabel/dice/taxonomy/TaxonomyDecorationPassTest.kt new file mode 100644 index 00000000..4b252271 --- /dev/null +++ b/dice/src/test/kotlin/com/embabel/dice/taxonomy/TaxonomyDecorationPassTest.kt @@ -0,0 +1,180 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.dice.taxonomy + +import com.embabel.agent.core.ContextId +import com.embabel.dice.common.DiceMetadataKeys +import com.embabel.dice.operations.consolidation.ConsolidationPassResult +import com.embabel.dice.proposition.Proposition +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.time.Instant + +class TaxonomyDecorationPassTest { + + private val contextId = ContextId("test") + + @Test + fun `decorates an unmarked proposition with central taxonomy metadata`() { + val taxonomy = taxonomy() + val originalRevision = Instant.parse("2025-01-01T00:00:00Z") + val proposition = proposition( + text = "A physics observation", + metadata = mapOf("existing" to "value"), + contentRevised = originalRevision, + metadataRevised = originalRevision, + ) + val pass = TaxonomyDecorationPass( + taxonomy = taxonomy, + classifier = TaxonomyClassifier { _, _ -> "science" }, + ) + + val result = pass.run(contextId, listOf(proposition)) + + assertThat(result).isInstanceOfSatisfying(ConsolidationPassResult.Changed::class.java) { changed -> + assertThat(changed.passName).isEqualTo("taxonomy") + assertThat(changed.propositionsToSave).hasSize(1) + val decorated = changed.propositionsToSave.single() + assertThat(decorated.metadata).containsExactlyInAnyOrderEntriesOf( + mapOf( + "existing" to "value", + DiceMetadataKeys.TAXONOMY_NODE to "science", + DiceMetadataKeys.TAXONOMY_VERSION to taxonomy.version, + ), + ) + assertThat(decorated.contentRevised).isEqualTo(originalRevision) + assertThat(decorated.metadataRevised).isAfter(originalRevision) + assertThat(changed.skipped).isZero() + } + } + + @Test + fun `skips current abstained and unknown classifications while decorating valid ones`() { + val taxonomy = taxonomy() + val valid = proposition("valid") + val current = proposition( + text = "current", + metadata = mapOf( + DiceMetadataKeys.TAXONOMY_NODE to "science", + DiceMetadataKeys.TAXONOMY_VERSION to taxonomy.version, + ), + ) + val abstained = proposition("abstained") + val unknown = proposition("unknown") + val pass = TaxonomyDecorationPass( + taxonomy = taxonomy, + classifier = TaxonomyClassifier { proposition, _ -> + when (proposition.text) { + "valid" -> "science" + "current" -> error("An already-current proposition must not be classified") + "unknown" -> "missing" + else -> null + } + }, + ) + + val result = pass.run(contextId, listOf(valid, current, abstained, unknown)) + + assertThat(result).isInstanceOfSatisfying(ConsolidationPassResult.Changed::class.java) { changed -> + assertThat(changed.propositionsToSave).extracting { + it.metadata[DiceMetadataKeys.TAXONOMY_NODE] as String + }.containsExactly("science") + assertThat(changed.skipped).isEqualTo(3) + } + assertThat(current.metadata).containsEntry(DiceMetadataKeys.TAXONOMY_VERSION, taxonomy.version) + assertThat(abstained.metadata).doesNotContainKeys( + DiceMetadataKeys.TAXONOMY_NODE, + DiceMetadataKeys.TAXONOMY_VERSION, + ) + assertThat(unknown.metadata).doesNotContainKeys( + DiceMetadataKeys.TAXONOMY_NODE, + DiceMetadataKeys.TAXONOMY_VERSION, + ) + } + + @Test + fun `redecorates when the taxonomy version changes`() { + val taxonomy = taxonomy(version = "2") + val proposition = proposition( + text = "version bump", + metadata = mapOf( + DiceMetadataKeys.TAXONOMY_NODE to "old-node", + DiceMetadataKeys.TAXONOMY_VERSION to "1", + ), + ) + val pass = TaxonomyDecorationPass( + taxonomy = taxonomy, + classifier = TaxonomyClassifier { _, _ -> "science" }, + ) + + val result = pass.run(contextId, listOf(proposition)) + + assertThat(result).isInstanceOfSatisfying(ConsolidationPassResult.Changed::class.java) { changed -> + assertThat(changed.propositionsToSave).hasSize(1) + assertThat(changed.propositionsToSave.single().metadata) + .containsEntry(DiceMetadataKeys.TAXONOMY_NODE, "science") + .containsEntry(DiceMetadataKeys.TAXONOMY_VERSION, "2") + assertThat(changed.skipped).isZero() + } + } + + @Test + fun `returns no-op when every proposition is skipped`() { + val taxonomy = taxonomy() + val current = proposition( + text = "current", + metadata = mapOf( + DiceMetadataKeys.TAXONOMY_NODE to "science", + DiceMetadataKeys.TAXONOMY_VERSION to taxonomy.version, + ), + ) + val pass = TaxonomyDecorationPass( + taxonomy = taxonomy, + classifier = TaxonomyClassifier { _, _ -> "missing" }, + ) + + val result = pass.run(contextId, listOf(current, proposition("unknown"))) + + assertThat(result).isEqualTo( + ConsolidationPassResult.NoOp( + passName = "taxonomy", + reason = "No taxonomy decorations changed", + ), + ) + } + + private fun taxonomy(version: String = "1") = Taxonomy( + nodes = listOf( + TaxonomyNode(id = "science", label = "Science"), + ), + version = version, + ) + + private fun proposition( + text: String, + metadata: Map = emptyMap(), + contentRevised: Instant = Instant.now(), + metadataRevised: Instant = contentRevised, + ) = Proposition( + contextId = contextId, + text = text, + mentions = emptyList(), + confidence = 1.0, + metadata = metadata, + contentRevised = contentRevised, + metadataRevised = metadataRevised, + ) +} diff --git a/dice/src/test/kotlin/com/embabel/dice/taxonomy/TaxonomyTest.kt b/dice/src/test/kotlin/com/embabel/dice/taxonomy/TaxonomyTest.kt new file mode 100644 index 00000000..023b22af --- /dev/null +++ b/dice/src/test/kotlin/com/embabel/dice/taxonomy/TaxonomyTest.kt @@ -0,0 +1,147 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.dice.taxonomy + +import com.embabel.dice.common.DiceMetadataKeys +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class TaxonomyTest { + + private val root = TaxonomyNode(id = "work", label = "Work") + private val engineering = TaxonomyNode( + id = "engineering", + label = "Engineering", + parentId = root.id, + ) + private val product = TaxonomyNode( + id = "product", + label = "Product", + parentId = root.id, + ) + private val platform = TaxonomyNode( + id = "platform", + label = "Platform", + parentId = engineering.id, + ) + private val roadmap = TaxonomyNode( + id = "roadmap", + label = "Roadmap", + parentId = product.id, + ) + + @Test + fun `walks ancestors nearest first and respects the level bound`() { + val taxonomy = taxonomy() + + assertEquals(listOf(engineering, root), taxonomy.ancestors(platform.id)) + assertEquals(listOf(engineering), taxonomy.ancestors(platform.id, maxLevels = 1)) + assertEquals(emptyList(), taxonomy.ancestors(platform.id, maxLevels = 0)) + assertEquals(2, taxonomy.levelsTo(platform.id, root.id)) + assertEquals(0, taxonomy.levelsTo(platform.id, platform.id)) + } + + @Test + fun `finds the nearest shared ancestor within each node's level bound`() { + val taxonomy = taxonomy() + + assertEquals(root, taxonomy.sharedAncestor(platform.id, roadmap.id, maxLevels = 2)) + assertEquals(engineering, taxonomy.sharedAncestor(platform.id, engineering.id, maxLevels = 1)) + assertNull(taxonomy.sharedAncestor(platform.id, roadmap.id, maxLevels = 1)) + } + + @Test + fun `preserves declaration order and supports exact lookup`() { + val declared = listOf(root, product, roadmap, engineering, platform) + val taxonomy = Taxonomy(declared) + + assertEquals(declared, taxonomy.nodes) + assertEquals(roadmap, taxonomy.node(roadmap.id)) + assertNull(taxonomy.node("unknown")) + } + + @Test + fun `rejects a two node cycle with an offending id`() { + val failure = assertThrows(IllegalArgumentException::class.java) { + Taxonomy( + listOf( + TaxonomyNode(id = "a", label = "A", parentId = "b"), + TaxonomyNode(id = "b", label = "B", parentId = "a"), + ), + ) + } + + assertEquals("Taxonomy cycle contains node 'a'", failure.message) + } + + @Test + fun `rejects an unknown parent with the offending node id`() { + val failure = assertThrows(IllegalArgumentException::class.java) { + Taxonomy( + listOf( + TaxonomyNode(id = "orphan", label = "Orphan", parentId = "missing"), + ), + ) + } + + assertEquals("Taxonomy node 'orphan' has unknown parent 'missing'", failure.message) + } + + @Test + fun `rejects blank and duplicate ids`() { + val blank = assertThrows(IllegalArgumentException::class.java) { + Taxonomy(listOf(TaxonomyNode(id = " ", label = "Blank"))) + } + val duplicate = assertThrows(IllegalArgumentException::class.java) { + Taxonomy( + listOf( + TaxonomyNode(id = "same", label = "First"), + TaxonomyNode(id = "same", label = "Second"), + ), + ) + } + + assertEquals("Taxonomy node id ' ' must not be blank", blank.message) + assertEquals("Duplicate taxonomy node id 'same'", duplicate.message) + } + + @Test + fun `keeps taxonomy content version separate from extraction shape version`() { + assertEquals("1", taxonomy().version) + assertEquals("customer-categories-v2", taxonomy(version = "customer-categories-v2").version) + assertEquals("dice.taxonomy.node", DiceMetadataKeys.TAXONOMY_NODE) + assertEquals("dice.taxonomy.version", DiceMetadataKeys.TAXONOMY_VERSION) + assertNotEquals(DiceMetadataKeys.METAMODEL_VERSION, DiceMetadataKeys.TAXONOMY_VERSION) + } + + @Test + fun `throws when the requested target is not an ancestor`() { + val failure = assertThrows(IllegalArgumentException::class.java) { + taxonomy().levelsTo(platform.id, product.id) + } + + assertTrue(failure.message.orEmpty().contains(product.id)) + } + + private fun taxonomy(version: String = "1") = Taxonomy( + nodes = listOf(root, engineering, product, platform, roadmap), + version = version, + ) +}