Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package carp.dsp.core.application.plan

import dk.cachet.carp.analytics.application.plan.ExecutionPlan
import dk.cachet.carp.analytics.application.plan.PlanHasher
import dk.cachet.carp.analytics.infrastructure.serialization.CoreAnalyticsSerializer
import kotlinx.serialization.encodeToString
import java.security.MessageDigest

/**
* SHA-256 based plan hasher.
*
* Lives in DSP (not core) because it uses Java's MessageDigest.
* Keeps core/analytics completely MPP-compatible.
*/
class SHA256PlanHasher : PlanHasher {

override fun hash(plan: ExecutionPlan): String {
// 1. Canonicalize the plan (deterministic JSON)
val canonical = canonicalizePlan(plan)

// 2. Hash the canonical JSON
val digest = MessageDigest.getInstance("SHA-256")
val hashBytes = digest.digest(canonical.toByteArray(Charsets.UTF_8))

// 3. Convert to hex string
return hashBytes.joinToString("") { "%02x".format(it) }
}

private fun canonicalizePlan(plan: ExecutionPlan): String {
// Serialize plan to JSON using CoreAnalyticsSerializer
return CoreAnalyticsSerializer.json.encodeToString(plan)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package carp.dsp.core.application.plan

import junit.framework.TestCase.assertTrue
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull

class IntegrationTest {
private val mockHasher = MockPlanHasher("integration-test-hash") // ✅ Use mock (MPP-safe)

@Test
fun full_planning_produces_valid_diagnostics() {
val definition = createTestWorkflowDefinition()
val planner = DefaultExecutionPlanner()

val plan = planner.plan(definition)
val diag = plan.diagnostics(mockHasher) // ✅ Pass mock hasher

assertNotNull(diag)
assertTrue(diag.stepCount > 0)
assertNotNull(diag.planHash)
}

@Test
fun plan_diagnostics_include_hash() {
val definition = createTestWorkflowDefinition()
val planner = DefaultExecutionPlanner()

val plan = planner.plan(definition)
val diag = plan.diagnostics(mockHasher) // ✅ Pass mock hasher

// Hash should be computed and included
assertTrue(diag.planHash.isNotEmpty())
assertEquals("integration-test-hash", diag.planHash)
}

@Test
fun different_hasher_implementations_can_be_swapped() {
val definition = createTestWorkflowDefinition()
val planner = DefaultExecutionPlanner()
val plan = planner.plan(definition)

// Different hasher implementations can be used
val deterministicHasher = DeterministicPlanHasher()
val diag = plan.diagnostics(deterministicHasher)

// Hashers can be swapped without changing code
assertNotNull(diag.planHash)
assertTrue(diag.planHash.isNotEmpty())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package carp.dsp.core.application.plan

import junit.framework.TestCase.assertTrue
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals

class SHA256PlanHasherTest {
private val hasher = SHA256PlanHasher()

@Test
fun same_plan_produces_same_hash() {
val plan = createTestExecutionPlan()
val hash1 = hasher.hash(plan)
val hash2 = hasher.hash(plan)

assertEquals(hash1, hash2)
}

@Test
fun different_plans_produce_different_hashes() {
val plan1 = createTestExecutionPlan(stepCount = 1)
val plan2 = createTestExecutionPlan(stepCount = 2)

val hash1 = hasher.hash(plan1)
val hash2 = hasher.hash(plan2)

assertNotEquals(hash1, hash2)
}

@Test
fun hash_is_deterministic() {
val plan = createTestExecutionPlan()
val hashes = (1..5).map { hasher.hash(plan) }

assertTrue(hashes.all { it == hashes[0] })
}

@Test
fun hash_is_hex_encoded_string() {
val plan = createTestExecutionPlan()
val hash = hasher.hash(plan)

assertTrue(hash.matches(Regex("[0-9a-f]{64}"))) // SHA-256 is 64 hex chars
}

@Test
fun hash_includes_all_plan_elements() {
val plan1 = createTestExecutionPlan(
workflowId = "workflow-1"
)
val plan2 = createTestExecutionPlan(
workflowId = "workflow-2"
)

assertNotEquals(hasher.hash(plan1), hasher.hash(plan2))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package carp.dsp.core.application.plan

import carp.dsp.core.application.environment.CondaEnvironmentDefinition
import dk.cachet.carp.analytics.application.plan.*
import dk.cachet.carp.analytics.domain.data.FileDestination
import dk.cachet.carp.analytics.domain.data.FileFormat
import dk.cachet.carp.analytics.domain.data.OutputDataSpec
import dk.cachet.carp.analytics.domain.tasks.CommandTaskDefinition
import dk.cachet.carp.analytics.domain.tasks.Literal
import dk.cachet.carp.analytics.domain.workflow.*
import dk.cachet.carp.common.application.UUID

// ── ExecutionPlan Helpers ──────────────────────────────────────────────────────

fun createTestExecutionPlan(
workflowId: String = "test-workflow",
planId: String = UUID.randomUUID().toString(),
stepCount: Int = 2,
environmentCount: Int = 1,
issues: List<PlanIssue> = emptyList()
): ExecutionPlan {
val steps = (1..stepCount).map { i ->
PlannedStep(
stepId = UUID.randomUUID(),
name = "Step $i",
process = CommandSpec("python", listOf(ExpandedArg.Literal("script.py"))),
bindings = ResolvedBindings(
inputs = mapOf(),
outputs = mapOf(UUID.randomUUID() to DataRef(UUID.randomUUID(), "csv"))
),
environmentRef = UUID.randomUUID()
)
}

val environments = (1..environmentCount).associate { i ->
UUID.randomUUID() to CondaEnvironmentRef(
id = "env-$i",
name = "test-env-$i",
dependencies = listOf("numpy"),
channels = listOf("conda-forge"),
pythonVersion = "3.11"
)
}

return ExecutionPlan(
workflowId = workflowId,
planId = planId,
steps = steps,
issues = issues,
requiredEnvironmentRefs = environments
)
}

// ── WorkflowDefinition Helpers ─────────────────────────────────────────────────

fun createTestWorkflowDefinition(
workflowName: String = "test-workflow",
stepCount: Int = 2,
environmentCount: Int = 1
): WorkflowDefinition {
val steps = (1..stepCount).map { i ->
Step(
metadata = StepMetadata(
name = "Step $i",
id = UUID.randomUUID(),
description = "Test step $i",
version = Version(1)
),
inputs = emptyList(),
outputs = listOf(
OutputDataSpec(
id = UUID.randomUUID(),
name = "output-$i",
destination = FileDestination("output-$i.csv", format = FileFormat.CSV),
)
),
task = CommandTaskDefinition(
id = UUID.randomUUID(),
name = "task-$i",
description = "Test task",
executable = "python",
args = listOf(Literal("script.py"))
),
environmentId = UUID.randomUUID()
)
}

val environments = (1..environmentCount).associate { i ->
UUID.randomUUID() to CondaEnvironmentDefinition(
id = UUID.randomUUID(),
name = "test-env-$i",
channels = listOf("conda-forge"),
pythonVersion = "3.11",
dependencies = listOf("numpy", "scipy")
)
}

val workflow = Workflow(
metadata = WorkflowMetadata(
name = workflowName,
id = UUID.randomUUID(),
description = "Test workflow",
version = Version(1)
)
)
steps.forEach { workflow.addComponent(it) }

return WorkflowDefinition(
workflow = workflow,
environments = environments
)
}

// ── PlanIssue Helpers ──────────────────────────────────────────────────────────

fun createTestPlanWithIssues(
errors: Int = 0,
warnings: Int = 0,
infos: Int = 0
): ExecutionPlan {
val issues = mutableListOf<PlanIssue>()

repeat(errors) { i ->
issues.add(
PlanIssue(
severity = PlanIssueSeverity.ERROR,
code = "ERROR_$i",
message = "Error $i",
stepId = null
)
)
}

repeat(warnings) { i ->
issues.add(
PlanIssue(
severity = PlanIssueSeverity.WARNING,
code = "WARNING_$i",
message = "Warning $i",
stepId = null
)
)
}

repeat(infos) { i ->
issues.add(
PlanIssue(
severity = PlanIssueSeverity.INFO,
code = "INFO_$i",
message = "Info $i",
stepId = null
)
)
}

return createTestExecutionPlan(issues = issues)
}

// ── Mock Objects ───────────────────────────────────────────────────────────────

class MockPlanHasher(val hashValue: String = "mock-hash-12345") : PlanHasher {
override fun hash(plan: ExecutionPlan): String = hashValue
}

class DeterministicPlanHasher : PlanHasher {
override fun hash(plan: ExecutionPlan): String {
// Simple deterministic hash for testing
return plan.planId.hashCode().toString().padStart(64, '0').take(64)
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package carp.dsp.demo
import carp.dsp.demo.api.Demo
import carp.dsp.demo.demos.MinimalAuthorModelDemo
import carp.dsp.demo.demos.PlanDiagnosticsDemo
import carp.dsp.demo.demos.PlanningDemo


object DemoRegistry {
private val _demos: MutableList<Demo> = mutableListOf(
MinimalAuthorModelDemo,
PlanningDemo,
PlanDiagnosticsDemo,
)

val demos: List<Demo> get() = _demos
Expand Down
Loading
Loading