feat: add domain core and verification harness

This commit is contained in:
QiuSW
2026-08-04 23:28:10 +08:00
parent a82af6939f
commit bad67fa5a8
38 changed files with 2054 additions and 43 deletions
+27
View File
@@ -0,0 +1,27 @@
plugins {
alias(libs.plugins.kotlin.jvm)
}
kotlin {
jvmToolchain(17)
sourceSets {
main {
kotlin.srcDir("src/main/java")
}
test {
kotlin.srcDir("src/test/java")
}
}
}
dependencies {
testImplementation(libs.junit)
}
tasks.test {
useJUnit()
testLogging {
events("failed", "skipped")
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
}
}
@@ -0,0 +1,36 @@
package brainwave.core.model
data class HexagramContent(
val kingWenNumber: Int,
val name: String,
val symbol: String,
val judgmentOriginal: String,
val judgmentPlain: String,
val lineTextsBottomUp: List<String>,
val linePlainBottomUp: List<String>,
val specialUsageText: String?,
val sourceRefs: List<String>,
) {
init {
require(kingWenNumber in 1..64) { "King Wen number must be between 1 and 64" }
require(name.isNotBlank()) { "Hexagram name must not be blank" }
require(symbol.isNotBlank()) { "Hexagram symbol must not be blank" }
require(judgmentOriginal.isNotBlank()) { "Original judgment must not be blank" }
require(judgmentPlain.isNotBlank()) { "Plain judgment must not be blank" }
require(lineTextsBottomUp.size == 6 && lineTextsBottomUp.all(String::isNotBlank)) {
"Original line texts must contain six non-blank bottom-up entries"
}
require(linePlainBottomUp.size == 6 && linePlainBottomUp.all(String::isNotBlank)) {
"Plain line texts must contain six non-blank bottom-up entries"
}
require(specialUsageText == null || specialUsageText.isNotBlank()) {
"Special usage text must be non-blank or null"
}
require(sourceRefs.isNotEmpty() && sourceRefs.all(String::isNotBlank)) {
"Source references must contain at least one non-blank id"
}
require(sourceRefs.distinct().size == sourceRefs.size) {
"Source references must be unique"
}
}
}
@@ -0,0 +1,14 @@
package brainwave.data.content
import brainwave.core.model.HexagramContent
interface HexagramContentRepository {
val contentVersion: String
fun contentFor(
kingWenNumber: Int,
requestedContentVersion: String = contentVersion,
): HexagramContent
}
class ContentIntegrityException(message: String) : IllegalStateException(message)
@@ -0,0 +1,80 @@
package brainwave.domain.casting
data class CastComputation internal constructor(
val roundsBottomUp: List<CastRound>,
val lineValuesBottomUp: List<LineValue>,
val primaryPatternBottomUp: HexagramPattern,
val movingLinePositions: List<Int>,
val transformedPatternBottomUp: HexagramPattern,
val primaryHexagramId: HexagramId,
val transformedHexagramId: HexagramId,
)
object CastEngine {
const val METHOD_VERSION = "coin-v1"
const val COIN_CONVENTION = "character=2;reverse=3;bottom-up"
fun cast(roundsBottomUp: List<CastRound>): CastComputation {
require(roundsBottomUp.size == HexagramPattern.LINE_COUNT) {
"A complete cast must contain exactly six rounds, but contained ${roundsBottomUp.size}"
}
val rounds = roundsBottomUp.toList()
val lineValues = rounds.map(CastRound::lineValue)
val primaryPattern = HexagramPattern.of(lineValues.map(LineValue::polarity))
val movingPositions = lineValues.mapIndexedNotNull { index, lineValue ->
if (lineValue.isMoving) index + 1 else null
}
val transformedPattern = HexagramPattern.of(lineValues.map(LineValue::transformedPolarity))
return CastComputation(
roundsBottomUp = rounds,
lineValuesBottomUp = lineValues,
primaryPatternBottomUp = primaryPattern,
movingLinePositions = movingPositions,
transformedPatternBottomUp = transformedPattern,
primaryHexagramId = HexagramCatalog.idFor(primaryPattern),
transformedHexagramId = HexagramCatalog.idFor(transformedPattern),
)
}
}
data class CastMetadata(
val contentVersion: String,
val createdAt: String,
) {
init {
require(contentVersion.isNotBlank()) { "Content version must not be blank" }
require(createdAt.isNotBlank()) { "Created-at metadata must not be blank" }
}
}
data class CastResult private constructor(
val methodVersion: String,
val coinConvention: String,
val roundsBottomUp: List<CastRound>,
val lineValuesBottomUp: List<LineValue>,
val primaryPatternBottomUp: HexagramPattern,
val movingLinePositions: List<Int>,
val transformedPatternBottomUp: HexagramPattern,
val primaryHexagramId: HexagramId,
val transformedHexagramId: HexagramId,
val contentVersion: String,
val createdAt: String,
) {
companion object {
fun record(computation: CastComputation, metadata: CastMetadata): CastResult =
CastResult(
methodVersion = CastEngine.METHOD_VERSION,
coinConvention = CastEngine.COIN_CONVENTION,
roundsBottomUp = computation.roundsBottomUp.toList(),
lineValuesBottomUp = computation.lineValuesBottomUp.toList(),
primaryPatternBottomUp = computation.primaryPatternBottomUp,
movingLinePositions = computation.movingLinePositions.toList(),
transformedPatternBottomUp = computation.transformedPatternBottomUp,
primaryHexagramId = computation.primaryHexagramId,
transformedHexagramId = computation.transformedHexagramId,
contentVersion = metadata.contentVersion,
createdAt = metadata.createdAt,
)
}
}
@@ -0,0 +1,75 @@
package brainwave.domain.casting
data class CastRecordDto(
val schemaVersion: Int,
val methodVersion: String,
val coinConvention: String,
val roundsBottomUp: List<List<Int>>,
val lineValuesBottomUp: List<Int>,
val primaryPatternBottomUp: String,
val movingLinePositions: List<Int>,
val transformedPatternBottomUp: String,
val primaryHexagramId: Int,
val transformedHexagramId: Int,
val contentVersion: String,
val createdAt: String,
) {
fun toDomain(): CastResult {
require(schemaVersion == CURRENT_SCHEMA_VERSION) {
"Unsupported cast record schema version: $schemaVersion"
}
require(methodVersion == CastEngine.METHOD_VERSION) {
"Unsupported casting method version: $methodVersion"
}
require(coinConvention == CastEngine.COIN_CONVENTION) {
"Unsupported coin convention: $coinConvention"
}
val rounds = roundsBottomUp.map(CastRound::fromScores)
val result = CastResult.record(
computation = CastEngine.cast(rounds),
metadata = CastMetadata(contentVersion = contentVersion, createdAt = createdAt),
)
require(lineValuesBottomUp == result.lineValuesBottomUp.map(LineValue::score)) {
"Stored line values do not match the preserved coin rounds"
}
require(primaryPatternBottomUp == result.primaryPatternBottomUp.encoded()) {
"Stored primary pattern does not match the preserved coin rounds"
}
require(movingLinePositions == result.movingLinePositions) {
"Stored moving-line positions do not match the preserved coin rounds"
}
require(transformedPatternBottomUp == result.transformedPatternBottomUp.encoded()) {
"Stored transformed pattern does not match the preserved coin rounds"
}
require(primaryHexagramId == result.primaryHexagramId.value) {
"Stored primary hexagram id does not match the King Wen lookup"
}
require(transformedHexagramId == result.transformedHexagramId.value) {
"Stored transformed hexagram id does not match the King Wen lookup"
}
return result
}
companion object {
const val CURRENT_SCHEMA_VERSION = 1
fun fromDomain(result: CastResult): CastRecordDto =
CastRecordDto(
schemaVersion = CURRENT_SCHEMA_VERSION,
methodVersion = result.methodVersion,
coinConvention = result.coinConvention,
roundsBottomUp = result.roundsBottomUp.map { round -> round.coins.map(CoinSide::score) },
lineValuesBottomUp = result.lineValuesBottomUp.map(LineValue::score),
primaryPatternBottomUp = result.primaryPatternBottomUp.encoded(),
movingLinePositions = result.movingLinePositions.toList(),
transformedPatternBottomUp = result.transformedPatternBottomUp.encoded(),
primaryHexagramId = result.primaryHexagramId.value,
transformedHexagramId = result.transformedHexagramId.value,
contentVersion = result.contentVersion,
createdAt = result.createdAt,
)
}
}
@@ -0,0 +1,104 @@
package brainwave.domain.casting
enum class CoinSide(val score: Int) {
CHARACTER(2),
REVERSE(3),
;
companion object {
fun fromScore(score: Int): CoinSide =
entries.singleOrNull { it.score == score }
?: throw IllegalArgumentException("Coin score must be 2 or 3, but was $score")
}
}
enum class Polarity {
YIN,
YANG,
;
fun opposite(): Polarity = if (this == YIN) YANG else YIN
}
enum class LineValue(
val score: Int,
val polarity: Polarity,
val isMoving: Boolean,
) {
OLD_YIN(6, Polarity.YIN, true),
YOUNG_YANG(7, Polarity.YANG, false),
YOUNG_YIN(8, Polarity.YIN, false),
OLD_YANG(9, Polarity.YANG, true),
;
val transformedPolarity: Polarity
get() = if (isMoving) polarity.opposite() else polarity
companion object {
fun fromScore(score: Int): LineValue =
entries.singleOrNull { it.score == score }
?: throw IllegalArgumentException("Line score must be between 6 and 9, but was $score")
}
}
data class CastRound private constructor(val coins: List<CoinSide>) {
init {
require(coins.size == COINS_PER_ROUND) {
"Each cast round must contain exactly $COINS_PER_ROUND coins, but contained ${coins.size}"
}
}
val lineValue: LineValue
get() = LineValue.fromScore(coins.sumOf(CoinSide::score))
companion object {
const val COINS_PER_ROUND = 3
fun of(coins: List<CoinSide>): CastRound = CastRound(coins.toList())
fun of(first: CoinSide, second: CoinSide, third: CoinSide): CastRound =
of(listOf(first, second, third))
fun fromScores(scores: List<Int>): CastRound = of(scores.map(CoinSide::fromScore))
fun fromLineValue(lineValue: LineValue): CastRound =
when (lineValue) {
LineValue.OLD_YIN -> of(CoinSide.CHARACTER, CoinSide.CHARACTER, CoinSide.CHARACTER)
LineValue.YOUNG_YANG -> of(CoinSide.CHARACTER, CoinSide.CHARACTER, CoinSide.REVERSE)
LineValue.YOUNG_YIN -> of(CoinSide.CHARACTER, CoinSide.REVERSE, CoinSide.REVERSE)
LineValue.OLD_YANG -> of(CoinSide.REVERSE, CoinSide.REVERSE, CoinSide.REVERSE)
}
}
}
data class HexagramPattern private constructor(val linesBottomUp: List<Polarity>) {
init {
require(linesBottomUp.size == LINE_COUNT) {
"A hexagram must contain exactly $LINE_COUNT lines, but contained ${linesBottomUp.size}"
}
}
fun encoded(): String = linesBottomUp.joinToString(separator = "") {
if (it == Polarity.YANG) "1" else "0"
}
companion object {
const val LINE_COUNT = 6
fun of(linesBottomUp: List<Polarity>): HexagramPattern =
HexagramPattern(linesBottomUp.toList())
fun decode(encoded: String): HexagramPattern {
require(encoded.length == LINE_COUNT && encoded.all { it == '0' || it == '1' }) {
"Encoded hexagram must contain exactly six 0/1 characters"
}
return of(encoded.map { if (it == '1') Polarity.YANG else Polarity.YIN })
}
}
}
@JvmInline
value class HexagramId(val value: Int) {
init {
require(value in 1..64) { "King Wen hexagram id must be between 1 and 64" }
}
}
@@ -0,0 +1,104 @@
package brainwave.domain.casting
object HexagramCatalog {
private enum class Trigram(val patternBottomUp: String) {
QIAN("111"),
DUI("110"),
LI("101"),
ZHEN("100"),
XUN("011"),
KAN("010"),
GEN("001"),
KUN("000"),
;
companion object {
fun fromPattern(patternBottomUp: String): Trigram =
entries.singleOrNull { it.patternBottomUp == patternBottomUp }
?: error("Unknown trigram pattern: $patternBottomUp")
}
}
private data class TrigramPair(val upper: Trigram, val lower: Trigram)
private val kingWenIds = mapOf(
TrigramPair(Trigram.QIAN, Trigram.QIAN) to 1,
TrigramPair(Trigram.KUN, Trigram.KUN) to 2,
TrigramPair(Trigram.KAN, Trigram.ZHEN) to 3,
TrigramPair(Trigram.GEN, Trigram.KAN) to 4,
TrigramPair(Trigram.KAN, Trigram.QIAN) to 5,
TrigramPair(Trigram.QIAN, Trigram.KAN) to 6,
TrigramPair(Trigram.KUN, Trigram.KAN) to 7,
TrigramPair(Trigram.KAN, Trigram.KUN) to 8,
TrigramPair(Trigram.XUN, Trigram.QIAN) to 9,
TrigramPair(Trigram.QIAN, Trigram.DUI) to 10,
TrigramPair(Trigram.KUN, Trigram.QIAN) to 11,
TrigramPair(Trigram.QIAN, Trigram.KUN) to 12,
TrigramPair(Trigram.QIAN, Trigram.LI) to 13,
TrigramPair(Trigram.LI, Trigram.QIAN) to 14,
TrigramPair(Trigram.KUN, Trigram.GEN) to 15,
TrigramPair(Trigram.ZHEN, Trigram.KUN) to 16,
TrigramPair(Trigram.DUI, Trigram.ZHEN) to 17,
TrigramPair(Trigram.GEN, Trigram.XUN) to 18,
TrigramPair(Trigram.KUN, Trigram.DUI) to 19,
TrigramPair(Trigram.XUN, Trigram.KUN) to 20,
TrigramPair(Trigram.LI, Trigram.ZHEN) to 21,
TrigramPair(Trigram.GEN, Trigram.LI) to 22,
TrigramPair(Trigram.GEN, Trigram.KUN) to 23,
TrigramPair(Trigram.KUN, Trigram.ZHEN) to 24,
TrigramPair(Trigram.QIAN, Trigram.ZHEN) to 25,
TrigramPair(Trigram.GEN, Trigram.QIAN) to 26,
TrigramPair(Trigram.GEN, Trigram.ZHEN) to 27,
TrigramPair(Trigram.DUI, Trigram.XUN) to 28,
TrigramPair(Trigram.KAN, Trigram.KAN) to 29,
TrigramPair(Trigram.LI, Trigram.LI) to 30,
TrigramPair(Trigram.DUI, Trigram.GEN) to 31,
TrigramPair(Trigram.ZHEN, Trigram.XUN) to 32,
TrigramPair(Trigram.QIAN, Trigram.GEN) to 33,
TrigramPair(Trigram.ZHEN, Trigram.QIAN) to 34,
TrigramPair(Trigram.LI, Trigram.KUN) to 35,
TrigramPair(Trigram.KUN, Trigram.LI) to 36,
TrigramPair(Trigram.XUN, Trigram.LI) to 37,
TrigramPair(Trigram.LI, Trigram.DUI) to 38,
TrigramPair(Trigram.KAN, Trigram.GEN) to 39,
TrigramPair(Trigram.ZHEN, Trigram.KAN) to 40,
TrigramPair(Trigram.GEN, Trigram.DUI) to 41,
TrigramPair(Trigram.XUN, Trigram.ZHEN) to 42,
TrigramPair(Trigram.DUI, Trigram.QIAN) to 43,
TrigramPair(Trigram.QIAN, Trigram.XUN) to 44,
TrigramPair(Trigram.DUI, Trigram.KUN) to 45,
TrigramPair(Trigram.KUN, Trigram.XUN) to 46,
TrigramPair(Trigram.DUI, Trigram.KAN) to 47,
TrigramPair(Trigram.KAN, Trigram.XUN) to 48,
TrigramPair(Trigram.DUI, Trigram.LI) to 49,
TrigramPair(Trigram.LI, Trigram.XUN) to 50,
TrigramPair(Trigram.ZHEN, Trigram.ZHEN) to 51,
TrigramPair(Trigram.GEN, Trigram.GEN) to 52,
TrigramPair(Trigram.XUN, Trigram.GEN) to 53,
TrigramPair(Trigram.ZHEN, Trigram.DUI) to 54,
TrigramPair(Trigram.ZHEN, Trigram.LI) to 55,
TrigramPair(Trigram.LI, Trigram.GEN) to 56,
TrigramPair(Trigram.XUN, Trigram.XUN) to 57,
TrigramPair(Trigram.DUI, Trigram.DUI) to 58,
TrigramPair(Trigram.XUN, Trigram.KAN) to 59,
TrigramPair(Trigram.KAN, Trigram.DUI) to 60,
TrigramPair(Trigram.XUN, Trigram.DUI) to 61,
TrigramPair(Trigram.ZHEN, Trigram.GEN) to 62,
TrigramPair(Trigram.KAN, Trigram.LI) to 63,
TrigramPair(Trigram.LI, Trigram.KAN) to 64,
)
init {
check(kingWenIds.size == 64) { "King Wen table must contain all 64 trigram pairs" }
check(kingWenIds.values.toSet() == (1..64).toSet()) {
"King Wen table must contain every id from 1 through 64 exactly once"
}
}
fun idFor(pattern: HexagramPattern): HexagramId {
val encoded = pattern.encoded()
val lower = Trigram.fromPattern(encoded.substring(0, 3))
val upper = Trigram.fromPattern(encoded.substring(3, 6))
return HexagramId(kingWenIds.getValue(TrigramPair(upper, lower)))
}
}
@@ -0,0 +1,31 @@
package brainwave.data.content
import brainwave.core.model.HexagramContent
class FakeHexagramContentRepository(
override val contentVersion: String = "fixture-only-v1",
entries: List<HexagramContent>,
) : HexagramContentRepository {
private val entriesById: Map<Int, HexagramContent>
init {
require(contentVersion.isNotBlank()) { "Content version must not be blank" }
require(entries.map(HexagramContent::kingWenNumber).distinct().size == entries.size) {
"Fake content entries must have unique King Wen numbers"
}
entriesById = entries.associateBy(HexagramContent::kingWenNumber)
}
override fun contentFor(
kingWenNumber: Int,
requestedContentVersion: String,
): HexagramContent {
if (requestedContentVersion != contentVersion) {
throw ContentIntegrityException(
"Requested content version '$requestedContentVersion' is unavailable; loaded '$contentVersion'",
)
}
return entriesById[kingWenNumber]
?: throw ContentIntegrityException("No validated content for King Wen number $kingWenNumber")
}
}
@@ -0,0 +1,59 @@
package brainwave.data.content
import brainwave.core.model.HexagramContent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
class HexagramContentRepositoryTest {
@Test
fun `fake returns content only for its exact version and id`() {
val qian = fixture(1)
val repository = FakeHexagramContentRepository(entries = listOf(qian))
assertEquals(qian, repository.contentFor(1))
assertFails("unavailable") { repository.contentFor(1, "another-version") }
assertFails("No validated content") { repository.contentFor(2) }
}
@Test
fun `fake rejects duplicate ids instead of silently overwriting`() {
assertFails("unique King Wen numbers") {
FakeHexagramContentRepository(entries = listOf(fixture(1), fixture(1)))
}
}
@Test
fun `content model enforces six bottom-up texts and sources`() {
assertFails("six non-blank") {
fixture(1).copy(lineTextsBottomUp = List(5) { "fixture" })
}
assertFails("at least one") {
fixture(1).copy(sourceRefs = emptyList())
}
}
private fun fixture(id: Int) = HexagramContent(
kingWenNumber = id,
name = "fixture-$id",
symbol = "fixture-symbol-$id",
judgmentOriginal = "fixture original",
judgmentPlain = "fixture plain",
lineTextsBottomUp = List(6) { index -> "fixture original line ${index + 1}" },
linePlainBottomUp = List(6) { index -> "fixture plain line ${index + 1}" },
specialUsageText = null,
sourceRefs = listOf("fixture-source"),
)
private fun assertFails(messageFragment: String, block: () -> Unit) {
try {
block()
fail("Expected failure containing '$messageFragment'")
} catch (error: IllegalArgumentException) {
assertTrue(error.message.orEmpty().contains(messageFragment))
} catch (error: ContentIntegrityException) {
assertTrue(error.message.orEmpty().contains(messageFragment))
}
}
}
@@ -0,0 +1,200 @@
package brainwave.domain.casting
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
class CastEngineTest {
@Test
fun `all eight coin combinations map to the correct line values`() {
val counts = mutableMapOf<LineValue, Int>()
for (encoded in 0 until 8) {
val coins = List(3) { index ->
if (encoded and (1 shl index) == 0) CoinSide.CHARACTER else CoinSide.REVERSE
}
val expected = LineValue.fromScore(coins.sumOf(CoinSide::score))
val actual = CastRound.of(coins).lineValue
assertEquals(expected, actual)
counts[actual] = counts.getOrDefault(actual, 0) + 1
}
assertEquals(
mapOf(
LineValue.OLD_YIN to 1,
LineValue.YOUNG_YANG to 3,
LineValue.YOUNG_YIN to 3,
LineValue.OLD_YANG to 1,
),
counts,
)
}
@Test
fun `line values expose correct polarity movement and transformation`() {
assertLine(LineValue.OLD_YIN, Polarity.YIN, moving = true, Polarity.YANG)
assertLine(LineValue.YOUNG_YANG, Polarity.YANG, moving = false, Polarity.YANG)
assertLine(LineValue.YOUNG_YIN, Polarity.YIN, moving = false, Polarity.YIN)
assertLine(LineValue.OLD_YANG, Polarity.YANG, moving = true, Polarity.YIN)
}
@Test
fun `a round must preserve exactly three coins`() {
assertFails("exactly 3 coins") { CastRound.of(emptyList()) }
assertFails("exactly 3 coins") {
CastRound.of(List(4) { CoinSide.CHARACTER })
}
assertEquals(
listOf(2, 3, 2),
CastRound.of(CoinSide.CHARACTER, CoinSide.REVERSE, CoinSide.CHARACTER)
.coins
.map(CoinSide::score),
)
}
@Test
fun `a cast requires exactly six rounds`() {
assertFails("exactly six rounds") {
CastEngine.cast(List(5) { roundFor(LineValue.YOUNG_YANG) })
}
assertFails("exactly six rounds") {
CastEngine.cast(List(7) { roundFor(LineValue.YOUNG_YANG) })
}
}
@Test
fun `documented fixtures preserve bottom-up order and King Wen ids`() {
assertFixture(listOf(7, 7, 7, 7, 7, 7), primary = 1, transformed = 1, moving = emptyList())
assertFixture(listOf(8, 8, 8, 8, 8, 8), primary = 2, transformed = 2, moving = emptyList())
assertFixture(listOf(9, 9, 9, 9, 9, 9), primary = 1, transformed = 2, moving = (1..6).toList())
assertFixture(listOf(6, 6, 6, 6, 6, 6), primary = 2, transformed = 1, moving = (1..6).toList())
assertFixture(listOf(7, 8, 8, 8, 8, 8), primary = 24, transformed = 24, moving = emptyList())
assertFixture(listOf(9, 8, 8, 8, 8, 8), primary = 24, transformed = 2, moving = listOf(1))
}
@Test
fun `all 4096 line sequences compute consistently`() {
val primaryIds = mutableSetOf<Int>()
for (encoded in 0 until 4096) {
val lineValues = List(6) { position ->
LineValue.entries[(encoded shr (position * 2)) and 0b11]
}
val result = CastEngine.cast(lineValues.map(::roundFor))
assertEquals(lineValues, result.lineValuesBottomUp)
assertEquals(lineValues.map(LineValue::polarity), result.primaryPatternBottomUp.linesBottomUp)
assertEquals(
lineValues.map(LineValue::transformedPolarity),
result.transformedPatternBottomUp.linesBottomUp,
)
assertEquals(
lineValues.mapIndexedNotNull { index, line -> if (line.isMoving) index + 1 else null },
result.movingLinePositions,
)
assertTrue(result.primaryHexagramId.value in 1..64)
assertTrue(result.transformedHexagramId.value in 1..64)
primaryIds += result.primaryHexagramId.value
}
assertEquals((1..64).toSet(), primaryIds)
}
@Test
fun `all 64 polarity patterns map one-to-one to King Wen ids`() {
val ids = (0 until 64).map { encoded ->
val pattern = HexagramPattern.of(
List(6) { position ->
if (encoded and (1 shl position) == 0) Polarity.YIN else Polarity.YANG
},
)
HexagramCatalog.idFor(pattern).value
}
assertEquals(64, ids.toSet().size)
assertEquals((1..64).toSet(), ids.toSet())
}
@Test
fun `record metadata is separate from deterministic computation`() {
val rounds = listOf(9, 8, 8, 8, 8, 8).map(::roundFor)
val computation = CastEngine.cast(rounds)
val first = CastResult.record(computation, CastMetadata("content-test", "2026-01-01T00:00:00Z"))
val second = CastResult.record(computation, CastMetadata("content-test", "2026-02-01T00:00:00Z"))
assertNotEquals(first.createdAt, second.createdAt)
assertEquals(first.roundsBottomUp, second.roundsBottomUp)
assertEquals(first.lineValuesBottomUp, second.lineValuesBottomUp)
assertEquals(first.primaryHexagramId, second.primaryHexagramId)
assertEquals(first.transformedHexagramId, second.transformedHexagramId)
}
@Test
fun `versioned dto round-trips all preserved coins and derived values`() {
val computation = CastEngine.cast(listOf(6, 7, 8, 9, 7, 8).map(::roundFor))
val original = CastResult.record(
computation,
CastMetadata(contentVersion = "fixture-v1", createdAt = "2026-08-04T12:00:00+08:00"),
)
val restored = CastRecordDto.fromDomain(original).toDomain()
assertEquals(original, restored)
assertEquals(18, restored.roundsBottomUp.sumOf { it.coins.size })
}
@Test
fun `versioned dto rejects corrupted derived values`() {
val original = CastResult.record(
CastEngine.cast(List(6) { roundFor(LineValue.YOUNG_YANG) }),
CastMetadata(contentVersion = "fixture-v1", createdAt = "2026-08-04T12:00:00+08:00"),
)
val corrupted = CastRecordDto.fromDomain(original).copy(primaryHexagramId = 2)
assertFails("does not match") { corrupted.toDomain() }
}
private fun assertFixture(
scoresBottomUp: List<Int>,
primary: Int,
transformed: Int,
moving: List<Int>,
) {
val result = CastEngine.cast(scoresBottomUp.map(::roundFor))
assertEquals(primary, result.primaryHexagramId.value)
assertEquals(transformed, result.transformedHexagramId.value)
assertEquals(moving, result.movingLinePositions)
assertEquals(scoresBottomUp, result.lineValuesBottomUp.map(LineValue::score))
}
private fun assertLine(
lineValue: LineValue,
polarity: Polarity,
moving: Boolean,
transformed: Polarity,
) {
assertEquals(polarity, lineValue.polarity)
assertEquals(moving, lineValue.isMoving)
assertEquals(transformed, lineValue.transformedPolarity)
if (moving) assertNotEquals(polarity, transformed) else assertEquals(polarity, transformed)
}
private fun roundFor(score: Int): CastRound = roundFor(LineValue.fromScore(score))
private fun roundFor(lineValue: LineValue): CastRound = CastRound.fromLineValue(lineValue)
private fun assertFails(messageFragment: String, block: () -> Unit) {
try {
block()
fail("Expected IllegalArgumentException containing '$messageFragment'")
} catch (error: IllegalArgumentException) {
assertTrue(
"Expected '${error.message}' to contain '$messageFragment'",
error.message.orEmpty().contains(messageFragment),
)
}
}
}