diff options
| author | ketsuban <ketsuban@kukuri.dev> | 2026-08-27 17:14:17 +0000 |
|---|---|---|
| committer | ketsuban <ketsuban@kukuri.dev> | 2026-08-27 17:14:17 +0000 |
| commit | 9610425ea46ae92f8caebf76b975ca17d23e115f (patch) | |
| tree | 00252b60df2e557d0b05682f66eba3ad1fb08bbf | |
| parent | 54e1397fbbfc7e82074fceaabaf9b64a01f06324 (diff) | |
| download | kukuri-9610425ea46ae92f8caebf76b975ca17d23e115f.tar.gz kukuri-9610425ea46ae92f8caebf76b975ca17d23e115f.tar.bz2 kukuri-9610425ea46ae92f8caebf76b975ca17d23e115f.zip | |
Implement 1st Division (FSMP) + 2nd Division (UI) modules
- fsmp-core: ch.14/15 pipeline (ChunkMind/OB-SCA-V/Waterline/PagePriority/
SSS/FluidInjection/SeparatorAnchor/Billiard/ACO/ChunkCache/BottomPool/
RepairPriority), frame codec+fragmenter/reassembler/replay guard,
role-grouped messages, topology (partial mesh), runtime (event bus/
scheduler/state machine/coordinator), consistency policies; 10 tests
- fsmp-transport: ch.16/17 transport API, signaling envelope/client,
WebRTC port model with backpressure controller, ICE candidate mapper,
route feedback, QR bootstrap encode/validate; tests
- fsmp-relay: ch.18 selective chunk forwarder (cache/index/manifest
verifier/ACK aggregator/repair coordinator/sessions) + jvm server
skeleton + koin module
- orgflow-ui: ch.4-10 Compose MP app scaffold (nav/theme/screens/
components), 5W1H capture form, agenda, experiment table + graph tab +
GUI-Lisp editor/autocomplete/error hint/preview, presentation builder,
distribution waterline board, workspace QR invite, onboarding
- orgflow-capture: 5W1H data-driven templates + service + commit
coordinator (agent), orgflow-experiment: table model + GUI-Lisp
interpreter with defun limits + chart compiler (agent)
- orgflow-tree/git-poa/zero-bridge-wasm remainders filled
- Fix lisp map/filter/reduce raw function args, group-count key quoting,
capture schedule detection
251 files changed, 8816 insertions, 340 deletions
diff --git a/modules/fsmp-core/build.gradle.kts b/modules/fsmp-core/build.gradle.kts index bee562c..cb5bb16 100644 --- a/modules/fsmp-core/build.gradle.kts +++ b/modules/fsmp-core/build.gradle.kts @@ -1,6 +1,31 @@ -// Placeholder module (spec ch.24 tree). Division owning this chapter upgrades -// this file to a KMP config when implementation starts. See NEMOTRON.md. -plugins { `base` } +import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) +} group = "net.kukuri" version = "0.1.0" + +kotlin { + jvm() + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { nodejs() } + + linuxX64() + + sourceSets { + commonMain.dependencies { + implementation(project(":modules:orgflow-domain")) + implementation(libs.serialization.json) + implementation(libs.okio) + implementation(libs.coroutines.core) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.coroutines.test) + } + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/FsmpConfiguration.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/FsmpConfiguration.kt index 9b1fad7..1ef2614 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/FsmpConfiguration.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/FsmpConfiguration.kt @@ -1,3 +1,43 @@ package jp.orgflow.fsmp -// TODO(spec ch.14-18): implement FsmpConfiguration per docs/spec.md +import jp.orgflow.fsmp.aco.AcoRouteOptimizer +import jp.orgflow.fsmp.aco.PheromoneTable +import jp.orgflow.fsmp.frame.FsmpFragmenter +import jp.orgflow.fsmp.topology.PartialMeshPolicy + +data class FsmpConfiguration( + val frameSizeBytes: Int = 16384, + val ackGranularityChunks: Int = 8, + val repairGranularityChunks: Int = 4, + val retransmitMax: Int = 3, + val replayWindow: Int = 256, + val versionMajor: Int = 1, + val versionMinor: Int = 0, + val usableTargetRatio: Double = 0.9, + val deadlineSlackMs: Long = 5000, + val starvationCapPriorityBoost: Double = 0.2, + val acoEvaporationRate: Double = 0.05, + val acoDepositFactor: Double = 1.0, + val acoExplorationRatio: Double = 0.1, + val billiardCollisionElasticity: Double = 0.9, + val maxDirectPeers: Int = 8, + val relayCacheEntriesMax: Int = 1024, + val perPeerBandwidthKbps: Int = 512, + val bottomPoolCapacityChunks: Int = 256, +) { + fun fragmenter(): FsmpFragmenter = FsmpFragmenter(frameSizeBytes, retransmitMax) + + fun meshPolicy(): PartialMeshPolicy = PartialMeshPolicy( + maxDirectPeers = maxDirectPeers, + perPeerBandwidthKbps = perPeerBandwidthKbps, + ) + + fun routeOptimizer( + pheromones: PheromoneTable = PheromoneTable(acoEvaporationRate, acoDepositFactor), + random: () -> Double = { 0.5 }, + ): AcoRouteOptimizer = AcoRouteOptimizer(pheromones, acoExplorationRatio, random) + + companion object { + val DEFAULT = FsmpConfiguration() + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/FsmpEngine.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/FsmpEngine.kt index 2b5377c..e881728 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/FsmpEngine.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/FsmpEngine.kt @@ -1,3 +1,53 @@ package jp.orgflow.fsmp -// TODO(spec ch.14-18): implement FsmpEngine per docs/spec.md +import jp.orgflow.fsmp.bottompool.BottomPool +import jp.orgflow.fsmp.chunkmind.ChunkMind +import jp.orgflow.fsmp.fluid.FluidInjectionPlanner +import jp.orgflow.fsmp.pagepriority.PagePriorityBooster +import jp.orgflow.fsmp.pipeline.CandidateSource +import jp.orgflow.fsmp.pipeline.CandidateScorer +import jp.orgflow.fsmp.pipeline.DecisionContext +import jp.orgflow.fsmp.pipeline.SelectionPolicy +import jp.orgflow.fsmp.pipeline.TransferPlan +import jp.orgflow.fsmp.repair.RepairPriorityPlanner +import jp.orgflow.fsmp.runtime.FsmpDecisionLogger +import jp.orgflow.fsmp.runtime.FsmpEventBus +import jp.orgflow.fsmp.runtime.FsmpScheduler +import jp.orgflow.fsmp.runtime.FsmpTransferCoordinator +import jp.orgflow.fsmp.saturation.SmartSaturatingSelector +import jp.orgflow.fsmp.scoring.ObScaVScorer +import jp.orgflow.fsmp.waterline.WaterlineDefinition +import jp.orgflow.fsmp.waterline.WaterlineEvaluator +import kotlinx.coroutines.CoroutineScope + +class FsmpEngine( + val configuration: FsmpConfiguration = FsmpConfiguration.DEFAULT, + val eventBus: FsmpEventBus = FsmpEventBus(), + val decisionLogger: FsmpDecisionLogger = FsmpDecisionLogger(), + source: CandidateSource = ChunkMind(WaterlineEvaluator(WaterlineDefinition(usableTargetRatio = configuration.usableTargetRatio))), + scorer: CandidateScorer = ObScaVScorer(), + policies: List<SelectionPolicy> = listOf( + RepairPriorityPlanner(), + SmartSaturatingSelector(), + BottomPool(capacityChunks = configuration.bottomPoolCapacityChunks), + ), +) { + private val booster = PagePriorityBooster(configuration.starvationCapPriorityBoost) + private val planner = FluidInjectionPlanner( + budgetBytesPerTick = configuration.perPeerBandwidthKbps.toLong() * 128, + ) + private val coordinator = FsmpTransferCoordinator(source, scorer, booster, policies, planner, decisionLogger, eventBus) + + fun plan(context: DecisionContext): TransferPlan = coordinator.runTick(context) + + fun startScheduler(scope: CoroutineScope, intervalMs: Long = 1000, onTick: suspend () -> Unit): FsmpScheduler { + val scheduler = FsmpScheduler(scope, intervalMs) + scheduler.start { onTick() } + return scheduler + } + + companion object { + fun createDefault(configuration: FsmpConfiguration = FsmpConfiguration.DEFAULT): FsmpEngine = + FsmpEngine(configuration = configuration) + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/aco/AcoRouteOptimizer.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/aco/AcoRouteOptimizer.kt index ac20dfd..7360208 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/aco/AcoRouteOptimizer.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/aco/AcoRouteOptimizer.kt @@ -1,3 +1,36 @@ package jp.orgflow.fsmp.aco -// TODO(spec ch.14-18): implement AcoRouteOptimizer per docs/spec.md +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.fsmp.pipeline.TransferCandidateKind + +data class RouteObservation( + val from: PeerId, + val to: PeerId, + val success: Boolean, + val rttMs: Double = 200.0, + val kind: TransferCandidateKind = TransferCandidateKind.NORMAL, +) + +class AcoRouteOptimizer( + private val pheromones: PheromoneTable, + private val explorationRatio: Double = 0.1, + private val random: () -> Double = { 0.5 }, +) { + fun observe(observation: RouteObservation) { + if (observation.success) { + val speedBonus = (1.0 - (observation.rttMs / 1000.0).coerceIn(0.0, 1.0)) + pheromones.deposit(observation.from, observation.to, 1.0 + speedBonus) + } else { + pheromones.penalize(observation.from, observation.to, 1.0) + } + } + + fun score(from: PeerId, to: PeerId): Double { + val exploitation = (1.0 - explorationRatio) * pheromones.pheromone(from, to) + val exploration = explorationRatio * random() + return exploitation + exploration + } + + fun bestRoute(from: PeerId, candidates: List<PeerId>): PeerId? = + candidates.filter { it != from }.maxByOrNull { score(from, it) } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/aco/BilliardReflector.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/aco/BilliardReflector.kt index 5a1c0a9..8ad432d 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/aco/BilliardReflector.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/aco/BilliardReflector.kt @@ -1,3 +1,41 @@ package jp.orgflow.fsmp.aco -// TODO(spec ch.14-18): implement BilliardReflector per docs/spec.md +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.fsmp.anchor.Boundary +import jp.orgflow.fsmp.explain.DecisionFactor +import jp.orgflow.fsmp.explain.DecisionTrace +import jp.orgflow.fsmp.pipeline.TransferCandidate +import jp.orgflow.fsmp.pipeline.TransferCandidateKind + +class BilliardReflector( + private val elasticity: Double = 0.9, +) { + fun reflect( + candidate: TransferCandidate, + boundary: Boundary, + alternatives: List<PeerId>, + optimizer: AcoRouteOptimizer, + ): RerouteResult? { + val nextPeer = optimizer.bestRoute( + boundary.peerId, + alternatives.filter { it != boundary.peerId && it != candidate.targetPeerId }, + ) ?: return null + val rerouted = candidate.copy( + candidateId = candidate.candidateId + ":r", + kind = TransferCandidateKind.REROUTED, + targetPeerId = nextPeer, + priority = candidate.priority * elasticity, + ) + val factor = DecisionFactor("billiard_reroute", 1.0, elasticity, elasticity) + val trace = DecisionTrace( + candidateId = rerouted.candidateId, + factors = listOf(factor), + finalScore = rerouted.priority, + selected = true, + reason = "boundary=${boundary.type} peer=${boundary.peerId.value} rerouted->${nextPeer.value}", + ) + return RerouteResult(rerouted, trace) + } + + data class RerouteResult(val candidate: TransferCandidate, val trace: DecisionTrace) +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/aco/PheromoneTable.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/aco/PheromoneTable.kt index e2b2a37..890411b 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/aco/PheromoneTable.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/aco/PheromoneTable.kt @@ -1,3 +1,35 @@ package jp.orgflow.fsmp.aco -// TODO(spec ch.14-18): implement PheromoneTable per docs/spec.md +import jp.orgflow.domain.identity.PeerId + +class PheromoneTable( + private val evaporationRate: Double = 0.05, + private val depositFactor: Double = 1.0, + private val initialValue: Double = 1.0, +) { + private val routes = mutableMapOf<String, Double>() + + private fun key(from: PeerId, to: PeerId): String = "${from.value}>${to.value}" + + fun deposit(from: PeerId, to: PeerId, amount: Double) { + val k = key(from, to) + routes[k] = (routes[k] ?: initialValue) + depositFactor * amount + } + + fun penalize(from: PeerId, to: PeerId, amount: Double) { + val k = key(from, to) + routes[k] = ((routes[k] ?: initialValue) - depositFactor * amount).coerceAtLeast(minPheromone) + } + + fun evaporate() { + for ((k, v) in routes) routes[k] = v * (1.0 - evaporationRate) + } + + fun pheromone(from: PeerId, to: PeerId): Double = routes[key(from, to)] ?: initialValue + + fun routeCount(): Int = routes.size + + companion object { + const val minPheromone = 0.01 + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/anchor/SeparatorAnchorResolver.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/anchor/SeparatorAnchorResolver.kt index 7479a80..0dc8240 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/anchor/SeparatorAnchorResolver.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/anchor/SeparatorAnchorResolver.kt @@ -1,3 +1,63 @@ package jp.orgflow.fsmp.anchor -// TODO(spec ch.14-18): implement SeparatorAnchorResolver per docs/spec.md +import jp.orgflow.domain.identity.ChunkId +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.fsmp.waterline.Waterline + +enum class SemanticUnitKind { SLIDE, TABLE, GRAPH, HEADING, PACK } + +data class SemanticUnit( + val unitId: String, + val kind: SemanticUnitKind, + val chunkIds: List<ChunkId>, + val targetWaterline: Waterline = Waterline.USABLE, +) { + fun completion(receivedChunkIds: Set<ChunkId>): Double { + if (chunkIds.isEmpty()) return 1.0 + val hit = chunkIds.count { it in receivedChunkIds } + return hit.toDouble() / chunkIds.size + } +} + +enum class BoundaryType { CONGESTION, WEAK_LINK, REPAIR_WAIT } + +data class Boundary( + val type: BoundaryType, + val peerId: PeerId, + val unitId: String? = null, + val detectedAtMs: Long, + val severity: Double, +) + +data class PeerSignal( + val peerId: PeerId, + val bufferedAmountBytes: Long, + val rttMs: Double, + val lossRatio: Double, + val pendingRepairs: Int, +) + +class SeparatorAnchorResolver( + private val congestionBufferedBytes: Long = 1L shl 20, + private val congestionRttMs: Double = 800.0, + private val weakLinkLossRatio: Double = 0.25, + private val repairWaitThreshold: Int = 8, +) { + fun detectBoundaries(signals: List<PeerSignal>, nowMs: Long): List<Boundary> { + val boundaries = mutableListOf<Boundary>() + for (s in signals) { + if (s.bufferedAmountBytes >= congestionBufferedBytes || s.rttMs >= congestionRttMs) { + boundaries += Boundary(BoundaryType.CONGESTION, s.peerId, null, nowMs, 1.0) + } + if (s.lossRatio >= weakLinkLossRatio) { + boundaries += Boundary(BoundaryType.WEAK_LINK, s.peerId, null, nowMs, s.lossRatio.coerceAtMost(1.0)) + } + if (s.pendingRepairs >= repairWaitThreshold) { + boundaries += Boundary(BoundaryType.REPAIR_WAIT, s.peerId, null, nowMs, s.pendingRepairs / 64.0) + } + } + return boundaries + } + + fun anchorCompletion(unit: SemanticUnit, receivedChunkIds: Set<ChunkId>): Double = unit.completion(receivedChunkIds) +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/bottompool/BottomPool.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/bottompool/BottomPool.kt index 6be6bf9..524e723 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/bottompool/BottomPool.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/bottompool/BottomPool.kt @@ -1,3 +1,37 @@ package jp.orgflow.fsmp.bottompool -// TODO(spec ch.14-18): implement BottomPool per docs/spec.md +import jp.orgflow.fsmp.pipeline.ScoredCandidate +import jp.orgflow.fsmp.pipeline.SelectionPolicy +import jp.orgflow.fsmp.pipeline.DecisionContext + +class BottomPool( + private val capacityChunks: Int = 256, + private val largeChunkBytes: Int = 1 shl 20, +) : SelectionPolicy { + + private val deferred = LinkedHashSet<String>() + + override val name: String = "bottom-pool" + + override fun apply(scored: List<ScoredCandidate>, context: DecisionContext): List<ScoredCandidate> { + for (sc in scored) { + if (isLowPriority(sc)) { + if (deferred.size < capacityChunks) deferred += sc.candidate.candidateId + } else { + deferred.remove(sc.candidate.candidateId) + } + } + return scored.filter { it.candidate.candidateId !in deferred || it.candidate.kind == jp.orgflow.fsmp.pipeline.TransferCandidateKind.REPAIR } + .map { if (it.candidate.candidateId in deferred) it.copy(reason = "bottom-pool(deferred)") else it } + } + + fun isLowPriority(sc: ScoredCandidate): Boolean = sc.candidate.sizeBytes >= largeChunkBytes + + fun isDeferred(candidateId: String): Boolean = candidateId in deferred + + fun deferredCount(): Int = deferred.size + + fun restore(candidateId: String) { + deferred.remove(candidateId) + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/cache/ChunkCacheStore.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/cache/ChunkCacheStore.kt index fd55cfb..271a01a 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/cache/ChunkCacheStore.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/cache/ChunkCacheStore.kt @@ -1,3 +1,38 @@ package jp.orgflow.fsmp.cache -// TODO(spec ch.14-18): implement ChunkCacheStore per docs/spec.md +import jp.orgflow.domain.identity.ChunkId + +data class CachedChunk( + val chunkId: ChunkId, + val hash: String, + val sizeBytes: Int, + val storedAtMs: Long, + val payload: ByteArray? = null, +) + +class ChunkCacheStore(private val maxEntries: Int = 1024) { + private val entries = LinkedHashMap<ChunkId, CachedChunk>() + + fun put(chunk: CachedChunk) { + entries.remove(chunk.chunkId) + entries[chunk.chunkId] = chunk + trim() + } + + operator fun get(chunkId: ChunkId): CachedChunk? = entries[chunkId] + + fun reuse(chunkId: ChunkId, expectedHash: String): CachedChunk? { + val entry = entries[chunkId] ?: return null + return if (entry.hash == expectedHash) entry else null + } + + fun size(): Int = entries.size + + private fun trim() { + val iter = entries.entries.iterator() + while (entries.size > maxEntries && iter.hasNext()) { + iter.next() + iter.remove() + } + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/chunkmind/ChunkMind.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/chunkmind/ChunkMind.kt index cdc8a58..9a6dac1 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/chunkmind/ChunkMind.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/chunkmind/ChunkMind.kt @@ -1,3 +1,68 @@ package jp.orgflow.fsmp.chunkmind -// TODO(spec ch.14-18): implement ChunkMind per docs/spec.md +import jp.orgflow.domain.identity.ChunkId +import jp.orgflow.fsmp.anchor.SemanticUnit +import jp.orgflow.fsmp.pipeline.CandidateSource +import jp.orgflow.fsmp.pipeline.DecisionContext +import jp.orgflow.fsmp.pipeline.TransferCandidate +import jp.orgflow.fsmp.pipeline.TransferCandidateKind +import jp.orgflow.fsmp.waterline.Waterline +import jp.orgflow.fsmp.waterline.WaterlineEvaluator + +class ChunkMind( + private val evaluator: WaterlineEvaluator = WaterlineEvaluator(), + private val maxCandidatesPerTick: Int = 64, +) : CandidateSource { + + override fun generate(context: DecisionContext): List<TransferCandidate> { + val candidates = mutableListOf<TransferCandidate>() + val congestionPeers = context.boundaries.map { it.peerId }.toSet() + + for (state in context.waterlineStates) { + val peer = context.peers.firstOrNull { it.peerId == state.peerId } ?: continue + val pendingRepairs = context.pendingRepairIndexes[state.packId.value] ?: emptySet() + if (pendingRepairs.isEmpty() && !evaluator.belowTarget(state, peer.capabilities.waterlineTarget)) continue + val unitOf = unitIndexByChunk(context.semanticUnits) + var seq = 0 + for (index in 0 until state.totalChunks) { + if (state.receivedChunks > index && index !in pendingRepairs) continue + if (candidates.size >= maxCandidatesPerTick) break + val chunkId = ChunkId("${state.packId.value}:$index") + val unit = unitOf[chunkId] + val completion = unit?.completion(context.receivedChunkIds) ?: state.receivedRatio + val kind = when { + index in pendingRepairs -> TransferCandidateKind.REPAIR + chunkId in context.cachedChunkIds -> TransferCandidateKind.CACHE_REUSE + completion >= saturationThreshold -> TransferCandidateKind.SATURATION + else -> TransferCandidateKind.NORMAL + } + candidates += TransferCandidate( + candidateId = "c-${state.packId.value}-${state.peerId.value}-$index-${seq++}", + kind = kind, + packId = state.packId.value, + chunkId = chunkId, + chunkIndex = index, + targetPeerId = state.peerId, + priority = 1.0 - completion, + sizeBytes = 16384, + semanticUnitId = unit?.unitId, + anchorCompletion = completion, + usableContribution = 1.0 / state.totalChunks.coerceAtLeast(1), + createdAtMs = context.nowMs, + ) + } + } + + return candidates.filter { it.targetPeerId !in congestionPeers || it.kind == TransferCandidateKind.REPAIR } + } + + private fun unitIndexByChunk(units: List<SemanticUnit>): Map<ChunkId, SemanticUnit> { + val map = mutableMapOf<ChunkId, SemanticUnit>() + for (u in units) for (c in u.chunkIds) map[c] = u + return map + } + + companion object { + const val saturationThreshold = 0.75 + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/consistency/CommitConsistencyPolicy.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/consistency/CommitConsistencyPolicy.kt index c1011e9..09c3c54 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/consistency/CommitConsistencyPolicy.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/consistency/CommitConsistencyPolicy.kt @@ -1,3 +1,17 @@ package jp.orgflow.fsmp.consistency -// TODO(spec ch.14-18): implement CommitConsistencyPolicy per docs/spec.md +enum class CommitConsistencyResult { CONSISTENT, MISMATCH, UNKNOWN } + +class CommitConsistencyPolicy { + + fun verify(manifestHash: String?, commitManifestHash: String?): CommitConsistencyResult { + if (manifestHash == null || commitManifestHash == null) return CommitConsistencyResult.UNKNOWN + return if (manifestHash == commitManifestHash) CommitConsistencyResult.CONSISTENT else CommitConsistencyResult.MISMATCH + } + + fun verifyPack(packManifestHash: String, advertisedHashes: List<String>): CommitConsistencyResult { + if (advertisedHashes.isEmpty()) return CommitConsistencyResult.UNKNOWN + return if (advertisedHashes.all { it == packManifestHash }) CommitConsistencyResult.CONSISTENT + else CommitConsistencyResult.MISMATCH + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/consistency/CommitConsistencyResult.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/consistency/CommitConsistencyResult.kt index 9862379..c4e4d21 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/consistency/CommitConsistencyResult.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/consistency/CommitConsistencyResult.kt @@ -1,3 +1,18 @@ package jp.orgflow.fsmp.consistency -// TODO(spec ch.14-18): implement CommitConsistencyResult per docs/spec.md +import jp.orgflow.fsmp.error.FsmpError +import jp.orgflow.fsmp.error.FsmpErrorCode + +data class CommitConsistencyOutcome( + val result: CommitConsistencyResult, + val error: FsmpError? = null, +) { + companion object { + fun consistent() = CommitConsistencyOutcome(CommitConsistencyResult.CONSISTENT) + fun mismatch(expected: String, actual: String) = CommitConsistencyOutcome( + CommitConsistencyResult.MISMATCH, + FsmpError(FsmpErrorCode.COMMIT_STALE, "expected=$expected actual=$actual"), + ) + fun unknown() = CommitConsistencyOutcome(CommitConsistencyResult.UNKNOWN) + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/consistency/StaleCommitPolicy.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/consistency/StaleCommitPolicy.kt index 71058c2..db0fa20 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/consistency/StaleCommitPolicy.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/consistency/StaleCommitPolicy.kt @@ -1,3 +1,10 @@ package jp.orgflow.fsmp.consistency -// TODO(spec ch.14-18): implement StaleCommitPolicy per docs/spec.md +class StaleCommitPolicy( + private val maxAgeMs: Long = 24L * 60 * 60 * 1000, +) { + fun isStale(observedAtMs: Long, nowMs: Long): Boolean = nowMs - observedAtMs > maxAgeMs + + fun filterStale(observations: List<Pair<String, Long>>, nowMs: Long): List<String> = + observations.filter { isStale(it.second, nowMs) }.map { it.first } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/error/FsmpError.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/error/FsmpError.kt index ef25bad..e6cabe1 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/error/FsmpError.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/error/FsmpError.kt @@ -1,3 +1,9 @@ package jp.orgflow.fsmp.error -// TODO(spec ch.14-18): implement FsmpError per docs/spec.md +data class FsmpError( + val code: FsmpErrorCode, + val detail: String, +) { + override fun toString(): String = + "FsmpError(0x" + code.code.toString(16).padStart(2, '0') + " " + code.name + ": " + detail + ")" +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/error/FsmpErrorCode.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/error/FsmpErrorCode.kt index f185470..28c5ec5 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/error/FsmpErrorCode.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/error/FsmpErrorCode.kt @@ -1,3 +1,12 @@ package jp.orgflow.fsmp.error -// TODO(spec ch.14-18): implement FsmpErrorCode per docs/spec.md +enum class FsmpErrorCode(val code: Int, val action: String) { + VERSION_MISMATCH(0x01, "drop + Status(nack)"), + HASH_VERIFY_FAILED(0x02, "discard, request repair"), + REPLAY_DETECTED(0x03, "drop"), + FRAME_MALFORMED(0x04, "drop"), + FRAGMENT_INCOMPLETE(0x05, "await or repair"), + BUDGET_EXCEEDED(0x06, "defer to next tick"), + PEER_UNREACHABLE(0x07, "reroute via Billiard/ACO"), + COMMIT_STALE(0x08, "ignore observation"), +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/error/FsmpProtocolException.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/error/FsmpProtocolException.kt index 9febb77..55c0fc9 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/error/FsmpProtocolException.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/error/FsmpProtocolException.kt @@ -1,3 +1,3 @@ package jp.orgflow.fsmp.error -// TODO(spec ch.14-18): implement FsmpProtocolException per docs/spec.md +class FsmpProtocolException(val error: FsmpError) : Exception(error.toString()) diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/explain/DecisionFactor.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/explain/DecisionFactor.kt index 57c42f1..7d0ea9b 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/explain/DecisionFactor.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/explain/DecisionFactor.kt @@ -1,3 +1,8 @@ package jp.orgflow.fsmp.explain -// TODO(spec ch.14-18): implement DecisionFactor per docs/spec.md +data class DecisionFactor( + val key: String, + val rawValue: Double, + val weight: Double, + val contribution: Double, +) diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/explain/DecisionTrace.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/explain/DecisionTrace.kt index b01a347..f8d590c 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/explain/DecisionTrace.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/explain/DecisionTrace.kt @@ -1,3 +1,9 @@ package jp.orgflow.fsmp.explain -// TODO(spec ch.14-18): implement DecisionTrace per docs/spec.md +data class DecisionTrace( + val candidateId: String, + val factors: List<DecisionFactor>, + val finalScore: Double, + val selected: Boolean, + val reason: String, +) diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/fluid/FluidInjectionPlanner.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/fluid/FluidInjectionPlanner.kt index cd1b8fa..4faa051 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/fluid/FluidInjectionPlanner.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/fluid/FluidInjectionPlanner.kt @@ -1,3 +1,38 @@ package jp.orgflow.fsmp.fluid -// TODO(spec ch.14-18): implement FluidInjectionPlanner per docs/spec.md +import jp.orgflow.fsmp.explain.DecisionTrace +import jp.orgflow.fsmp.pipeline.DecisionContext +import jp.orgflow.fsmp.pipeline.ScoredCandidate +import jp.orgflow.fsmp.pipeline.TransferPlan +import jp.orgflow.fsmp.waterline.Waterline +import jp.orgflow.fsmp.waterline.WaterlineEvaluator + +class FluidInjectionPlanner( + private val budgetBytesPerTick: Long = 512L * 1024, + private val evaluator: WaterlineEvaluator = WaterlineEvaluator(), +) { + + fun plan(scored: List<ScoredCandidate>, context: DecisionContext): TransferPlan { + val selected = mutableListOf<ScoredCandidate>() + var remaining = budgetBytesPerTick + var deferred = 0 + + val stateOf = context.waterlineStates.associateBy { it.peerId to it.packId.value } + for (sc in scored) { + val key = sc.candidate.targetPeerId to sc.candidate.packId + val state = stateOf[key] + val needsInjection = state == null || evaluator.belowTarget(state, Waterline.USABLE) + if (!needsInjection) continue + val cost = sc.candidate.sizeBytes.toLong().coerceAtLeast(1) + if (cost <= remaining) { + remaining -= cost + selected += sc.copy(reason = if (sc.reason.isBlank()) "fluid-injection" else sc.reason) + } else { + deferred++ + } + } + + val traces = selected.map { it.trace(selected = true) } + return TransferPlan(selected, budgetBytesPerTick - remaining, traces, deferred) + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFragmenter.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFragmenter.kt index b1de2fa..18fc008 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFragmenter.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFragmenter.kt @@ -1,3 +1,25 @@ package jp.orgflow.fsmp.frame -// TODO(spec ch.14-18): implement FsmpFragmenter per docs/spec.md +class FsmpFragmenter( + private val frameSizeBytes: Int = 16384, + private val retransmitMax: Int = 3, +) { + fun fragment(streamId: Long, payload: ByteArray): List<FsmpFrame> { + if (payload.isEmpty()) return listOf(FsmpFrameEncoder.dataFrame(streamId, 0, ByteArray(0), FsmpFrameHeader.FLAG_LAST_FRAGMENT)) + val frames = mutableListOf<FsmpFrame>() + var offset = 0 + var seq = 0L + while (offset < payload.size) { + val end = minOf(offset + frameSizeBytes, payload.size) + val chunk = payload.copyOfRange(offset, end) + val last = if (end >= payload.size) FsmpFrameHeader.FLAG_LAST_FRAGMENT else 0 + frames += FsmpFrameEncoder.dataFrame(streamId, seq, chunk, last) + offset = end + seq++ + } + return frames + } + + fun retransmitFrames(pending: List<FsmpFrame>, attempts: Map<Long, Int>): List<FsmpFrame> = + pending.filter { (attempts[it.header.sequence] ?: 0) < retransmitMax } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFrame.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFrame.kt index ee1afd3..663ffd1 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFrame.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFrame.kt @@ -1,3 +1,11 @@ package jp.orgflow.fsmp.frame -// TODO(spec ch.14-18): implement FsmpFrame per docs/spec.md +data class FsmpFrame( + val header: FsmpFrameHeader, + val payload: ByteArray, +) { + override fun equals(other: Any?): Boolean = + other is FsmpFrame && other.header == header && other.payload.contentEquals(payload) + + override fun hashCode(): Int = header.hashCode() * 31 + payload.contentHashCode() +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFrameDecoder.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFrameDecoder.kt index b4b2264..1f18fb5 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFrameDecoder.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFrameDecoder.kt @@ -1,3 +1,40 @@ package jp.orgflow.fsmp.frame -// TODO(spec ch.14-18): implement FsmpFrameDecoder per docs/spec.md +import jp.orgflow.fsmp.error.FsmpError +import jp.orgflow.fsmp.error.FsmpErrorCode +import jp.orgflow.fsmp.error.FsmpProtocolException + +object FsmpFrameDecoder { + + fun decode(bytes: ByteArray, maxPayload: Int = 1 shl 24): FsmpFrame { + if (bytes.size < 30) throw FsmpProtocolException(FsmpError(FsmpErrorCode.FRAME_MALFORMED, "short header ${bytes.size}")) + var p = 0 + fun byte(): Int = bytes[p++].toInt() and 0xFF + fun i16(): Int = (byte() shl 8) or byte() + fun i32(): Int = (byte() shl 24) or (byte() shl 16) or (byte() shl 8) or byte() + fun i64(): Long = (i32().toLong() shl 32) or (i32().toLong() and 0xFFFFFFFFL) + + if (byte() != 0x46 || byte() != 0x53) throw FsmpProtocolException(FsmpError(FsmpErrorCode.FRAME_MALFORMED, "bad magic")) + val major = byte() + val minor = byte() + val typeCode = byte() + val flags = byte() + val streamId = i64() + val sequence = i64() + val length = i32() + val hashLen = i16() + if (length < 0 || length > maxPayload) throw FsmpProtocolException(FsmpError(FsmpErrorCode.FRAME_MALFORMED, "payload length $length")) + if (hashLen < 0 || p + hashLen + length > bytes.size) throw FsmpProtocolException(FsmpError(FsmpErrorCode.FRAME_MALFORMED, "truncated frame")) + + val type = FsmpFrameType.fromCode(typeCode.toByte()) + ?: throw FsmpProtocolException(FsmpError(FsmpErrorCode.FRAME_MALFORMED, "unknown type $typeCode")) + val hash = bytes.copyOfRange(p, p + hashLen).decodeToString() + p += hashLen + val payload = bytes.copyOfRange(p, p + length) + + val header = FsmpFrameHeader(major, minor, type, flags, streamId, sequence, length, hash) + if (!header.isCompatible(1, 0)) throw FsmpProtocolException(FsmpError(FsmpErrorCode.VERSION_MISMATCH, "v$major.$minor")) + if (FsmpFrameEncoder.bodyHash(payload) != hash) throw FsmpProtocolException(FsmpError(FsmpErrorCode.HASH_VERIFY_FAILED, "body hash mismatch")) + return FsmpFrame(header, payload) + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFrameEncoder.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFrameEncoder.kt index b6b4ef1..001023f 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFrameEncoder.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFrameEncoder.kt @@ -1,3 +1,46 @@ package jp.orgflow.fsmp.frame -// TODO(spec ch.14-18): implement FsmpFrameEncoder per docs/spec.md +import okio.ByteString.Companion.toByteString + +object FsmpFrameEncoder { + private val magic = byteArrayOf(0x46, 0x53) + + fun bodyHash(payload: ByteArray): String = payload.toByteString().sha256().hex() + + fun encode(frame: FsmpFrame): ByteArray { + val hashBytes = frame.header.bodyHash.encodeToByteArray() + val size = magic.size + 1 + 1 + 1 + 1 + 8 + 8 + 4 + 2 + hashBytes.size + frame.payload.size + val out = ByteArray(size) + var p = 0 + fun byte(v: Int) { out[p++] = v.toByte() } + fun i16(v: Int) { byte(v shr 8); byte(v) } + fun i32(v: Int) { byte(v shr 24); byte(v shr 16); byte(v shr 8); byte(v) } + fun i64(v: Long) { i32((v shr 32).toInt()); i32(v.toInt()) } + + for (b in magic) out[p++] = b + byte(frame.header.versionMajor) + byte(frame.header.versionMinor) + byte(frame.header.type.code.toInt()) + byte(frame.header.flags) + i64(frame.header.streamId) + i64(frame.header.sequence) + i32(frame.payload.size) + i16(hashBytes.size) + for (b in hashBytes) out[p++] = b + for (b in frame.payload) out[p++] = b + return out + } + + fun dataFrame(streamId: Long, sequence: Long, payload: ByteArray, flags: Int = 0): FsmpFrame = + FsmpFrame( + FsmpFrameHeader( + type = FsmpFrameType.DATA, + flags = flags, + streamId = streamId, + sequence = sequence, + payloadLength = payload.size, + bodyHash = bodyHash(payload), + ), + payload, + ) +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFrameHeader.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFrameHeader.kt index e1c40c3..dec4569 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFrameHeader.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFrameHeader.kt @@ -1,3 +1,18 @@ package jp.orgflow.fsmp.frame -// TODO(spec ch.14-18): implement FsmpFrameHeader per docs/spec.md +data class FsmpFrameHeader( + val versionMajor: Int = 1, + val versionMinor: Int = 0, + val type: FsmpFrameType = FsmpFrameType.DATA, + val flags: Int = 0, + val streamId: Long = 0L, + val sequence: Long = 0L, + val payloadLength: Int = 0, + val bodyHash: String = "", +) { + fun isCompatible(major: Int, minor: Int): Boolean = versionMajor == major && versionMinor <= minor + + companion object { + const val FLAG_LAST_FRAGMENT = 0x01 + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFrameType.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFrameType.kt index 363a77b..36ae0c2 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFrameType.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFrameType.kt @@ -1,3 +1,14 @@ package jp.orgflow.fsmp.frame -// TODO(spec ch.14-18): implement FsmpFrameType per docs/spec.md +enum class FsmpFrameType(val code: Byte) { + DATA(0x01), + ACK(0x02), + REPAIR(0x03), + CONTROL(0x04), + ADVERTISE(0x05), + ; + + companion object { + fun fromCode(code: Byte): FsmpFrameType? = entries.firstOrNull { it.code == code } + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpReassembler.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpReassembler.kt index b478646..3ae81c7 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpReassembler.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpReassembler.kt @@ -1,3 +1,40 @@ package jp.orgflow.fsmp.frame -// TODO(spec ch.14-18): implement FsmpReassembler per docs/spec.md +class FsmpReassembler { + private val fragments = HashMap<Long, FsmpFrame>() + private var completed: ByteArray? = null + private var sawLast = false + + fun accept(frame: FsmpFrame): Boolean { + if (completed != null) return true + if (frame.header.type != FsmpFrameType.DATA) return false + fragments[frame.header.sequence] = frame + if (frame.header.flags and FsmpFrameHeader.FLAG_LAST_FRAGMENT != 0) sawLast = true + if (sawLast && contiguous()) { + val ordered = fragments.keys.sorted() + val out = ByteArray(ordered.sumOf { fragments.getValue(it).payload.size }) + var p = 0 + for (seq in ordered) { + val payload = fragments.getValue(seq).payload + payload.copyInto(out, p) + p += payload.size + } + completed = out + return true + } + return false + } + + fun assembled(): ByteArray? = completed + + fun isDuplicate(sequence: Long): Boolean = fragments.containsKey(sequence) + + private fun contiguous(): Boolean { + var expected = 0L + for (seq in fragments.keys.sorted()) { + if (seq != expected) return false + expected++ + } + return true + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpReplayGuard.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpReplayGuard.kt index 572c9d2..de0b337 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpReplayGuard.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpReplayGuard.kt @@ -1,3 +1,22 @@ package jp.orgflow.fsmp.frame -// TODO(spec ch.14-18): implement FsmpReplayGuard per docs/spec.md +class FsmpReplayGuard( + private val window: Int = 256, +) { + private val seen = HashMap<Long, Long>() + private var maxSequence: Long = -1 + + fun accept(sequence: Long): Boolean { + if (seen.containsKey(sequence)) return false + if (maxSequence >= 0 && sequence <= maxSequence - window) return false + seen[sequence] = sequence + if (sequence > maxSequence) maxSequence = sequence + if (seen.size > window * 2) { + val cutoff = maxSequence - window + seen.keys.removeAll { it < cutoff } + } + return true + } + + fun isReplay(sequence: Long): Boolean = !accept(sequence) +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/ContentMessages.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/ContentMessages.kt index 2fe2536..297ed43 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/ContentMessages.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/ContentMessages.kt @@ -1,3 +1,66 @@ package jp.orgflow.fsmp.message -// TODO(spec ch.14-18): implement ContentMessages per docs/spec.md +import kotlinx.serialization.Serializable + +@Serializable +sealed interface ContentMessage : FsmpMessage + +@Serializable +data class ManifestRequest( + override val streamId: String, + val packId: String, + override val flags: Int = 0, + override val bodyHash: String = "", +) : ContentMessage { + override val msgType: String = "content.manifest.request" +} + +@Serializable +data class ManifestResponse( + override val streamId: String, + val packId: String, + val manifestHash: String, + val totalChunks: Int, + val chunkHashes: List<String>, + override val flags: Int = 0, + override val bodyHash: String = "", +) : ContentMessage { + override val msgType: String = "content.manifest.response" +} + +@Serializable +data class ChunkMessage( + override val streamId: String, + val packId: String, + val chunkIndex: Int, + val chunkHash: String, + val payloadSizeBytes: Int, + override val flags: Int = 0, + override val bodyHash: String = "", +) : ContentMessage { + override val msgType: String = "content.chunk" +} + +@Serializable +data class AckBitmapMessage( + override val streamId: String, + val packId: String, + val peerId: String, + val receivedBitmap: List<Boolean>, + override val flags: Int = 0, + override val bodyHash: String = "", +) : ContentMessage { + override val msgType: String = "content.ack.bitmap" +} + +@Serializable +data class PackAdvertise( + override val streamId: String, + val packId: String, + val manifestHash: String, + val totalChunks: Int, + override val flags: Int = 0, + override val bodyHash: String = "", +) : ContentMessage { + override val msgType: String = "content.pack.advertise" +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/FsmpMessage.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/FsmpMessage.kt index 582e306..74fa7c2 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/FsmpMessage.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/FsmpMessage.kt @@ -1,3 +1,13 @@ package jp.orgflow.fsmp.message -// TODO(spec ch.14-18): implement FsmpMessage per docs/spec.md +import kotlinx.serialization.Serializable + +@Serializable +sealed interface FsmpMessage { + val msgType: String + val streamId: String + val flags: Int + val bodyHash: String + + fun validate(): Boolean = msgType.isNotBlank() && streamId.isNotBlank() +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/GitPoaMessages.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/GitPoaMessages.kt index 548bcb9..8810619 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/GitPoaMessages.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/GitPoaMessages.kt @@ -1,3 +1,31 @@ package jp.orgflow.fsmp.message -// TODO(spec ch.14-18): implement GitPoaMessages per docs/spec.md +import kotlinx.serialization.Serializable + +@Serializable +sealed interface GitPoaMessage : FsmpMessage + +@Serializable +data class GitPoaMetadata( + override val streamId: String, + val commitHash: String, + val manifestHashes: List<String>, + val committedAtMs: Long, + override val flags: Int = 0, + override val bodyHash: String = "", +) : GitPoaMessage { + override val msgType: String = "gitpoa.metadata" +} + +@Serializable +data class CommitObservationNote( + override val streamId: String, + val commitHash: String, + val observedByPeerId: String, + val observedAtMs: Long, + val consistent: Boolean, + override val flags: Int = 0, + override val bodyHash: String = "", +) : GitPoaMessage { + override val msgType: String = "gitpoa.commit.observation" +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/PeerMessages.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/PeerMessages.kt index aae8384..09af128 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/PeerMessages.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/PeerMessages.kt @@ -1,3 +1,41 @@ package jp.orgflow.fsmp.message -// TODO(spec ch.14-18): implement PeerMessages per docs/spec.md +import jp.orgflow.fsmp.waterline.Waterline +import kotlinx.serialization.Serializable + +@Serializable +sealed interface PeerMessage : FsmpMessage + +@Serializable +data class PeerHello( + override val streamId: String, + val peerId: String, + val maxBandwidthKbps: Int = 512, + val waterlineTarget: Waterline = Waterline.USABLE, + override val flags: Int = 0, + override val bodyHash: String = "", +) : PeerMessage { + override val msgType: String = "peer.hello" +} + +@Serializable +data class PeerBye( + override val streamId: String, + val peerId: String, + override val flags: Int = 0, + override val bodyHash: String = "", +) : PeerMessage { + override val msgType: String = "peer.bye" +} + +@Serializable +data class StatusReport( + override val streamId: String, + val peerId: String, + val waterline: Waterline, + val missingChunkCount: Int, + override val flags: Int = 0, + override val bodyHash: String = "", +) : PeerMessage { + override val msgType: String = "peer.status" +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/RepairMessages.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/RepairMessages.kt index 228ddec..615b77b 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/RepairMessages.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/RepairMessages.kt @@ -1,3 +1,40 @@ package jp.orgflow.fsmp.message -// TODO(spec ch.14-18): implement RepairMessages per docs/spec.md +import kotlinx.serialization.Serializable + +@Serializable +sealed interface RepairMessage : FsmpMessage + +@Serializable +data class RepairRequest( + override val streamId: String, + val packId: String, + val missingChunkIndexes: List<Int>, + override val flags: Int = 0, + override val bodyHash: String = "", +) : RepairMessage { + override val msgType: String = "repair.request" +} + +@Serializable +data class RepairResponse( + override val streamId: String, + val packId: String, + val chunkIndex: Int, + val chunkHash: String, + override val flags: Int = 0, + override val bodyHash: String = "", +) : RepairMessage { + override val msgType: String = "repair.response" +} + +@Serializable +data class CacheAdvertise( + override val streamId: String, + val packId: String, + val cachedChunkIndexes: List<Int>, + override val flags: Int = 0, + override val bodyHash: String = "", +) : RepairMessage { + override val msgType: String = "repair.cache.advertise" +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/RouteMessages.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/RouteMessages.kt index 71fbd5d..f255294 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/RouteMessages.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/RouteMessages.kt @@ -1,3 +1,20 @@ package jp.orgflow.fsmp.message -// TODO(spec ch.14-18): implement RouteMessages per docs/spec.md +import kotlinx.serialization.Serializable + +@Serializable +sealed interface RouteMessage : FsmpMessage + +@Serializable +data class RouteFeedback( + override val streamId: String, + val fromPeerId: String, + val toPeerId: String, + val success: Boolean, + val rttMs: Double, + val kind: String = "NORMAL", + override val flags: Int = 0, + override val bodyHash: String = "", +) : RouteMessage { + override val msgType: String = "route.feedback" +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pagepriority/PagePriorityBooster.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pagepriority/PagePriorityBooster.kt index 461f621..13ccae9 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pagepriority/PagePriorityBooster.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pagepriority/PagePriorityBooster.kt @@ -1,3 +1,20 @@ package jp.orgflow.fsmp.pagepriority -// TODO(spec ch.14-18): implement PagePriorityBooster per docs/spec.md +import jp.orgflow.fsmp.explain.DecisionFactor +import jp.orgflow.fsmp.pipeline.DecisionContext +import jp.orgflow.fsmp.pipeline.ScoredCandidate + +class PagePriorityBooster(private val cap: Double = 0.2) { + + fun boost(scored: List<ScoredCandidate>, context: DecisionContext): List<ScoredCandidate> { + val focus = context.focusUnitId ?: return scored + return scored.map { sc -> + val isFocus = sc.candidate.semanticUnitId == focus + if (!isFocus) sc else { + val bonus = cap + val factors = sc.factors + DecisionFactor("page_priority", 1.0, bonus, bonus) + sc.copy(score = sc.score + bonus, factors = factors) + } + } + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/peer/FsmpPeer.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/peer/FsmpPeer.kt index acba972..91a92f8 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/peer/FsmpPeer.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/peer/FsmpPeer.kt @@ -1,3 +1,11 @@ package jp.orgflow.fsmp.peer -// TODO(spec ch.14-18): implement FsmpPeer per docs/spec.md +import jp.orgflow.domain.identity.PeerId + +data class FsmpPeer( + val peerId: PeerId, + val capabilities: PeerCapabilities = PeerCapabilities(), + val reliability: PeerReliability = PeerReliability(), + val scope: PeerScope = PeerScope.LAN, + val lastSeenMs: Long = 0L, +) diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/peer/FsmpPeerSession.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/peer/FsmpPeerSession.kt index 0acde6a..00324eb 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/peer/FsmpPeerSession.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/peer/FsmpPeerSession.kt @@ -1,3 +1,14 @@ package jp.orgflow.fsmp.peer -// TODO(spec ch.14-18): implement FsmpPeerSession per docs/spec.md +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.domain.identity.SessionId + +enum class FsmpPeerSessionState { IDLE, CONNECTING, CONNECTED, DEGRADED, CLOSED } + +data class FsmpPeerSession( + val sessionId: SessionId, + val peer: FsmpPeer, + val state: FsmpPeerSessionState = FsmpPeerSessionState.IDLE, + val connectedSinceMs: Long = 0L, + val bufferedAmountBytes: Long = 0L, +) diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/peer/PeerCapabilities.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/peer/PeerCapabilities.kt index 1e5543a..7316b1e 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/peer/PeerCapabilities.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/peer/PeerCapabilities.kt @@ -1,3 +1,11 @@ package jp.orgflow.fsmp.peer -// TODO(spec ch.14-18): implement PeerCapabilities per docs/spec.md +import jp.orgflow.fsmp.waterline.Waterline + +data class PeerCapabilities( + val maxBandwidthKbps: Int = 512, + val storageBytes: Long = 1L shl 30, + val waterlineTarget: Waterline = Waterline.USABLE, + val supportsRelay: Boolean = false, + val supportsTurn: Boolean = false, +) diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/peer/PeerReliability.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/peer/PeerReliability.kt index 1e4b450..fc3b392 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/peer/PeerReliability.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/peer/PeerReliability.kt @@ -1,3 +1,33 @@ package jp.orgflow.fsmp.peer -// TODO(spec ch.14-18): implement PeerReliability per docs/spec.md +class PeerReliability( + private val emaAlpha: Double = 0.25, + initialRttMs: Double = 200.0, + initialLossRatio: Double = 0.0, +) { + var rttMs: Double = initialRttMs + private set + var lossRatio: Double = initialLossRatio + private set + var successes: Long = 0 + private set + var failures: Long = 0 + private set + + fun recordSuccess(elapsedMs: Double) { + successes++ + rttMs += emaAlpha * (elapsedMs - rttMs) + lossRatio *= (1.0 - emaAlpha) + } + + fun recordFailure() { + failures++ + lossRatio += emaAlpha * (1.0 - lossRatio) + } + + val deliveryRatio: Double + get() { + val total = successes + failures + return if (total == 0L) 1.0 else successes.toDouble() / total + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/peer/PeerScope.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/peer/PeerScope.kt index d088cf7..40dd24e 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/peer/PeerScope.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/peer/PeerScope.kt @@ -1,3 +1,3 @@ package jp.orgflow.fsmp.peer -// TODO(spec ch.14-18): implement PeerScope per docs/spec.md +enum class PeerScope { LOCAL, LAN, REMOTE, RELAY, TURN } diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/CandidateScorer.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/CandidateScorer.kt index df13c74..a90182c 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/CandidateScorer.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/CandidateScorer.kt @@ -1,3 +1,5 @@ package jp.orgflow.fsmp.pipeline -// TODO(spec ch.14-18): implement CandidateScorer per docs/spec.md +interface CandidateScorer { + fun score(candidate: TransferCandidate, context: DecisionContext): ScoredCandidate +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/CandidateSource.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/CandidateSource.kt index dd8f3e7..16fe33c 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/CandidateSource.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/CandidateSource.kt @@ -1,3 +1,5 @@ package jp.orgflow.fsmp.pipeline -// TODO(spec ch.14-18): implement CandidateSource per docs/spec.md +interface CandidateSource { + fun generate(context: DecisionContext): List<TransferCandidate> +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/DecisionContext.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/DecisionContext.kt index dcba6d2..176d7a8 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/DecisionContext.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/DecisionContext.kt @@ -1,3 +1,27 @@ package jp.orgflow.fsmp.pipeline -// TODO(spec ch.14-18): implement DecisionContext per docs/spec.md +import jp.orgflow.domain.identity.ChunkId +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.fsmp.anchor.Boundary +import jp.orgflow.fsmp.anchor.PeerSignal +import jp.orgflow.fsmp.anchor.SemanticUnit +import jp.orgflow.fsmp.peer.FsmpPeer +import jp.orgflow.fsmp.peer.FsmpPeerSession +import jp.orgflow.fsmp.waterline.WaterlineState + +data class DecisionContext( + val nowMs: Long = 0L, + val peers: List<FsmpPeer> = emptyList(), + val sessions: List<FsmpPeerSession> = emptyList(), + val waterlineStates: List<WaterlineState> = emptyList(), + val semanticUnits: List<SemanticUnit> = emptyList(), + val receivedChunkIds: Set<ChunkId> = emptySet(), + val cachedChunkIds: Set<ChunkId> = emptySet(), + val pendingRepairIndexes: Map<String, Set<Int>> = emptyMap(), + val peerSignals: List<PeerSignal> = emptyList(), + val boundaries: List<Boundary> = emptyList(), + val routeScores: Map<String, Double> = emptyMap(), + val focusUnitId: String? = null, + val deadlineSlackMs: Long = 5000L, + val commitManifestHashes: Map<String, String> = emptyMap(), +) diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/ScoredCandidate.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/ScoredCandidate.kt index 11e7420..06d6d3e 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/ScoredCandidate.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/ScoredCandidate.kt @@ -1,3 +1,19 @@ package jp.orgflow.fsmp.pipeline -// TODO(spec ch.14-18): implement ScoredCandidate per docs/spec.md +import jp.orgflow.fsmp.explain.DecisionFactor +import jp.orgflow.fsmp.explain.DecisionTrace + +data class ScoredCandidate( + val candidate: TransferCandidate, + val score: Double, + val factors: List<DecisionFactor>, + val reason: String = "", +) { + fun trace(selected: Boolean): DecisionTrace = DecisionTrace( + candidateId = candidate.candidateId, + factors = factors, + finalScore = score, + selected = selected, + reason = reason, + ) +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/SelectionPolicy.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/SelectionPolicy.kt index 1e2c093..258f949 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/SelectionPolicy.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/SelectionPolicy.kt @@ -1,3 +1,6 @@ package jp.orgflow.fsmp.pipeline -// TODO(spec ch.14-18): implement SelectionPolicy per docs/spec.md +interface SelectionPolicy { + val name: String + fun apply(scored: List<ScoredCandidate>, context: DecisionContext): List<ScoredCandidate> +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/TransferCandidate.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/TransferCandidate.kt index 498fbf1..c0065b5 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/TransferCandidate.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/TransferCandidate.kt @@ -1,3 +1,23 @@ package jp.orgflow.fsmp.pipeline -// TODO(spec ch.14-18): implement TransferCandidate per docs/spec.md +import jp.orgflow.domain.identity.ChunkId +import jp.orgflow.domain.identity.PeerId + +enum class TransferCandidateKind { NORMAL, REPAIR, SATURATION, CACHE_REUSE, REROUTED } + +data class TransferCandidate( + val candidateId: String, + val kind: TransferCandidateKind, + val packId: String, + val chunkId: ChunkId, + val chunkIndex: Int, + val targetPeerId: PeerId, + val sourcePeerId: PeerId? = null, + val priority: Double = 0.5, + val sizeBytes: Int = 0, + val semanticUnitId: String? = null, + val anchorCompletion: Double = 0.0, + val usableContribution: Double = 0.0, + val deadlineMs: Long? = null, + val createdAtMs: Long = 0L, +) diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/TransferPlan.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/TransferPlan.kt index 3af48da..d310835 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/TransferPlan.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/TransferPlan.kt @@ -1,3 +1,14 @@ package jp.orgflow.fsmp.pipeline -// TODO(spec ch.14-18): implement TransferPlan per docs/spec.md +import jp.orgflow.fsmp.explain.DecisionTrace + +data class TransferPlan( + val selected: List<ScoredCandidate>, + val budgetBytes: Long, + val traces: List<DecisionTrace>, + val deferredCount: Int, +) { + companion object { + val EMPTY = TransferPlan(emptyList(), 0L, emptyList(), 0) + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/repair/RepairPriorityPlanner.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/repair/RepairPriorityPlanner.kt index 7f3e080..95587f8 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/repair/RepairPriorityPlanner.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/repair/RepairPriorityPlanner.kt @@ -1,3 +1,19 @@ package jp.orgflow.fsmp.repair -// TODO(spec ch.14-18): implement RepairPriorityPlanner per docs/spec.md +import jp.orgflow.fsmp.pipeline.DecisionContext +import jp.orgflow.fsmp.pipeline.ScoredCandidate +import jp.orgflow.fsmp.pipeline.SelectionPolicy +import jp.orgflow.fsmp.pipeline.TransferCandidateKind + +class RepairPriorityPlanner : SelectionPolicy { + + override val name: String = "repair-priority" + + override fun apply(scored: List<ScoredCandidate>, context: DecisionContext): List<ScoredCandidate> { + val repairs = scored.filter { it.candidate.kind == TransferCandidateKind.REPAIR } + .sortedWith(compareByDescending<ScoredCandidate> { it.candidate.usableContribution }.thenBy { it.candidate.candidateId }) + .map { it.copy(reason = "repair(contribution=${it.candidate.usableContribution})") } + val others = scored.filter { it.candidate.kind != TransferCandidateKind.REPAIR } + return repairs + others + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpDecisionLogger.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpDecisionLogger.kt index b6d39b8..7ddfd80 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpDecisionLogger.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpDecisionLogger.kt @@ -1,3 +1,18 @@ package jp.orgflow.fsmp.runtime -// TODO(spec ch.14-18): implement FsmpDecisionLogger per docs/spec.md +import jp.orgflow.fsmp.explain.DecisionTrace + +class FsmpDecisionLogger(private val capacity: Int = 256) { + private val traces = ArrayDeque<DecisionTrace>() + + fun log(trace: DecisionTrace) { + traces.addLast(trace) + while (traces.size > capacity) traces.removeFirst() + } + + fun logAll(list: List<DecisionTrace>) = list.forEach { log(it) } + + fun recent(count: Int = 32): List<DecisionTrace> = traces.takeLast(count) + + fun size(): Int = traces.size +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpEvent.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpEvent.kt index 7213d95..639d18e 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpEvent.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpEvent.kt @@ -1,3 +1,12 @@ package jp.orgflow.fsmp.runtime -// TODO(spec ch.14-18): implement FsmpEvent per docs/spec.md +import jp.orgflow.fsmp.anchor.Boundary +import jp.orgflow.fsmp.error.FsmpError +import jp.orgflow.fsmp.pipeline.TransferPlan + +sealed interface FsmpEvent { + data class TransferPlanned(val plan: TransferPlan) : FsmpEvent + data class BoundaryDetected(val boundary: Boundary) : FsmpEvent + data class PeerStateChanged(val peerId: String, val state: String) : FsmpEvent + data class ErrorOccurred(val error: FsmpError) : FsmpEvent +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpEventBus.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpEventBus.kt index d430cb8..d119916 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpEventBus.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpEventBus.kt @@ -1,3 +1,16 @@ package jp.orgflow.fsmp.runtime -// TODO(spec ch.14-18): implement FsmpEventBus per docs/spec.md +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow + +class FsmpEventBus(private val bufferCapacity: Int = 64) { + private val _events = MutableSharedFlow<FsmpEvent>(replay = 0, extraBufferCapacity = bufferCapacity) + + val events: SharedFlow<FsmpEvent> = _events + + fun tryEmit(event: FsmpEvent): Boolean = _events.tryEmit(event) + + suspend fun emit(event: FsmpEvent) { + _events.emit(event) + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpScheduler.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpScheduler.kt index 038ad08..96efa46 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpScheduler.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpScheduler.kt @@ -1,3 +1,32 @@ package jp.orgflow.fsmp.runtime -// TODO(spec ch.14-18): implement FsmpScheduler per docs/spec.md +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class FsmpScheduler( + private val scope: CoroutineScope, + private val tickIntervalMs: Long = 1000, +) { + private var job: Job? = null + private var ticks: Long = 0 + + fun start(onTick: suspend (Long) -> Unit): Job { + stop() + job = scope.launch { + while (true) { + delay(tickIntervalMs) + onTick(++ticks) + } + } + return job!! + } + + fun stop() { + job?.cancel() + job = null + } + + fun tickCount(): Long = ticks +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpStateMachine.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpStateMachine.kt index 05e5fa0..e1e1393 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpStateMachine.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpStateMachine.kt @@ -1,3 +1,35 @@ package jp.orgflow.fsmp.runtime -// TODO(spec ch.14-18): implement FsmpStateMachine per docs/spec.md +import jp.orgflow.domain.identity.PeerId + +enum class FsmpSessionState { IDLE, HELLO, SYNCING, ACTIVE, DEGRADED, CLOSED } + +class FsmpStateMachine { + + private val transitions = mapOf( + FsmpSessionState.IDLE to setOf(FsmpSessionState.HELLO, FsmpSessionState.CLOSED), + FsmpSessionState.HELLO to setOf(FsmpSessionState.SYNCING, FsmpSessionState.CLOSED), + FsmpSessionState.SYNCING to setOf(FsmpSessionState.ACTIVE, FsmpSessionState.DEGRADED, FsmpSessionState.CLOSED), + FsmpSessionState.ACTIVE to setOf(FsmpSessionState.DEGRADED, FsmpSessionState.CLOSED), + FsmpSessionState.DEGRADED to setOf(FsmpSessionState.ACTIVE, FsmpSessionState.CLOSED), + FsmpSessionState.CLOSED to emptySet(), + ) + + private val states = mutableMapOf<PeerId, FsmpSessionState>() + private val listeners = mutableListOf<(PeerId, FsmpSessionState) -> Unit>() + + fun stateOf(peerId: PeerId): FsmpSessionState = states[peerId] ?: FsmpSessionState.IDLE + + fun transition(peerId: PeerId, next: FsmpSessionState): Boolean { + val current = stateOf(peerId) + val allowed = transitions[current] ?: emptySet() + if (next !in allowed) return false + states[peerId] = next + listeners.forEach { it(peerId, next) } + return true + } + + fun onTransition(listener: (PeerId, FsmpSessionState) -> Unit) { + listeners += listener + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpTransferCoordinator.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpTransferCoordinator.kt index efe00b5..730ca80 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpTransferCoordinator.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpTransferCoordinator.kt @@ -1,3 +1,35 @@ package jp.orgflow.fsmp.runtime -// TODO(spec ch.14-18): implement FsmpTransferCoordinator per docs/spec.md +import jp.orgflow.fsmp.pipeline.CandidateScorer +import jp.orgflow.fsmp.pipeline.CandidateSource +import jp.orgflow.fsmp.pipeline.DecisionContext +import jp.orgflow.fsmp.pipeline.ScoredCandidate +import jp.orgflow.fsmp.pipeline.SelectionPolicy +import jp.orgflow.fsmp.pipeline.TransferPlan +import jp.orgflow.fsmp.fluid.FluidInjectionPlanner +import jp.orgflow.fsmp.pagepriority.PagePriorityBooster + +class FsmpTransferCoordinator( + private val source: CandidateSource, + private val scorer: CandidateScorer, + private val booster: PagePriorityBooster, + private val policies: List<SelectionPolicy>, + private val planner: FluidInjectionPlanner, + private val logger: FsmpDecisionLogger, + private val eventBus: FsmpEventBus, +) { + fun runTick(context: DecisionContext): TransferPlan { + val candidates = source.generate(context) + if (candidates.isEmpty()) return TransferPlan.EMPTY + + var scored: List<ScoredCandidate> = candidates.map { scorer.score(it, context) } + scored = booster.boost(scored, context) + for (policy in policies) { + scored = policy.apply(scored, context) + } + val plan = planner.plan(scored, context) + logger.logAll(plan.traces) + eventBus.tryEmit(FsmpEvent.TransferPlanned(plan)) + return plan + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/saturation/SmartSaturatingSelector.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/saturation/SmartSaturatingSelector.kt index 7881e97..5f470b5 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/saturation/SmartSaturatingSelector.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/saturation/SmartSaturatingSelector.kt @@ -1,3 +1,22 @@ package jp.orgflow.fsmp.saturation -// TODO(spec ch.14-18): implement SmartSaturatingSelector per docs/spec.md +import jp.orgflow.fsmp.pipeline.DecisionContext +import jp.orgflow.fsmp.pipeline.ScoredCandidate +import jp.orgflow.fsmp.pipeline.SelectionPolicy + +class SmartSaturatingSelector( + private val maxSelections: Int = 16, + private val completionThreshold: Double = 0.75, +) : SelectionPolicy { + + override val name: String = "smart-saturating-selection" + + override fun apply(scored: List<ScoredCandidate>, context: DecisionContext): List<ScoredCandidate> { + val nearCompletion = scored.filter { it.candidate.anchorCompletion >= completionThreshold } + .sortedByDescending { it.candidate.anchorCompletion } + .take(maxSelections) + .map { it.copy(reason = "saturating(${it.candidate.anchorCompletion})") } + val rest = scored.filter { it.candidate.anchorCompletion < completionThreshold } + return nearCompletion + rest + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/scoring/ObScaVScorer.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/scoring/ObScaVScorer.kt index a9f6a4c..91c7660 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/scoring/ObScaVScorer.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/scoring/ObScaVScorer.kt @@ -1,3 +1,34 @@ package jp.orgflow.fsmp.scoring -// TODO(spec ch.14-18): implement ObScaVScorer per docs/spec.md +import jp.orgflow.fsmp.explain.DecisionFactor +import jp.orgflow.fsmp.pipeline.CandidateScorer +import jp.orgflow.fsmp.pipeline.DecisionContext +import jp.orgflow.fsmp.pipeline.ScoredCandidate +import jp.orgflow.fsmp.pipeline.TransferCandidate +import jp.orgflow.fsmp.pipeline.TransferCandidateKind +import kotlin.math.min + +class ObScaVScorer(private val weights: ObScaVWeights = ObScaVWeights.DEFAULT) : CandidateScorer { + + override fun score(candidate: TransferCandidate, context: DecisionContext): ScoredCandidate { + val peer = context.peers.firstOrNull { it.peerId == candidate.targetPeerId } + val focusRaw = if (candidate.semanticUnitId != null && candidate.semanticUnitId == context.focusUnitId) 1.0 else 0.0 + val deadlineRaw = candidate.deadlineMs + ?.takeIf { it > context.nowMs } + ?.let { 1.0 - min((it - context.nowMs).toDouble() / context.deadlineSlackMs.coerceAtLeast(1), 1.0) } + ?: 0.0 + val rttRaw = 1.0 - min((peer?.reliability?.rttMs ?: 200.0) / 1000.0, 1.0) + val lossRaw = 1.0 - min(peer?.reliability?.lossRatio ?: 0.0, 1.0) + val repairAgeRaw = if (candidate.kind == TransferCandidateKind.REPAIR) min(candidate.anchorCompletion, 1.0) else 0.0 + + val factors = listOf( + DecisionFactor("focus_card", focusRaw, weights.wFocusCard, focusRaw * weights.wFocusCard), + DecisionFactor("deadline", deadlineRaw, weights.wDeadline, deadlineRaw * weights.wDeadline), + DecisionFactor("rtt", rttRaw, weights.wRtt, rttRaw * weights.wRtt), + DecisionFactor("loss", lossRaw, weights.wLoss, lossRaw * weights.wLoss), + DecisionFactor("repair_age", repairAgeRaw, weights.wRepairAge, repairAgeRaw * weights.wRepairAge), + ) + val score = factors.sumOf { it.contribution } + return ScoredCandidate(candidate, score, factors) + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/scoring/ObScaVWeights.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/scoring/ObScaVWeights.kt index b3c9f16..bfb3507 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/scoring/ObScaVWeights.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/scoring/ObScaVWeights.kt @@ -1,3 +1,15 @@ package jp.orgflow.fsmp.scoring -// TODO(spec ch.14-18): implement ObScaVWeights per docs/spec.md +data class ObScaVWeights( + val wFocusCard: Double = 0.35, + val wDeadline: Double = 0.25, + val wRtt: Double = 0.15, + val wLoss: Double = 0.15, + val wRepairAge: Double = 0.10, +) { + val sum: Double get() = wFocusCard + wDeadline + wRtt + wLoss + wRepairAge + + companion object { + val DEFAULT = ObScaVWeights() + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/GroupBottleneckDetector.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/GroupBottleneckDetector.kt index 97a17ac..67d5699 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/GroupBottleneckDetector.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/GroupBottleneckDetector.kt @@ -1,3 +1,18 @@ package jp.orgflow.fsmp.topology -// TODO(spec ch.14-18): implement GroupBottleneckDetector per docs/spec.md +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.fsmp.anchor.Boundary +import jp.orgflow.fsmp.anchor.BoundaryType + +class GroupBottleneckDetector(private val boundaryQuorum: Int = 2) { + + data class Bottleneck(val peerId: PeerId, val boundaryTypes: Set<BoundaryType>, val affectedCount: Int) + + fun detect(boundaries: List<Boundary>): List<Bottleneck> { + val byPeer = boundaries.groupBy { it.peerId } + return byPeer.mapNotNull { (peer, list) -> + if (list.size < boundaryQuorum) null + else Bottleneck(peer, list.map { it.type }.toSet(), list.size) + }.sortedByDescending { it.affectedCount } + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/LocalityAwareRouting.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/LocalityAwareRouting.kt index 0098f23..ee6d9a8 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/LocalityAwareRouting.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/LocalityAwareRouting.kt @@ -1,3 +1,24 @@ package jp.orgflow.fsmp.topology -// TODO(spec ch.14-18): implement LocalityAwareRouting per docs/spec.md +import jp.orgflow.fsmp.peer.PeerScope + +class LocalityAwareRouting { + + fun routeScore(from: PeerScope, to: PeerScope): Double { + if (from == to) return 1.0 + return when (minOf(scopeRank(from), scopeRank(to))) { + 0 -> 0.9 + 1 -> 0.7 + 2 -> 0.5 + else -> 0.3 + } + } + + private fun scopeRank(scope: PeerScope): Int = when (scope) { + PeerScope.LOCAL -> 0 + PeerScope.LAN -> 1 + PeerScope.REMOTE -> 2 + PeerScope.RELAY -> 3 + PeerScope.TURN -> 4 + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/PartialMeshManager.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/PartialMeshManager.kt index 0a3ef6b..f94736f 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/PartialMeshManager.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/PartialMeshManager.kt @@ -1,3 +1,49 @@ package jp.orgflow.fsmp.topology -// TODO(spec ch.14-18): implement PartialMeshManager per docs/spec.md +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.fsmp.peer.FsmpPeerSession +import jp.orgflow.fsmp.peer.FsmpPeerSessionState + +class PartialMeshManager( + private val policy: PartialMeshPolicy = PartialMeshPolicy(), + private val selector: PeerConnectionSelector = PeerConnectionSelector(policy.maxDirectPeers), + private val pruner: PeerConnectionPruner = PeerConnectionPruner(policy.maxDirectPeers), +) { + private val sessions = mutableMapOf<PeerId, FsmpPeerSession>() + + fun offerCandidates(candidates: List<PeerConnectionCandidate>): List<PeerConnectionCandidate> { + if (candidates.isEmpty()) return emptyList() + val slots = policy.maxDirectPeers - activeCount() + if (slots <= 0) return emptyList() + val anyTurn = candidates.any { it.kind == ConnectionKind.RELAY_TURN } + if (anyTurn && !policy.allowTurnFallback) { + val nonTurn = candidates.filter { it.kind != ConnectionKind.RELAY_TURN } + return selector.select(nonTurn).take(slots) + } + return selector.select(candidates).take(slots) + } + + fun connect(session: FsmpPeerSession): Boolean { + if (activeCount() >= policy.maxDirectPeers) return false + sessions[session.peer.peerId] = session.copy(state = FsmpPeerSessionState.CONNECTED) + return true + } + + fun disconnect(peerId: PeerId) { + sessions.remove(peerId) + } + + fun markDegraded(peerId: PeerId) { + sessions[peerId]?.let { sessions[peerId] = it.copy(state = FsmpPeerSessionState.DEGRADED) } + } + + fun pruneOverflow(): List<FsmpPeerSession> { + val toDrop = pruner.prune(activeSessions()) + toDrop.forEach { sessions.remove(it.peer.peerId) } + return toDrop + } + + fun activeSessions(): List<FsmpPeerSession> = sessions.values.toList() + + fun activeCount(): Int = sessions.size +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/PartialMeshPolicy.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/PartialMeshPolicy.kt index 8872dfd..9e6f0a7 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/PartialMeshPolicy.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/PartialMeshPolicy.kt @@ -1,3 +1,8 @@ package jp.orgflow.fsmp.topology -// TODO(spec ch.14-18): implement PartialMeshPolicy per docs/spec.md +data class PartialMeshPolicy( + val maxDirectPeers: Int = 8, + val preferLocal: Boolean = true, + val allowTurnFallback: Boolean = true, + val perPeerBandwidthKbps: Int = 512, +) diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/PeerConnectionCandidate.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/PeerConnectionCandidate.kt index 57590fc..37c1c26 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/PeerConnectionCandidate.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/PeerConnectionCandidate.kt @@ -1,3 +1,11 @@ package jp.orgflow.fsmp.topology -// TODO(spec ch.14-18): implement PeerConnectionCandidate per docs/spec.md +import jp.orgflow.domain.identity.PeerId + +enum class ConnectionKind { LOCAL_HOST, SRFLX, RELAY_TURN } + +data class PeerConnectionCandidate( + val peerId: PeerId, + val kind: ConnectionKind, + val rttMs: Double = 200.0, +) diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/PeerConnectionPruner.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/PeerConnectionPruner.kt index 3b6964c..32526f8 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/PeerConnectionPruner.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/PeerConnectionPruner.kt @@ -1,3 +1,19 @@ package jp.orgflow.fsmp.topology -// TODO(spec ch.14-18): implement PeerConnectionPruner per docs/spec.md +import jp.orgflow.fsmp.peer.FsmpPeerSession +import jp.orgflow.fsmp.peer.FsmpPeerSessionState + +class PeerConnectionPruner(private val maxPeers: Int = 8) { + + fun utility(session: FsmpPeerSession): Double { + val delivery = session.peer.reliability.deliveryRatio + val freshness = if (session.peer.lastSeenMs <= 0L) 0.5 else 1.0 + val stateBonus = if (session.state == FsmpPeerSessionState.CONNECTED) 1.0 else 0.5 + return delivery * freshness * stateBonus + } + + fun prune(sessions: List<FsmpPeerSession>): List<FsmpPeerSession> { + if (sessions.size <= maxPeers) return emptyList() + return sessions.sortedBy { utility(it) }.take(sessions.size - maxPeers) + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/PeerConnectionSelector.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/PeerConnectionSelector.kt index eb49e11..ea148e9 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/PeerConnectionSelector.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/PeerConnectionSelector.kt @@ -1,3 +1,21 @@ package jp.orgflow.fsmp.topology -// TODO(spec ch.14-18): implement PeerConnectionSelector per docs/spec.md +import jp.orgflow.domain.identity.PeerId + +class PeerConnectionSelector( + private val maxPeers: Int = 8, + private val localityWeight: Double = 1.0, +) { + fun score(candidate: PeerConnectionCandidate): Double { + val kindScore = when (candidate.kind) { + ConnectionKind.LOCAL_HOST -> 1.0 + ConnectionKind.SRFLX -> 0.6 + ConnectionKind.RELAY_TURN -> 0.2 + } + val rttPenalty = (candidate.rttMs / 1000.0).coerceIn(0.0, 1.0) + return localityWeight * kindScore * (1.0 - rttPenalty) + } + + fun select(candidates: List<PeerConnectionCandidate>): List<PeerConnectionCandidate> = + candidates.sortedByDescending { score(it) }.take(maxPeers) +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/waterline/Waterline.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/waterline/Waterline.kt index 2b30017..3476769 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/waterline/Waterline.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/waterline/Waterline.kt @@ -1,3 +1,7 @@ package jp.orgflow.fsmp.waterline -// TODO(spec ch.14-18): implement Waterline per docs/spec.md +enum class Waterline { + PREVIEW, BASE, USABLE, FULL; + + val isAtLeastUsable: Boolean get() = this == USABLE || this == FULL +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/waterline/WaterlineDefinition.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/waterline/WaterlineDefinition.kt index 14be2e2..3a83367 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/waterline/WaterlineDefinition.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/waterline/WaterlineDefinition.kt @@ -1,3 +1,12 @@ package jp.orgflow.fsmp.waterline -// TODO(spec ch.14-18): implement WaterlineDefinition per docs/spec.md +data class WaterlineDefinition( + val previewRatio: Double = 0.05, + val baseRatio: Double = 0.25, + val usableTargetRatio: Double = 0.9, + val usableAlsoWhenAnchorComplete: Boolean = true, +) { + companion object { + val DEFAULT = WaterlineDefinition() + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/waterline/WaterlineEvaluator.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/waterline/WaterlineEvaluator.kt index 2123a65..ad0bbc7 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/waterline/WaterlineEvaluator.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/waterline/WaterlineEvaluator.kt @@ -1,3 +1,18 @@ package jp.orgflow.fsmp.waterline -// TODO(spec ch.14-18): implement WaterlineEvaluator per docs/spec.md +class WaterlineEvaluator(private val definition: WaterlineDefinition = WaterlineDefinition.DEFAULT) { + + fun evaluate(state: WaterlineState): Waterline { + if (state.totalChunks > 0 && state.receivedChunks >= state.totalChunks) return Waterline.FULL + val ratio = state.receivedRatio + if (ratio >= definition.usableTargetRatio) return Waterline.USABLE + if (definition.usableAlsoWhenAnchorComplete && state.anchorComplete) return Waterline.USABLE + if (ratio >= definition.baseRatio) return Waterline.BASE + return Waterline.PREVIEW + } + + fun belowTarget(state: WaterlineState, target: Waterline): Boolean { + val current = evaluate(state) + return current.ordinal < target.ordinal + } +} diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/waterline/WaterlineState.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/waterline/WaterlineState.kt index 5ca773c..ca846ca 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/waterline/WaterlineState.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/waterline/WaterlineState.kt @@ -1,3 +1,17 @@ package jp.orgflow.fsmp.waterline -// TODO(spec ch.14-18): implement WaterlineState per docs/spec.md +import jp.orgflow.domain.identity.PackId +import jp.orgflow.domain.identity.PeerId + +data class WaterlineState( + val packId: PackId, + val peerId: PeerId, + val receivedChunks: Int, + val totalChunks: Int, + val anchorComplete: Boolean = false, +) { + val receivedRatio: Double + get() = if (totalChunks <= 0) 1.0 else receivedChunks.toDouble() / totalChunks + + val missingChunks: Int get() = (totalChunks - receivedChunks).coerceAtLeast(0) +} diff --git a/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/AcoRouteOptimizerTest.kt b/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/AcoRouteOptimizerTest.kt index c23eb11..0b747b4 100644 --- a/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/AcoRouteOptimizerTest.kt +++ b/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/AcoRouteOptimizerTest.kt @@ -1,3 +1,82 @@ package jp.orgflow.fsmp -// TODO(spec ch.14-18): implement AcoRouteOptimizerTest per docs/spec.md +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.fsmp.aco.AcoRouteOptimizer +import jp.orgflow.fsmp.aco.BilliardReflector +import jp.orgflow.fsmp.aco.PheromoneTable +import jp.orgflow.fsmp.aco.RouteObservation +import jp.orgflow.fsmp.anchor.Boundary +import jp.orgflow.fsmp.anchor.BoundaryType +import jp.orgflow.fsmp.pipeline.TransferCandidate +import jp.orgflow.fsmp.pipeline.TransferCandidateKind +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class AcoRouteOptimizerTest { + + private val from = PeerId("self") + private val a = PeerId("a") + private val b = PeerId("b") + + private fun optimizer(table: PheromoneTable = PheromoneTable()) = + AcoRouteOptimizer(table, explorationRatio = 0.0) + + @Test + fun successIncreasesPheromone() { + val table = PheromoneTable() + val opt = optimizer(table) + val before = table.pheromone(from, a) + opt.observe(RouteObservation(from, a, success = true, rttMs = 100.0)) + assertTrue(table.pheromone(from, a) > before) + } + + @Test + fun failureDecreasesPheromone() { + val table = PheromoneTable() + val opt = optimizer(table) + table.deposit(from, a, 5.0) + val before = table.pheromone(from, a) + opt.observe(RouteObservation(from, a, success = false)) + assertTrue(table.pheromone(from, a) < before) + } + + @Test + fun evaporationDecaysAllRoutes() { + val table = PheromoneTable(evaporationRate = 0.5) + table.deposit(from, a, 10.0) + val before = table.pheromone(from, a) + table.evaporate() + assertEquals(before * 0.5, table.pheromone(from, a), 1e-9) + } + + @Test + fun bestRoutePrefersHigherPheromone() { + val table = PheromoneTable() + table.deposit(from, a, 10.0) + assertEquals(a, optimizer(table).bestRoute(from, listOf(a, b))) + } + + @Test + fun billiardReroutesAwayFromBoundaryPeer() { + val table = PheromoneTable() + table.deposit(b, a, 10.0) + val opt = optimizer(table) + val reflector = BilliardReflector() + val candidate = TransferCandidate( + candidateId = "c1", + kind = TransferCandidateKind.NORMAL, + packId = "p", + chunkId = jp.orgflow.domain.identity.ChunkId("p:0"), + chunkIndex = 0, + targetPeerId = a, + ) + val boundary = Boundary(BoundaryType.CONGESTION, a, null, 0L, 1.0) + val result = reflector.reflect(candidate, boundary, listOf(a, b), opt) + assertNotNull(result) + assertEquals(b, result.candidate.targetPeerId) + assertEquals(TransferCandidateKind.REROUTED, result.candidate.kind) + assertTrue(result.trace.reason.contains("rerouted")) + } +} diff --git a/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/BottomPoolPolicyTest.kt b/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/BottomPoolPolicyTest.kt index 19d0394..8f73de5 100644 --- a/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/BottomPoolPolicyTest.kt +++ b/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/BottomPoolPolicyTest.kt @@ -1,3 +1,65 @@ package jp.orgflow.fsmp -// TODO(spec ch.14-18): implement BottomPoolPolicyTest per docs/spec.md +import jp.orgflow.domain.identity.ChunkId +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.fsmp.bottompool.BottomPool +import jp.orgflow.fsmp.pipeline.DecisionContext +import jp.orgflow.fsmp.pipeline.ScoredCandidate +import jp.orgflow.fsmp.pipeline.TransferCandidate +import jp.orgflow.fsmp.pipeline.TransferCandidateKind +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BottomPoolPolicyTest { + + private val peer = PeerId("p") + private val context = DecisionContext() + + private fun scored(index: Int, size: Int, kind: TransferCandidateKind = TransferCandidateKind.NORMAL) = + ScoredCandidate( + TransferCandidate( + candidateId = "c$index", + kind = kind, + packId = "p", + chunkId = ChunkId("p:$index"), + chunkIndex = index, + targetPeerId = peer, + sizeBytes = size, + ), + 0.5, + emptyList(), + ) + + @Test + fun largeChunksAreDeferred() { + val pool = BottomPool(largeChunkBytes = 1024) + val result = pool.apply(listOf(scored(0, 2048), scored(1, 100)), context) + assertEquals(listOf("c1"), result.map { it.candidate.candidateId }) + assertTrue(pool.isDeferred("c0")) + assertFalse(pool.isDeferred("c1")) + } + + @Test + fun repairsEscapeBottomPool() { + val pool = BottomPool(largeChunkBytes = 1024) + val result = pool.apply(listOf(scored(0, 2048, TransferCandidateKind.REPAIR)), context) + assertEquals(listOf("c0"), result.map { it.candidate.candidateId }) + } + + @Test + fun restoreRemovesDeferral() { + val pool = BottomPool(largeChunkBytes = 1024) + pool.apply(listOf(scored(0, 2048)), context) + pool.restore("c0") + assertFalse(pool.isDeferred("c0")) + } + + @Test + fun capacityBoundsDeferredSet() { + val pool = BottomPool(capacityChunks = 2, largeChunkBytes = 100) + pool.apply((0 until 5).map { scored(it, 200) }, context) + assertEquals(2, pool.deferredCount()) + } +} diff --git a/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/ChunkMindTest.kt b/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/ChunkMindTest.kt index a4625f6..e4c8552 100644 --- a/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/ChunkMindTest.kt +++ b/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/ChunkMindTest.kt @@ -1,3 +1,65 @@ package jp.orgflow.fsmp -// TODO(spec ch.14-18): implement ChunkMindTest per docs/spec.md +import jp.orgflow.domain.identity.ChunkId +import jp.orgflow.domain.identity.PackId +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.fsmp.anchor.Boundary +import jp.orgflow.fsmp.anchor.BoundaryType +import jp.orgflow.fsmp.chunkmind.ChunkMind +import jp.orgflow.fsmp.pipeline.DecisionContext +import jp.orgflow.fsmp.pipeline.TransferCandidateKind +import jp.orgflow.fsmp.waterline.WaterlineState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ChunkMindTest { + + private val source = ChunkMind() + private val peer = PeerId("peer-a") + + private fun context(states: List<WaterlineState>, boundaries: List<Boundary> = emptyList()) = DecisionContext( + nowMs = 0L, + peers = listOf(jp.orgflow.fsmp.peer.FsmpPeer(peer)), + waterlineStates = states, + boundaries = boundaries, + ) + + @Test + fun generatesCandidatesForPeerBelowTarget() { + val state = WaterlineState(PackId("p"), peer, receivedChunks = 2, totalChunks = 5) + val candidates = source.generate(context(listOf(state))) + assertEquals(3, candidates.size) + assertTrue(candidates.all { it.targetPeerId == peer }) + } + + @Test + fun noCandidatesWhenFull() { + val state = WaterlineState(PackId("p"), peer, receivedChunks = 5, totalChunks = 5) + assertEquals(0, source.generate(context(listOf(state))).size) + } + + @Test + fun pendingRepairsBecomeRepairKind() { + val state = WaterlineState(PackId("p"), peer, receivedChunks = 5, totalChunks = 5) + val ctx = context(listOf(state)).copy(pendingRepairIndexes = mapOf("p" to setOf(1))) + val candidates = source.generate(ctx) + assertEquals(listOf(TransferCandidateKind.REPAIR), candidates.map { it.kind }.distinct()) + } + + @Test + fun congestedPeerFilteredExceptRepair() { + val state = WaterlineState(PackId("p"), peer, receivedChunks = 0, totalChunks = 4) + val boundary = Boundary(BoundaryType.CONGESTION, peer, null, 0L, 1.0) + val candidates = source.generate(context(listOf(state), listOf(boundary))) + assertTrue(candidates.isEmpty()) + } + + @Test + fun cachedChunkMarkedCacheReuse() { + val state = WaterlineState(PackId("p"), peer, receivedChunks = 0, totalChunks = 2) + val ctx = context(listOf(state)).copy(cachedChunkIds = setOf(ChunkId("p:0"))) + val kinds = source.generate(ctx).map { it.kind } + assertEquals(TransferCandidateKind.CACHE_REUSE, kinds.first()) + } +} diff --git a/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/CommitConsistencyPolicyTest.kt b/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/CommitConsistencyPolicyTest.kt index 6199b66..48c62cb 100644 --- a/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/CommitConsistencyPolicyTest.kt +++ b/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/CommitConsistencyPolicyTest.kt @@ -1,3 +1,45 @@ package jp.orgflow.fsmp -// TODO(spec ch.14-18): implement CommitConsistencyPolicyTest per docs/spec.md +import jp.orgflow.fsmp.consistency.CommitConsistencyPolicy +import jp.orgflow.fsmp.consistency.CommitConsistencyResult +import jp.orgflow.fsmp.consistency.StaleCommitPolicy +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CommitConsistencyPolicyTest { + + private val policy = CommitConsistencyPolicy() + + @Test + fun consistentWhenHashesMatch() { + assertEquals(CommitConsistencyResult.CONSISTENT, policy.verify("abc", "abc")) + } + + @Test + fun mismatchWhenHashesDiffer() { + assertEquals(CommitConsistencyResult.MISMATCH, policy.verify("abc", "abd")) + } + + @Test + fun unknownWhenEitherHashMissing() { + assertEquals(CommitConsistencyResult.UNKNOWN, policy.verify(null, "abc")) + assertEquals(CommitConsistencyResult.UNKNOWN, policy.verify("abc", null)) + } + + @Test + fun packAdvertisementQuorum() { + assertEquals(CommitConsistencyResult.CONSISTENT, policy.verifyPack("h", listOf("h", "h"))) + assertEquals(CommitConsistencyResult.MISMATCH, policy.verifyPack("h", listOf("h", "x"))) + assertEquals(CommitConsistencyResult.UNKNOWN, policy.verifyPack("h", emptyList())) + } + + @Test + fun staleCommitDetection() { + val stale = StaleCommitPolicy(maxAgeMs = 1000) + assertTrue(stale.isStale(observedAtMs = 0L, nowMs = 2000L)) + assertFalse(stale.isStale(observedAtMs = 0L, nowMs = 500L)) + assertEquals(listOf("old"), stale.filterStale(listOf("old" to 0L, "new" to 999L), nowMs = 1500L)) + } +} diff --git a/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/FluidInjectionPlannerTest.kt b/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/FluidInjectionPlannerTest.kt index 07c4ce8..29fe9ae 100644 --- a/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/FluidInjectionPlannerTest.kt +++ b/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/FluidInjectionPlannerTest.kt @@ -1,3 +1,58 @@ package jp.orgflow.fsmp -// TODO(spec ch.14-18): implement FluidInjectionPlannerTest per docs/spec.md +import jp.orgflow.domain.identity.ChunkId +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.fsmp.fluid.FluidInjectionPlanner +import jp.orgflow.fsmp.pipeline.DecisionContext +import jp.orgflow.fsmp.pipeline.ScoredCandidate +import jp.orgflow.fsmp.pipeline.TransferCandidate +import jp.orgflow.fsmp.pipeline.TransferCandidateKind +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class FluidInjectionPlannerTest { + + private val peer = PeerId("peer-a") + + private fun scored(index: Int, size: Int) = ScoredCandidate( + TransferCandidate( + candidateId = "c$index", + kind = TransferCandidateKind.NORMAL, + packId = "p", + chunkId = ChunkId("p:$index"), + chunkIndex = index, + targetPeerId = peer, + sizeBytes = size, + ), + score = 1.0, + factors = emptyList(), + ) + + private val context = DecisionContext(nowMs = 0L) + + @Test + fun allocatesWithinBudget() { + val planner = FluidInjectionPlanner(budgetBytesPerTick = 1000) + val plan = planner.plan((0 until 5).map { scored(it, 300) }, context) + assertEquals(3, plan.selected.size) + assertEquals(2, plan.deferredCount) + assertEquals(900L, plan.budgetBytes) + } + + @Test + fun tracesCoverSelected() { + val planner = FluidInjectionPlanner(budgetBytesPerTick = 1000) + val plan = planner.plan(listOf(scored(0, 100), scored(1, 100)), context) + assertEquals(2, plan.selected.size) + assertEquals(2, plan.traces.size) + assertTrue(plan.traces.all { it.selected }) + } + + @Test + fun emptyInputYieldsEmptyPlan() { + val plan = FluidInjectionPlanner().plan(emptyList(), context) + assertEquals(0, plan.selected.size) + assertEquals(0, plan.deferredCount) + } +} diff --git a/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/FsmpFrameCodecTest.kt b/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/FsmpFrameCodecTest.kt index 8a610d0..d932422 100644 --- a/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/FsmpFrameCodecTest.kt +++ b/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/FsmpFrameCodecTest.kt @@ -1,3 +1,83 @@ package jp.orgflow.fsmp -// TODO(spec ch.14-18): implement FsmpFrameCodecTest per docs/spec.md +import jp.orgflow.fsmp.error.FsmpErrorCode +import jp.orgflow.fsmp.error.FsmpProtocolException +import jp.orgflow.fsmp.frame.FsmpFrameDecoder +import jp.orgflow.fsmp.frame.FsmpFrameEncoder +import jp.orgflow.fsmp.frame.FsmpFragmenter +import jp.orgflow.fsmp.frame.FsmpFrameHeader +import jp.orgflow.fsmp.frame.FsmpReassembler +import jp.orgflow.fsmp.frame.FsmpReplayGuard +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FsmpFrameCodecTest { + + @Test + fun encodeDecodeRoundtrip() { + val payload = ByteArray(500) { (it % 251).toByte() } + val frame = FsmpFrameEncoder.dataFrame(42L, 7L, payload) + val decoded = FsmpFrameDecoder.decode(FsmpFrameEncoder.encode(frame)) + assertEquals(frame.header, decoded.header) + assertTrue(decoded.payload.contentEquals(payload)) + } + + @Test + fun corruptedPayloadFailsHash() { + val frame = FsmpFrameEncoder.dataFrame(1L, 0L, byteArrayOf(1, 2, 3)) + val bytes = FsmpFrameEncoder.encode(frame) + bytes[bytes.size - 1] = 0x7F + val ex = assertFailsWith<FsmpProtocolException> { FsmpFrameDecoder.decode(bytes) } + assertEquals(FsmpErrorCode.HASH_VERIFY_FAILED, ex.error.code) + } + + @Test + fun badMagicFailsMalformed() { + val bytes = FsmpFrameEncoder.encode(FsmpFrameEncoder.dataFrame(1L, 0L, byteArrayOf(9))) + bytes[0] = 0x00 + val ex = assertFailsWith<FsmpProtocolException> { FsmpFrameDecoder.decode(bytes) } + assertEquals(FsmpErrorCode.FRAME_MALFORMED, ex.error.code) + } + + @Test + fun fragmentReassembleRoundtrip() { + val payload = ByteArray(50_000) { (it * 31 % 255).toByte() } + val fragmenter = FsmpFragmenter(frameSizeBytes = 16384) + val frames = fragmenter.fragment(9L, payload) + assertEquals(4, frames.size) + val reassembler = FsmpReassembler() + var completed = false + for (f in frames) completed = reassembler.accept(f) || completed + assertTrue(completed) + assertTrue(reassembler.assembled()!!.contentEquals(payload)) + } + + @Test + fun reassemblerDetectsDuplicate() { + val frames = FsmpFragmenter(4).fragment(1L, byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9)) + val reassembler = FsmpReassembler() + reassembler.accept(frames[0]) + assertTrue(reassembler.isDuplicate(frames[0].header.sequence)) + assertFalse(reassembler.isDuplicate(frames[1].header.sequence)) + } + + @Test + fun replayGuardRejectsDuplicatesAndOldSequences() { + val guard = FsmpReplayGuard(window = 8) + assertTrue(guard.accept(10)) + assertFalse(guard.accept(10)) + assertTrue(guard.accept(11)) + assertFalse(guard.accept(1)) + assertTrue(guard.accept(30)) + } + + @Test + fun lastFragmentFlagSet() { + val frames = FsmpFragmenter(4).fragment(1L, byteArrayOf(1, 2, 3, 4, 5)) + assertEquals(0, frames.first().header.flags and FsmpFrameHeader.FLAG_LAST_FRAGMENT) + assertTrue(frames.last().header.flags and FsmpFrameHeader.FLAG_LAST_FRAGMENT != 0) + } +} diff --git a/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/ObScaVScorerTest.kt b/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/ObScaVScorerTest.kt index 0a8ce13..8152017 100644 --- a/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/ObScaVScorerTest.kt +++ b/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/ObScaVScorerTest.kt @@ -1,3 +1,68 @@ package jp.orgflow.fsmp -// TODO(spec ch.14-18): implement ObScaVScorerTest per docs/spec.md +import jp.orgflow.domain.identity.ChunkId +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.fsmp.explain.DecisionTrace +import jp.orgflow.fsmp.pipeline.DecisionContext +import jp.orgflow.fsmp.pipeline.ScoredCandidate +import jp.orgflow.fsmp.pipeline.TransferCandidate +import jp.orgflow.fsmp.pipeline.TransferCandidateKind +import jp.orgflow.fsmp.scoring.ObScaVScorer +import jp.orgflow.fsmp.scoring.ObScaVWeights +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ObScaVScorerTest { + + private val scorer = ObScaVScorer() + + private fun candidate(deadline: Long? = null) = TransferCandidate( + candidateId = "c1", + kind = TransferCandidateKind.NORMAL, + packId = "p", + chunkId = ChunkId("p:0"), + chunkIndex = 0, + targetPeerId = PeerId("peer"), + deadlineMs = deadline, + ) + + private val context = DecisionContext(nowMs = 1000L, focusUnitId = "unit-1") + + @Test + fun weightsSumToOne() { + assertEquals(1.0, ObScaVWeights.DEFAULT.sum, 1e-9) + } + + @Test + fun deterministicScoring() { + val c = candidate() + assertEquals(scorer.score(c, context).score, scorer.score(c, context).score, 1e-12) + } + + @Test + fun focusUnitBoostsScore() { + val focused = candidate().copy(semanticUnitId = "unit-1") + val other = candidate().copy(semanticUnitId = "unit-2") + val fs = scorer.score(focused, context) + val os = scorer.score(other, context) + assertTrue(fs.score > os.score) + assertEquals(1.0, fs.factors.first { it.key == "focus_card" }.rawValue, 1e-9) + } + + @Test + fun traceCarriesFactors() { + val sc: ScoredCandidate = scorer.score(candidate(), context) + val trace: DecisionTrace = sc.trace(selected = true) + assertEquals(sc.candidate.candidateId, trace.candidateId) + assertEquals(5, trace.factors.size) + assertTrue(trace.selected) + } + + @Test + fun deadlineUrgencyIncreasesScore() { + val urgent = scorer.score(candidate(deadline = 1100L), context) + val relaxed = scorer.score(candidate(deadline = 6000L), context) + assertTrue(urgent.score > relaxed.score) + } +} diff --git a/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/PartialMeshManagerTest.kt b/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/PartialMeshManagerTest.kt index bc0b692..50d0842 100644 --- a/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/PartialMeshManagerTest.kt +++ b/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/PartialMeshManagerTest.kt @@ -1,3 +1,78 @@ package jp.orgflow.fsmp -// TODO(spec ch.14-18): implement PartialMeshManagerTest per docs/spec.md +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.domain.identity.SessionId +import jp.orgflow.fsmp.peer.FsmpPeer +import jp.orgflow.fsmp.peer.FsmpPeerSession +import jp.orgflow.fsmp.topology.ConnectionKind +import jp.orgflow.fsmp.topology.PartialMeshManager +import jp.orgflow.fsmp.topology.PartialMeshPolicy +import jp.orgflow.fsmp.topology.PeerConnectionCandidate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PartialMeshManagerTest { + + private fun session(n: Int) = FsmpPeerSession( + sessionId = SessionId("s$n"), + peer = FsmpPeer(PeerId("peer-$n")), + ) + + @Test + fun respectsMaxDirectPeers() { + val manager = PartialMeshManager(PartialMeshPolicy(maxDirectPeers = 2)) + assertTrue(manager.connect(session(0))) + assertTrue(manager.connect(session(1))) + assertTrue(!manager.connect(session(2))) + assertEquals(2, manager.activeCount()) + } + + @Test + fun offerCandidatesLimitedByFreeSlots() { + val manager = PartialMeshManager(PartialMeshPolicy(maxDirectPeers = 3)) + manager.connect(session(0)) + manager.connect(session(1)) + val offered = manager.offerCandidates( + listOf( + PeerConnectionCandidate(PeerId("x"), ConnectionKind.LOCAL_HOST), + PeerConnectionCandidate(PeerId("y"), ConnectionKind.SRFLX), + PeerConnectionCandidate(PeerId("z"), ConnectionKind.RELAY_TURN), + ), + ) + assertEquals(1, offered.size) + assertEquals(PeerId("x"), offered.first().peerId) + } + + @Test + fun turnFallbackDisabledFiltersRelayCandidates() { + val manager = PartialMeshManager(PartialMeshPolicy(maxDirectPeers = 8, allowTurnFallback = false)) + val offered = manager.offerCandidates( + listOf( + PeerConnectionCandidate(PeerId("t"), ConnectionKind.RELAY_TURN), + PeerConnectionCandidate(PeerId("s"), ConnectionKind.SRFLX), + ), + ) + assertTrue(offered.none { it.kind == ConnectionKind.RELAY_TURN }) + } + + @Test + fun connectBeyondLimitRefused() { + val manager = PartialMeshManager(PartialMeshPolicy(maxDirectPeers = 2)) + manager.connect(session(0)) + manager.connect(session(1)) + assertTrue(!manager.connect(session(2))) + assertEquals(2, manager.activeCount()) + assertEquals(0, manager.pruneOverflow().size) + } + + @Test + fun prunerDropsLowestUtilityFirst() { + val pruner = jp.orgflow.fsmp.topology.PeerConnectionPruner(maxPeers = 2) + val good = session(0).let { it.copy(peer = it.peer.copy(reliability = it.peer.reliability.apply { repeat(10) { recordSuccess(50.0) } })) } + val bad = session(1).let { it.copy(peer = it.peer.copy(reliability = it.peer.reliability.apply { repeat(10) { recordFailure() } })) } + val mid = session(2) + val dropped = pruner.prune(listOf(good, bad, mid)) + assertEquals(listOf(bad.peer.peerId), dropped.map { it.peer.peerId }) + } +} diff --git a/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/SmartSaturatingSelectorTest.kt b/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/SmartSaturatingSelectorTest.kt index 15edd38..4b6b068 100644 --- a/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/SmartSaturatingSelectorTest.kt +++ b/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/SmartSaturatingSelectorTest.kt @@ -1,3 +1,52 @@ package jp.orgflow.fsmp -// TODO(spec ch.14-18): implement SmartSaturatingSelectorTest per docs/spec.md +import jp.orgflow.domain.identity.ChunkId +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.fsmp.pipeline.DecisionContext +import jp.orgflow.fsmp.pipeline.ScoredCandidate +import jp.orgflow.fsmp.pipeline.TransferCandidate +import jp.orgflow.fsmp.pipeline.TransferCandidateKind +import jp.orgflow.fsmp.saturation.SmartSaturatingSelector +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SmartSaturatingSelectorTest { + + private val policy = SmartSaturatingSelector(maxSelections = 2, completionThreshold = 0.75) + private val peer = PeerId("p") + private val context = DecisionContext() + + private fun scored(index: Int, completion: Double) = ScoredCandidate( + TransferCandidate( + candidateId = "c$index", + kind = TransferCandidateKind.NORMAL, + packId = "p", + chunkId = ChunkId("p:$index"), + chunkIndex = index, + targetPeerId = peer, + anchorCompletion = completion, + ), + 0.5, + emptyList(), + ) + + @Test + fun nearCompletionCandidatesComeFirstInDescendingOrder() { + val result = policy.apply( + listOf(scored(0, 0.1), scored(1, 0.8), scored(2, 0.95), scored(3, 0.3)), + context, + ) + assertEquals(listOf("c2", "c1"), result.take(2).map { it.candidate.candidateId }) + assertTrue(result.take(2).all { it.reason.startsWith("saturating") }) + } + + @Test + fun farCandidatesKeepOriginalRelativeOrder() { + val result = policy.apply( + listOf(scored(0, 0.2), scored(1, 0.1), scored(2, 0.85)), + context, + ) + assertEquals(listOf("c2", "c0", "c1"), result.map { it.candidate.candidateId }) + } +} diff --git a/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/WaterlineEvaluatorTest.kt b/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/WaterlineEvaluatorTest.kt index 81ad8b3..26ae703 100644 --- a/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/WaterlineEvaluatorTest.kt +++ b/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/WaterlineEvaluatorTest.kt @@ -1,3 +1,49 @@ package jp.orgflow.fsmp -// TODO(spec ch.14-18): implement WaterlineEvaluatorTest per docs/spec.md +import jp.orgflow.domain.identity.PackId +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.fsmp.waterline.Waterline +import jp.orgflow.fsmp.waterline.WaterlineDefinition +import jp.orgflow.fsmp.waterline.WaterlineEvaluator +import jp.orgflow.fsmp.waterline.WaterlineState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WaterlineEvaluatorTest { + + private val evaluator = WaterlineEvaluator(WaterlineDefinition()) + + private fun state(received: Int, total: Int, anchor: Boolean = false) = + WaterlineState(PackId("p"), PeerId("a"), received, total, anchor) + + @Test + fun fullWhenAllReceived() { + assertEquals(Waterline.FULL, evaluator.evaluate(state(10, 10))) + } + + @Test + fun usableAtTargetRatio() { + assertEquals(Waterline.USABLE, evaluator.evaluate(state(9, 10))) + assertEquals(Waterline.BASE, evaluator.evaluate(state(8, 10))) + } + + @Test + fun anchorCompleteReachesUsable() { + assertEquals(Waterline.USABLE, evaluator.evaluate(state(1, 10, anchor = true))) + } + + @Test + fun previewBelowBase() { + assertEquals(Waterline.PREVIEW, evaluator.evaluate(state(1, 10))) + assertEquals(Waterline.PREVIEW, evaluator.evaluate(state(0, 10))) + } + + @Test + fun belowTargetRespectsOrdering() { + assertTrue(evaluator.belowTarget(state(0, 10), Waterline.BASE)) + assertFalse(evaluator.belowTarget(state(10, 10), Waterline.USABLE)) + assertTrue(evaluator.belowTarget(state(8, 10), Waterline.FULL)) + } +} diff --git a/modules/fsmp-relay/build.gradle.kts b/modules/fsmp-relay/build.gradle.kts index bee562c..6d7a41a 100644 --- a/modules/fsmp-relay/build.gradle.kts +++ b/modules/fsmp-relay/build.gradle.kts @@ -1,6 +1,34 @@ -// Placeholder module (spec ch.24 tree). Division owning this chapter upgrades -// this file to a KMP config when implementation starts. See NEMOTRON.md. -plugins { `base` } +import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) +} group = "net.kukuri" version = "0.1.0" + +kotlin { + jvm() + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { nodejs() } + + linuxX64() + + sourceSets { + commonMain.dependencies { + implementation(project(":modules:orgflow-domain")) + implementation(project(":modules:fsmp-core")) + implementation(libs.serialization.json) + implementation(libs.coroutines.core) + implementation(libs.koin.core) + } + jvmMain.dependencies { + implementation(libs.coroutines.core) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + } + } +} diff --git a/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/AckBitmapAggregator.kt b/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/AckBitmapAggregator.kt index a20891c..3e52fc3 100644 --- a/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/AckBitmapAggregator.kt +++ b/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/AckBitmapAggregator.kt @@ -1,3 +1,36 @@ package jp.orgflow.relay -// TODO(spec ch.14-18): implement AckBitmapAggregator per docs/spec.md +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.fsmp.message.AckBitmapMessage + +class AckBitmapAggregator { + + private val perPeer = mutableMapOf<Pair<String, PeerId>, BooleanArray>() + + fun merge(message: AckBitmapMessage) { + val key = message.packId to PeerId(message.peerId) + val existing = perPeer[key] + val width = maxOf(existing?.size ?: 0, message.receivedBitmap.size) + val merged = BooleanArray(width) + existing?.forEachIndexed { i, v -> merged[i] = merged[i] || v } + message.receivedBitmap.forEachIndexed { i, v -> merged[i] = merged[i] || v } + perPeer[key] = merged + } + + fun aggregated(packId: String): Boolean { + val lists = perPeer.filter { it.key.first == packId }.values.toList() + if (lists.isEmpty()) return false + val width = lists.maxOf { it.size } + for (i in 0 until width) { + if (lists.none { it.getOrNull(i) == true }) return false + } + return true + } + + fun peersCovered(packId: String, chunkIndex: Int): List<PeerId> = + perPeer.filter { it.key.first == packId && it.value.getOrNull(chunkIndex) == true }.map { it.key.second } + + fun reset(packId: String) { + perPeer.keys.removeAll { it.first == packId } + } +} diff --git a/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/FsmpRelay.kt b/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/FsmpRelay.kt index 5dab300..bc22ee4 100644 --- a/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/FsmpRelay.kt +++ b/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/FsmpRelay.kt @@ -1,3 +1,85 @@ package jp.orgflow.relay -// TODO(spec ch.14-18): implement FsmpRelay per docs/spec.md +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.domain.identity.SessionId +import jp.orgflow.fsmp.message.AckBitmapMessage +import jp.orgflow.fsmp.message.CacheAdvertise +import jp.orgflow.fsmp.message.ChunkMessage +import jp.orgflow.fsmp.message.FsmpMessage +import jp.orgflow.fsmp.message.ManifestRequest +import jp.orgflow.fsmp.message.ManifestResponse +import jp.orgflow.fsmp.message.RepairRequest +import jp.orgflow.fsmp.message.RepairResponse +import jp.orgflow.fsmp.runtime.FsmpEventBus + +class FsmpRelay( + val configuration: FsmpRelayConfiguration = FsmpRelayConfiguration(), + val cache: RelayCache = RelayCache(configuration.cacheEntriesMax), + val index: RelayCacheIndex = RelayCacheIndex(), + val verifier: RelayManifestVerifier = RelayManifestVerifier(), + val ackAggregator: AckBitmapAggregator = AckBitmapAggregator(), + val peerRegistry: RelayPeerRegistry = RelayPeerRegistry(), + val repairCoordinator: RelayRepairCoordinator = RelayRepairCoordinator(cache, configuration.repairBatchSize), + val forwarder: SelectiveChunkForwarder = SelectiveChunkForwarder(), + val eventBus: FsmpEventBus = FsmpEventBus(), +) { + private val sessions = mutableMapOf<PeerId, RelaySession>() + private val manifests = mutableMapOf<String, RelayManifestSpec>() + + fun registerSession(session: RelaySession) { + sessions[session.peer] = session + } + + fun onManifest(manifest: RelayManifestSpec) { + manifests[manifest.packId] = manifest + index.register(jp.orgflow.domain.identity.PackId(manifest.packId), manifest.manifestHash, manifest.totalChunks) + } + + fun onChunk(from: PeerId, chunk: ChunkMessage): RepairResponse? { + val manifest = manifests[chunk.packId] ?: return null + if (configuration.verifyManifestHash && !verifier.verifyChunk(manifest, chunk.chunkIndex, chunk.chunkHash)) return null + cache.put( + RelayCacheEntry( + packId = chunk.packId, + chunkIndex = chunk.chunkIndex, + chunkHash = chunk.chunkHash, + sizeBytes = chunk.payloadSizeBytes, + storedAtMs = 0L, + ), + ) + return RepairResponse( + streamId = chunk.streamId, + packId = chunk.packId, + chunkIndex = chunk.chunkIndex, + chunkHash = chunk.chunkHash, + ) + } + + fun onAck(ack: AckBitmapMessage) { + ackAggregator.merge(ack) + } + + fun onRepairRequest(request: RepairRequest, requester: PeerId): List<RepairResponse> { + val manifest = manifests[request.packId] ?: return emptyList() + val served = repairCoordinator.accept(request, requester) + repairCoordinator.complete(request.packId, requester) + return served.mapNotNull { idx -> + val entry = cache.get(request.packId, idx) ?: return@mapNotNull null + RepairResponse(request.streamId, request.packId, idx, entry.chunkHash) + }.filter { !configuration.verifyManifestHash || verifier.verifyChunk(manifest, it.chunkIndex, it.chunkHash) } + } + + fun advertise(packId: String, streamId: String): CacheAdvertise { + val cached = cache.indexesFor(packId) + return CacheAdvertise(streamId, packId, cached.toList()) + } + + fun handleManifestRequest(request: ManifestRequest): ManifestResponse? { + val manifest = manifests[request.packId] ?: return null + return ManifestResponse(request.streamId, manifest.packId, manifest.manifestHash, manifest.totalChunks, manifest.chunkHashes) + } + + fun session(peerId: PeerId): RelaySession? = sessions[peerId] + + fun sessionCount(): Int = sessions.size +} diff --git a/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/FsmpRelayConfiguration.kt b/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/FsmpRelayConfiguration.kt index 1e7eff2..e5bd2e4 100644 --- a/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/FsmpRelayConfiguration.kt +++ b/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/FsmpRelayConfiguration.kt @@ -1,3 +1,9 @@ package jp.orgflow.relay -// TODO(spec ch.14-18): implement FsmpRelayConfiguration per docs/spec.md +data class FsmpRelayConfiguration( + val cacheEntriesMax: Int = 1024, + val ackBitmapGranularity: Int = 8, + val repairBatchSize: Int = 4, + val verifyManifestHash: Boolean = true, + val serveStaleCommits: Boolean = false, +) diff --git a/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelayCache.kt b/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelayCache.kt index 2f4d9b4..2bf3f59 100644 --- a/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelayCache.kt +++ b/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelayCache.kt @@ -1,3 +1,37 @@ package jp.orgflow.relay -// TODO(spec ch.14-18): implement RelayCache per docs/spec.md +import jp.orgflow.domain.identity.ChunkId +import jp.orgflow.domain.identity.PeerId + +data class RelayCacheEntry( + val packId: String, + val chunkIndex: Int, + val chunkHash: String, + val sizeBytes: Int, + val storedAtMs: Long, +) + +class RelayCache(private val maxEntries: Int = 1024) { + private val entries = LinkedHashMap<String, RelayCacheEntry>() + + private fun key(packId: String, chunkIndex: Int) = "$packId#$chunkIndex" + + fun put(entry: RelayCacheEntry) { + val k = key(entry.packId, entry.chunkIndex) + entries.remove(k) + entries[k] = entry + while (entries.size > maxEntries) { + entries.remove(entries.keys.first()) + } + } + + fun get(packId: String, chunkIndex: Int): RelayCacheEntry? = entries[key(packId, chunkIndex)] + + fun hasChunk(packId: String, chunkIndex: Int, expectedHash: String): Boolean = + entries[key(packId, chunkIndex)]?.chunkHash == expectedHash + + fun indexesFor(packId: String): Set<Int> = + entries.values.filter { it.packId == packId }.map { it.chunkIndex }.toSet() + + fun size(): Int = entries.size +} diff --git a/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelayCacheIndex.kt b/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelayCacheIndex.kt index a9fd346..c22b46c 100644 --- a/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelayCacheIndex.kt +++ b/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelayCacheIndex.kt @@ -1,3 +1,24 @@ package jp.orgflow.relay -// TODO(spec ch.14-18): implement RelayCacheIndex per docs/spec.md +import jp.orgflow.domain.identity.PackId + +data class RelayCacheIndexEntry( + val packId: PackId, + val manifestHash: String, + val totalChunks: Int, +) + +class RelayCacheIndex { + private val packs = mutableMapOf<PackId, RelayCacheIndexEntry>() + + fun register(packId: PackId, manifestHash: String, totalChunks: Int) { + packs[packId] = RelayCacheIndexEntry(packId, manifestHash, totalChunks) + } + + fun entry(packId: PackId): RelayCacheIndexEntry? = packs[packId] + + fun knownPacks(): List<PackId> = packs.keys.toList() + + fun advertiseFor(packId: PackId, cached: Set<Int>): List<Int> = + (0 until (packs[packId]?.totalChunks ?: 0)).filter { it in cached } +} diff --git a/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelayManifestVerifier.kt b/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelayManifestVerifier.kt index 91e000e..f433b22 100644 --- a/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelayManifestVerifier.kt +++ b/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelayManifestVerifier.kt @@ -1,3 +1,25 @@ package jp.orgflow.relay -// TODO(spec ch.14-18): implement RelayManifestVerifier per docs/spec.md +import jp.orgflow.fsmp.consistency.CommitConsistencyPolicy +import jp.orgflow.fsmp.consistency.CommitConsistencyResult + +data class RelayManifestSpec( + val packId: String, + val manifestHash: String, + val totalChunks: Int, + val chunkHashes: List<String>, +) + +class RelayManifestVerifier(private val policy: CommitConsistencyPolicy = CommitConsistencyPolicy()) { + + fun verifyChunk(manifest: RelayManifestSpec, chunkIndex: Int, chunkHash: String): Boolean { + if (chunkIndex < 0 || chunkIndex >= manifest.totalChunks) return false + return manifest.chunkHashes.getOrNull(chunkIndex) == chunkHash + } + + fun verifyAgainstCommit(manifest: RelayManifestSpec, commitManifestHash: String?): CommitConsistencyResult = + policy.verify(manifest.manifestHash, commitManifestHash) + + fun verifyPackAdvertisement(manifest: RelayManifestSpec, advertisedHashes: List<String>): CommitConsistencyResult = + policy.verifyPack(manifest.manifestHash, advertisedHashes) +} diff --git a/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelayPeerRegistry.kt b/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelayPeerRegistry.kt index e1fe528..3d97af7 100644 --- a/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelayPeerRegistry.kt +++ b/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelayPeerRegistry.kt @@ -1,3 +1,25 @@ package jp.orgflow.relay -// TODO(spec ch.14-18): implement RelayPeerRegistry per docs/spec.md +import jp.orgflow.fsmp.peer.FsmpPeer +import jp.orgflow.fsmp.peer.PeerScope + +class RelayPeerRegistry { + private val peers = mutableMapOf<String, FsmpPeer>() + + fun register(peer: FsmpPeer) { + peers[peer.peerId.value] = peer + } + + fun unregister(peerId: String) { + peers.remove(peerId) + } + + fun find(peerId: String): FsmpPeer? = peers[peerId] + + fun all(): List<FsmpPeer> = peers.values.toList() + + fun byScope(scope: PeerScope): List<FsmpPeer> = peers.values.filter { it.scope == scope } + + fun unreliableAbove(lossRatio: Double): List<FsmpPeer> = + peers.values.filter { it.reliability.lossRatio >= lossRatio } +} diff --git a/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelayRepairCoordinator.kt b/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelayRepairCoordinator.kt index ac258d4..0878cc1 100644 --- a/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelayRepairCoordinator.kt +++ b/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelayRepairCoordinator.kt @@ -1,3 +1,25 @@ package jp.orgflow.relay -// TODO(spec ch.14-18): implement RelayRepairCoordinator per docs/spec.md +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.fsmp.message.RepairRequest + +class RelayRepairCoordinator( + private val cache: RelayCache, + private val batchSize: Int = 4, +) { + private val inFlight = mutableSetOf<Pair<String, PeerId>>() + + fun accept(request: RepairRequest, requester: PeerId): List<Int> { + val servable = request.missingChunkIndexes + .filter { cache.get(request.packId, it) != null } + .take(batchSize) + servable.forEach { inFlight += request.packId to requester } + return servable + } + + fun complete(packId: String, peer: PeerId) { + inFlight.remove(packId to peer) + } + + fun pendingCount(): Int = inFlight.size +} diff --git a/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelaySelectionPolicy.kt b/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelaySelectionPolicy.kt index 2dee041..8ab4b77 100644 --- a/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelaySelectionPolicy.kt +++ b/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelaySelectionPolicy.kt @@ -1,3 +1,26 @@ package jp.orgflow.relay -// TODO(spec ch.14-18): implement RelaySelectionPolicy per docs/spec.md +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.fsmp.pipeline.ScoredCandidate +import jp.orgflow.fsmp.pipeline.SelectionPolicy +import jp.orgflow.fsmp.pipeline.DecisionContext +import jp.orgflow.fsmp.waterline.Waterline +import jp.orgflow.fsmp.waterline.WaterlineEvaluator +import jp.orgflow.fsmp.waterline.WaterlineState + +class RelaySelectionPolicy( + private val evaluator: WaterlineEvaluator = WaterlineEvaluator(), + private val target: Waterline = Waterline.USABLE, +) : SelectionPolicy { + + override val name: String = "relay-selective-forward" + + override fun apply(scored: List<ScoredCandidate>, context: DecisionContext): List<ScoredCandidate> { + val stateOf: Map<Pair<PeerId, String>, WaterlineState> = + context.waterlineStates.associateBy { it.peerId to it.packId.value } + return scored.filter { sc -> + val state = stateOf[sc.candidate.targetPeerId to sc.candidate.packId] + state == null || evaluator.belowTarget(state, target) + }.map { it.copy(reason = if (it.reason.isBlank()) name else it.reason) } + } +} diff --git a/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelaySession.kt b/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelaySession.kt index adf54fa..73bf080 100644 --- a/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelaySession.kt +++ b/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelaySession.kt @@ -1,3 +1,23 @@ package jp.orgflow.relay -// TODO(spec ch.14-18): implement RelaySession per docs/spec.md +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.domain.identity.SessionId +import jp.orgflow.fsmp.peer.FsmpPeerSession + +data class RelaySession( + val sessionId: SessionId, + val peer: PeerId, + val createdAtMs: Long = 0L, + val waterlineTarget: String = "USABLE", + val bandwidthBudgetKbps: Int = 512, +) { + var bytesRelayed: Long = 0L + private set + var chunksRelayed: Long = 0L + private set + + fun recordRelay(bytes: Long, chunks: Long) { + bytesRelayed += bytes + chunksRelayed += chunks + } +} diff --git a/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/SelectiveChunkForwarder.kt b/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/SelectiveChunkForwarder.kt index f7f3e6e..0d72afc 100644 --- a/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/SelectiveChunkForwarder.kt +++ b/modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/SelectiveChunkForwarder.kt @@ -1,3 +1,24 @@ package jp.orgflow.relay -// TODO(spec ch.14-18): implement SelectiveChunkForwarder per docs/spec.md +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.fsmp.waterline.WaterlineState + +data class ForwardDecision( + val peerId: PeerId, + val chunkIndexes: List<Int>, +) + +class SelectiveChunkForwarder( + private val policy: RelaySelectionPolicy = RelaySelectionPolicy(), +) { + fun decide( + packId: String, + cachedIndexes: Set<Int>, + states: List<WaterlineState>, + ): List<ForwardDecision> { + return states.mapNotNull { state -> + val missing = cachedIndexes.filter { it >= state.receivedChunks && it < state.totalChunks } + if (missing.isEmpty()) null else ForwardDecision(state.peerId, missing) + } + } +} diff --git a/modules/fsmp-relay/src/jvmMain/kotlin/jp/orgflow/relay/server/FsmpRelayServer.kt b/modules/fsmp-relay/src/jvmMain/kotlin/jp/orgflow/relay/server/FsmpRelayServer.kt index 7419a1f..796e5df 100644 --- a/modules/fsmp-relay/src/jvmMain/kotlin/jp/orgflow/relay/server/FsmpRelayServer.kt +++ b/modules/fsmp-relay/src/jvmMain/kotlin/jp/orgflow/relay/server/FsmpRelayServer.kt @@ -1,3 +1,56 @@ package jp.orgflow.relay.server -// TODO(spec ch.14-18): implement FsmpRelayServer per docs/spec.md +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.relay.FsmpRelay +import jp.orgflow.relay.RelaySession +import jp.orgflow.domain.identity.SessionId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow + +interface RelayConnection { + val peerId: PeerId + val incoming: Flow<ByteArray> + suspend fun send(bytes: ByteArray): Boolean + suspend fun close() +} + +class FsmpRelayServer(private val relay: FsmpRelay) { + private val connections = mutableMapOf<PeerId, RelayConnection>() + private val started = MutableSharedFlow<PeerId>(extraBufferCapacity = 32) + + suspend fun accept(connection: RelayConnection) { + connections[connection.peerId] = connection + relay.registerSession( + RelaySession( + sessionId = SessionId("relay-${connection.peerId.value}"), + peer = connection.peerId, + ), + ) + started.emit(connection.peerId) + connection.incoming.collect { bytes -> relay.onChunk(connection.peerId, decodeChunk(bytes)) } + } + + suspend fun broadcast(bytes: ByteArray) { + connections.values.forEach { it.send(bytes) } + } + + suspend fun disconnect(peerId: PeerId) { + connections.remove(peerId)?.close() + } + + fun connectionCount(): Int = connections.size + + companion object { + fun decodeChunk(bytes: ByteArray): jp.orgflow.fsmp.message.ChunkMessage { + val text = bytes.decodeToString() + val parts = text.split("|") + return jp.orgflow.fsmp.message.ChunkMessage( + streamId = parts.getOrElse(0) { "0" }, + packId = parts.getOrElse(1) { "" }, + chunkIndex = parts.getOrNull(2)?.toIntOrNull() ?: 0, + chunkHash = parts.getOrElse(3) { "" }, + payloadSizeBytes = bytes.size, + ) + } + } +} diff --git a/modules/fsmp-relay/src/jvmMain/kotlin/jp/orgflow/relay/server/RelayServerMain.kt b/modules/fsmp-relay/src/jvmMain/kotlin/jp/orgflow/relay/server/RelayServerMain.kt index 034b0f3..b4f0f2d 100644 --- a/modules/fsmp-relay/src/jvmMain/kotlin/jp/orgflow/relay/server/RelayServerMain.kt +++ b/modules/fsmp-relay/src/jvmMain/kotlin/jp/orgflow/relay/server/RelayServerMain.kt @@ -1,3 +1,20 @@ package jp.orgflow.relay.server -// TODO(spec ch.14-18): implement RelayServerMain per docs/spec.md +import jp.orgflow.relay.FsmpRelay +import jp.orgflow.relay.FsmpRelayConfiguration +import kotlinx.coroutines.runBlocking + +fun main(args: Array<String>) { + val configuration = FsmpRelayConfiguration() + val relay = FsmpRelay(configuration) + val server = FsmpRelayServer(relay) + Runtime.getRuntime().addShutdownHook(Thread { runBlocking { server.disconnectAll() } }) + println("FSMP Relay Cache forwarder ready (config: cache=${configuration.cacheEntriesMax} entries)") + Thread.currentThread().join() +} + +private suspend fun FsmpRelayServer.disconnectAll() { + connectionsSnapshot().forEach { disconnect(it) } +} + +private fun FsmpRelayServer.connectionsSnapshot(): List<jp.orgflow.domain.identity.PeerId> = emptyList() diff --git a/modules/fsmp-relay/src/jvmMain/kotlin/jp/orgflow/relay/server/RelayServerModule.kt b/modules/fsmp-relay/src/jvmMain/kotlin/jp/orgflow/relay/server/RelayServerModule.kt index c145966..a21d3da 100644 --- a/modules/fsmp-relay/src/jvmMain/kotlin/jp/orgflow/relay/server/RelayServerModule.kt +++ b/modules/fsmp-relay/src/jvmMain/kotlin/jp/orgflow/relay/server/RelayServerModule.kt @@ -1,3 +1,30 @@ package jp.orgflow.relay.server -// TODO(spec ch.14-18): implement RelayServerModule per docs/spec.md +import jp.orgflow.relay.AckBitmapAggregator +import jp.orgflow.relay.FsmpRelay +import jp.orgflow.relay.FsmpRelayConfiguration +import jp.orgflow.relay.RelayCache +import jp.orgflow.relay.RelayCacheIndex +import jp.orgflow.relay.RelayManifestVerifier +import jp.orgflow.relay.RelayPeerRegistry +import jp.orgflow.relay.RelayRepairCoordinator +import jp.orgflow.relay.RelaySelectionPolicy +import jp.orgflow.relay.SelectiveChunkForwarder +import jp.orgflow.fsmp.runtime.FsmpEventBus +import org.koin.core.module.Module +import org.koin.dsl.module + +val relayServerModule: Module = module { + single { FsmpRelayConfiguration() } + single { RelayCache(get<FsmpRelayConfiguration>().cacheEntriesMax) } + single { RelayCacheIndex() } + single { RelayManifestVerifier() } + single { AckBitmapAggregator() } + single { RelayPeerRegistry() } + single { RelayRepairCoordinator(get(), get<FsmpRelayConfiguration>().repairBatchSize) } + single { RelaySelectionPolicy() } + single { SelectiveChunkForwarder(get()) } + single { FsmpEventBus() } + single { FsmpRelay(get(), get(), get(), get(), get(), get(), get(), get(), get()) } + single { FsmpRelayServer(get()) } +} diff --git a/modules/fsmp-transport/build.gradle.kts b/modules/fsmp-transport/build.gradle.kts index bee562c..41a7aae 100644 --- a/modules/fsmp-transport/build.gradle.kts +++ b/modules/fsmp-transport/build.gradle.kts @@ -1,6 +1,31 @@ -// Placeholder module (spec ch.24 tree). Division owning this chapter upgrades -// this file to a KMP config when implementation starts. See NEMOTRON.md. -plugins { `base` } +import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) +} group = "net.kukuri" version = "0.1.0" + +kotlin { + jvm() + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { nodejs() } + + linuxX64() + + sourceSets { + commonMain.dependencies { + implementation(project(":modules:orgflow-domain")) + implementation(project(":modules:fsmp-core")) + implementation(libs.serialization.json) + implementation(libs.coroutines.core) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.coroutines.test) + } + } +} diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/api/FsmpTransport.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/api/FsmpTransport.kt index fc32de7..8b0fb71 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/api/FsmpTransport.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/api/FsmpTransport.kt @@ -1,3 +1,16 @@ package jp.orgflow.transport.api -// TODO(spec ch.14-18): implement FsmpTransport per docs/spec.md +import jp.orgflow.domain.identity.PeerId +import kotlinx.coroutines.flow.Flow + +interface FsmpTransport { + val peerId: PeerId + val state: FsmpTransportState + val events: Flow<FsmpTransportEvent> + + suspend fun connect(target: PeerId) + suspend fun disconnect(target: PeerId) + suspend fun send(target: PeerId, bytes: ByteArray): Boolean + val incoming: Flow<Pair<PeerId, ByteArray>> + suspend fun close() +} diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/api/FsmpTransportEvent.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/api/FsmpTransportEvent.kt index 5ad5e45..6be9c73 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/api/FsmpTransportEvent.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/api/FsmpTransportEvent.kt @@ -1,3 +1,10 @@ package jp.orgflow.transport.api -// TODO(spec ch.14-18): implement FsmpTransportEvent per docs/spec.md +import jp.orgflow.domain.identity.PeerId + +sealed interface FsmpTransportEvent { + data class StateChanged(val peerId: PeerId, val state: FsmpTransportState) : FsmpTransportEvent + data class FrameReceived(val peerId: PeerId, val frame: FsmpTransportFrame) : FsmpTransportEvent + data class Backpressure(val peerId: PeerId, val paused: Boolean) : FsmpTransportEvent + data class RouteFeedbackSignal(val peerId: PeerId, val success: Boolean, val rttMs: Double) : FsmpTransportEvent +} diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/api/FsmpTransportException.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/api/FsmpTransportException.kt index b3459b3..eafac5e 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/api/FsmpTransportException.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/api/FsmpTransportException.kt @@ -1,3 +1,3 @@ package jp.orgflow.transport.api -// TODO(spec ch.14-18): implement FsmpTransportException per docs/spec.md +class FsmpTransportException(message: String, cause: Throwable? = null) : Exception(message, cause) diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/api/FsmpTransportFrame.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/api/FsmpTransportFrame.kt index c96a570..35dbb18 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/api/FsmpTransportFrame.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/api/FsmpTransportFrame.kt @@ -1,3 +1,21 @@ package jp.orgflow.transport.api -// TODO(spec ch.14-18): implement FsmpTransportFrame per docs/spec.md +import jp.orgflow.domain.identity.PeerId + +data class FsmpTransportFrame( + val streamId: Long, + val sequence: Long, + val flags: Int, + val payload: ByteArray, + val fromPeerId: PeerId? = null, +) { + override fun equals(other: Any?): Boolean = + other is FsmpTransportFrame && + other.streamId == streamId && + other.sequence == sequence && + other.flags == flags && + other.payload.contentEquals(payload) && + other.fromPeerId == fromPeerId + + override fun hashCode(): Int = ((streamId.hashCode() * 31 + sequence.hashCode()) * 31 + flags) * 31 + payload.contentHashCode() +} diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/api/FsmpTransportState.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/api/FsmpTransportState.kt index 8a1b935..48f5f3a 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/api/FsmpTransportState.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/api/FsmpTransportState.kt @@ -1,3 +1,5 @@ package jp.orgflow.transport.api -// TODO(spec ch.14-18): implement FsmpTransportState per docs/spec.md +enum class FsmpTransportState { + IDLE, CONNECTING, CONNECTED, FAILED, CLOSED +} diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/qr/QrBootstrapEncoder.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/qr/QrBootstrapEncoder.kt index 2d3dcbd..b0be703 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/qr/QrBootstrapEncoder.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/qr/QrBootstrapEncoder.kt @@ -1,3 +1,62 @@ package jp.orgflow.transport.qr -// TODO(spec ch.14-18): implement QrBootstrapEncoder per docs/spec.md +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +object QrBootstrapEncoder { + private val json = Json { ignoreUnknownKeys = true } + private const val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + + fun encode(payload: QrBootstrapPayload): String { + val bytes = json.encodeToString(payload).encodeToByteArray() + return "FSMP1:" + toBase64(bytes) + } + + fun decode(text: String): QrBootstrapPayload? { + if (!text.startsWith("FSMP1:")) return null + val bytes = fromBase64(text.removePrefix("FSMP1:")) ?: return null + return try { + json.decodeFromString(bytes.decodeToString()) + } catch (e: Exception) { + null + } + } + + private fun toBase64(bytes: ByteArray): String { + val out = StringBuilder() + var i = 0 + while (i + 2 < bytes.size) { + val n = (bytes[i].toInt() and 0xFF shl 16) or (bytes[i + 1].toInt() and 0xFF shl 8) or (bytes[i + 2].toInt() and 0xFF) + out.append(alphabet[(n shr 18) and 63]).append(alphabet[(n shr 12) and 63]) + .append(alphabet[(n shr 6) and 63]).append(alphabet[n and 63]) + i += 3 + } + val remain = bytes.size - i + if (remain == 1) { + val n = bytes[i].toInt() and 0xFF shl 16 + out.append(alphabet[(n shr 18) and 63]).append(alphabet[(n shr 12) and 63]).append("==") + } else if (remain == 2) { + val n = (bytes[i].toInt() and 0xFF shl 16) or (bytes[i + 1].toInt() and 0xFF shl 8) + out.append(alphabet[(n shr 18) and 63]).append(alphabet[(n shr 12) and 63]).append(alphabet[(n shr 6) and 63]).append("=") + } + return out.toString() + } + + private fun fromBase64(text: String): ByteArray? { + val clean = text.filter { it != '=' } + val out = mutableListOf<Byte>() + var buffer = 0 + var bits = 0 + for (c in clean) { + val v = alphabet.indexOf(c) + if (v < 0) return null + buffer = (buffer shl 6) or v + bits += 6 + if (bits >= 8) { + bits -= 8 + out.add(((buffer shr bits) and 0xFF).toByte()) + } + } + return out.toByteArray() + } +} diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/qr/QrBootstrapExpiryPolicy.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/qr/QrBootstrapExpiryPolicy.kt index 02f8af0..e35d451 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/qr/QrBootstrapExpiryPolicy.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/qr/QrBootstrapExpiryPolicy.kt @@ -1,3 +1,17 @@ package jp.orgflow.transport.qr -// TODO(spec ch.14-18): implement QrBootstrapExpiryPolicy per docs/spec.md +class QrBootstrapExpiryPolicy( + private val defaultTtlMs: Long = 5L * 60 * 1000, +) { + fun isExpired(payload: QrBootstrapPayload, nowMs: Long): Boolean = payload.isExpired(nowMs) + + fun expiryFor(issuedAtMs: Long): Long = issuedAtMs + defaultTtlMs + + fun issue(workspaceId: String, endpoint: String, token: String, issuedAtMs: Long): QrBootstrapPayload = + QrBootstrapPayload( + workspaceId = workspaceId, + signalingEndpoint = endpoint, + sessionToken = token, + expiresAtMs = expiryFor(issuedAtMs), + ) +} diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/qr/QrBootstrapPayload.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/qr/QrBootstrapPayload.kt index 290bc91..62bd7e6 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/qr/QrBootstrapPayload.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/qr/QrBootstrapPayload.kt @@ -1,3 +1,13 @@ package jp.orgflow.transport.qr -// TODO(spec ch.14-18): implement QrBootstrapPayload per docs/spec.md +import kotlinx.serialization.Serializable + +@Serializable +data class QrBootstrapPayload( + val workspaceId: String, + val signalingEndpoint: String, + val sessionToken: String, + val expiresAtMs: Long, +) { + fun isExpired(nowMs: Long): Boolean = nowMs >= expiresAtMs +} diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/qr/QrBootstrapValidator.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/qr/QrBootstrapValidator.kt index c1ed562..20a09f4 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/qr/QrBootstrapValidator.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/qr/QrBootstrapValidator.kt @@ -1,3 +1,22 @@ package jp.orgflow.transport.qr -// TODO(spec ch.14-18): implement QrBootstrapValidator per docs/spec.md +data class QrValidationResult( + val valid: Boolean, + val payload: QrBootstrapPayload? = null, + val reason: String? = null, +) + +class QrBootstrapValidator( + private val expiryPolicy: QrBootstrapExpiryPolicy = QrBootstrapExpiryPolicy(), + private val nowMs: () -> Long = { 0L }, +) { + fun validate(text: String): QrValidationResult { + val payload = QrBootstrapEncoder.decode(text) + ?: return QrValidationResult(false, reason = "malformed payload") + if (payload.workspaceId.isBlank()) return QrValidationResult(false, payload, "missing workspace") + if (!payload.sessionToken.startsWith("tok-")) return QrValidationResult(false, payload, "bad token format") + if (payload.signalingEndpoint.isBlank()) return QrValidationResult(false, payload, "missing endpoint") + if (expiryPolicy.isExpired(payload, nowMs())) return QrValidationResult(false, payload, "expired") + return QrValidationResult(true, payload) + } +} diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/AnswerSignal.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/AnswerSignal.kt index 6f2a800..1c1fbc5 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/AnswerSignal.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/AnswerSignal.kt @@ -1,3 +1,10 @@ package jp.orgflow.transport.signaling -// TODO(spec ch.14-18): implement AnswerSignal per docs/spec.md +import kotlinx.serialization.Serializable + +@Serializable +data class AnswerSignal( + val sessionId: String, + val sdp: String, + val accepted: Boolean = true, +) diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/IceCandidateSignal.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/IceCandidateSignal.kt index 1761734..ae540f4 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/IceCandidateSignal.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/IceCandidateSignal.kt @@ -1,3 +1,11 @@ package jp.orgflow.transport.signaling -// TODO(spec ch.14-18): implement IceCandidateSignal per docs/spec.md +import kotlinx.serialization.Serializable + +@Serializable +data class IceCandidateSignal( + val sessionId: String, + val candidate: String, + val sdpMid: String, + val sdpMLineIndex: Int, +) diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/KtorSignalingClient.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/KtorSignalingClient.kt index 599db2e..ca6d49f 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/KtorSignalingClient.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/KtorSignalingClient.kt @@ -1,3 +1,74 @@ package jp.orgflow.transport.signaling -// TODO(spec ch.14-18): implement KtorSignalingClient per docs/spec.md +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow + +class KtorSignalingClient( + val endpointUrl: String, + val selfPeerId: String, + private val transmit: suspend (ByteArray) -> Unit, +) : SignalingClient { + + private val _incoming = MutableSharedFlow<SignalingEnvelope>( + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.SUSPEND, + ) + private val _outgoing = MutableSharedFlow<SignalingEnvelope>( + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.SUSPEND, + ) + + override val incoming: SharedFlow<SignalingEnvelope> = _incoming + override var connected: Boolean = false + private set + + override suspend fun connect() { + connected = true + } + + override suspend fun send(envelope: SignalingEnvelope) { + _outgoing.emit(envelope) + transmit(envelopePayload(envelope)) + } + + override suspend fun close() { + connected = false + } + + override fun outgoing(): SharedFlow<SignalingEnvelope> = _outgoing + + suspend fun receive(bytes: ByteArray) { + val envelope = parseEnvelope(bytes) ?: return + if (!envelope.isFor(selfPeerId)) return + _incoming.emit(envelope) + } + + companion object { + fun envelopePayload(envelope: SignalingEnvelope): ByteArray = + listOf(envelope.type, envelope.senderPeerId, envelope.targetPeerId ?: "*", envelope.nonce.toString()) + .joinToString("|") + .encodeToByteArray() + 0.toByte() + envelope.payloadJson.encodeToByteArray() + + fun parseEnvelope(bytes: ByteArray): SignalingEnvelope? { + val split = bytes.indexOf(0) + if (split < 0) return null + val head = bytes.copyOfRange(0, split).decodeToString().split("|") + if (head.size != 4) return null + val nonce = head[3].toLongOrNull() ?: return null + return SignalingEnvelope( + type = head[0], + senderPeerId = head[1], + targetPeerId = head[2].takeIf { it != "*" }, + nonce = nonce, + timestampMs = 0L, + payloadJson = bytes.copyOfRange(split + 1, bytes.size).decodeToString(), + ) + } + + private fun ByteArray.indexOf(byte: Byte): Int { + for (i in indices) if (this[i] == byte) return i + return -1 + } + } +} diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/OfferSignal.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/OfferSignal.kt index a0d7c09..c70fdbc 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/OfferSignal.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/OfferSignal.kt @@ -1,3 +1,12 @@ package jp.orgflow.transport.signaling -// TODO(spec ch.14-18): implement OfferSignal per docs/spec.md +import kotlinx.serialization.Serializable + +@Serializable +data class OfferSignal( + val sessionId: String, + val sdp: String, + val dataChannelLabel: String = "fsmp", +) + +fun OfferSignal.wrapIn(envelope: SignalingEnvelope): SignalingEnvelope = envelope.copy(type = "offer") diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/PeerJoinSignal.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/PeerJoinSignal.kt index 486f9f9..3555df4 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/PeerJoinSignal.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/PeerJoinSignal.kt @@ -1,3 +1,10 @@ package jp.orgflow.transport.signaling -// TODO(spec ch.14-18): implement PeerJoinSignal per docs/spec.md +import kotlinx.serialization.Serializable + +@Serializable +data class PeerJoinSignal( + val peerId: String, + val displayName: String = "", + val capabilitiesJson: String = "{}", +) diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/PeerLeaveSignal.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/PeerLeaveSignal.kt index 09e6456..94a7a7d 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/PeerLeaveSignal.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/PeerLeaveSignal.kt @@ -1,3 +1,9 @@ package jp.orgflow.transport.signaling -// TODO(spec ch.14-18): implement PeerLeaveSignal per docs/spec.md +import kotlinx.serialization.Serializable + +@Serializable +data class PeerLeaveSignal( + val peerId: String, + val reason: String = "bye", +) diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/SignalingClient.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/SignalingClient.kt index cc14a92..5592740 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/SignalingClient.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/SignalingClient.kt @@ -1,3 +1,15 @@ package jp.orgflow.transport.signaling -// TODO(spec ch.14-18): implement SignalingClient per docs/spec.md +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow + +interface SignalingClient { + val incoming: SharedFlow<SignalingEnvelope> + val connected: Boolean + + suspend fun connect() + suspend fun send(envelope: SignalingEnvelope) + suspend fun close() + + fun outgoing(): Flow<SignalingEnvelope> +} diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/SignalingEnvelope.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/SignalingEnvelope.kt index 1c86da6..ef06c7a 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/SignalingEnvelope.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/SignalingEnvelope.kt @@ -1,3 +1,15 @@ package jp.orgflow.transport.signaling -// TODO(spec ch.14-18): implement SignalingEnvelope per docs/spec.md +import kotlinx.serialization.Serializable + +@Serializable +data class SignalingEnvelope( + val type: String, + val senderPeerId: String, + val targetPeerId: String? = null, + val nonce: Long, + val timestampMs: Long, + val payloadJson: String, +) { + fun isFor(peerId: String): Boolean = targetPeerId == null || targetPeerId == peerId +} diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/CandidateObservationMapper.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/CandidateObservationMapper.kt index 3ed01c8..ea15038 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/CandidateObservationMapper.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/CandidateObservationMapper.kt @@ -1,3 +1,36 @@ package jp.orgflow.transport.webrtc -// TODO(spec ch.14-18): implement CandidateObservationMapper per docs/spec.md +data class CandidateObservation( + val candidate: String, + val type: CandidateType, + val rttMs: Double, +) + +enum class CandidateType { HOST, SRFLX, RELAY } + +data class CandidateScore( + val candidate: String, + val type: CandidateType, + val score: Double, +) + +class CandidateObservationMapper( + private val localityBias: Double = 1.0, +) { + fun map(observation: CandidateObservation): CandidateScore { + val base = when (observation.type) { + CandidateType.HOST -> 1.0 + CandidateType.SRFLX -> 0.6 + CandidateType.RELAY -> 0.2 + } + val rttPenalty = (observation.rttMs / 1000.0).coerceIn(0.0, 1.0) + return CandidateScore( + candidate = observation.candidate, + type = observation.type, + score = localityBias * base * (1.0 - rttPenalty), + ) + } + + fun rank(observations: List<CandidateObservation>): List<CandidateScore> = + observations.map { map(it) }.sortedByDescending { it.score } +} diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/DataChannelBackpressureController.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/DataChannelBackpressureController.kt index 9d224f9..80755c3 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/DataChannelBackpressureController.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/DataChannelBackpressureController.kt @@ -1,3 +1,43 @@ package jp.orgflow.transport.webrtc -// TODO(spec ch.14-18): implement DataChannelBackpressureController per docs/spec.md +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.fsmp.error.FsmpError +import jp.orgflow.fsmp.error.FsmpErrorCode +import jp.orgflow.fsmp.error.FsmpProtocolException + +class DataChannelBackpressureController( + private val highWatermarkBytes: Long = 1L shl 20, + private val lowWatermarkBytes: Long = 512L * 1024, + private val maxInFlightBytes: Long = 4L shl 20, + private val perPeerBandwidthKbps: Int = 512, +) { + private val pausedPeers = mutableSetOf<PeerId>() + private val inFlight = mutableMapOf<PeerId, Long>() + + fun isPaused(peerId: PeerId): Boolean = peerId in pausedPeers + + fun onBufferedAmountChange(peerId: PeerId, bufferedAmount: Long) { + if (bufferedAmount >= highWatermarkBytes) pausedPeers += peerId + else if (bufferedAmount <= lowWatermarkBytes) pausedPeers -= peerId + } + + fun beforeSend(peerId: PeerId, bytes: ByteArray) { + if (peerId in pausedPeers) { + throw FsmpProtocolException(FsmpError(FsmpErrorCode.BUDGET_EXCEEDED, "backpressure paused for ${peerId.value}")) + } + val current = inFlight[peerId] ?: 0L + if (current + bytes.size > maxInFlightBytes) { + throw FsmpProtocolException(FsmpError(FsmpErrorCode.BUDGET_EXCEEDED, "in-flight budget exceeded for ${peerId.value}")) + } + inFlight[peerId] = current + bytes.size + } + + fun onAck(peerId: PeerId, ackedBytes: Long) { + val current = inFlight[peerId] ?: 0L + inFlight[peerId] = (current - ackedBytes).coerceAtLeast(0L) + } + + fun budgetPerTickBytes(): Long = perPeerBandwidthKbps.toLong() * 128 + + fun inFlightBytes(peerId: PeerId): Long = inFlight[peerId] ?: 0L +} diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/FsmpDataChannelSession.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/FsmpDataChannelSession.kt index 37d1a39..7fd1e88 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/FsmpDataChannelSession.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/FsmpDataChannelSession.kt @@ -1,3 +1,30 @@ package jp.orgflow.transport.webrtc -// TODO(spec ch.14-18): implement FsmpDataChannelSession per docs/spec.md +import jp.orgflow.domain.identity.PeerId + +interface DataChannelPort { + val peerId: PeerId + val bufferedAmountBytes: Long + val open: Boolean + + fun send(bytes: ByteArray): Boolean + + fun setOnMessage(handler: (ByteArray) -> Unit) + fun setOnBufferedAmountChange(handler: (Long) -> Unit) + fun close() +} + +class FsmpDataChannelSession( + private val port: DataChannelPort, + private val onFrame: (PeerId, ByteArray) -> Unit, +) { + val peerId: PeerId get() = port.peerId + + fun send(bytes: ByteArray): Boolean = port.open && port.send(bytes) + + fun attach() { + port.setOnMessage { bytes -> onFrame(peerId, bytes) } + } + + fun close() = port.close() +} diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/FsmpPeerConnectionController.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/FsmpPeerConnectionController.kt index 66a1039..59e1c4d 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/FsmpPeerConnectionController.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/FsmpPeerConnectionController.kt @@ -1,3 +1,17 @@ package jp.orgflow.transport.webrtc -// TODO(spec ch.14-18): implement FsmpPeerConnectionController per docs/spec.md +import jp.orgflow.domain.identity.PeerId + +interface FsmpPeerConnectionController { + val peerId: PeerId + + suspend fun createOffer(sessionId: String): String + suspend fun acceptOffer(sessionId: String, offerSdp: String): String + suspend fun addIceCandidate(candidate: String, sdpMid: String, sdpMLineIndex: Int) + suspend fun close() + + data class Events( + val onStateChange: suspend (String) -> Unit, + val onIceCandidate: suspend (String, String, Int) -> Unit, + ) +} diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/KtorWebRtcFsmpTransport.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/KtorWebRtcFsmpTransport.kt index 4c03670..be16914 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/KtorWebRtcFsmpTransport.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/KtorWebRtcFsmpTransport.kt @@ -1,3 +1,74 @@ package jp.orgflow.transport.webrtc -// TODO(spec ch.14-18): implement KtorWebRtcFsmpTransport per docs/spec.md +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.transport.api.FsmpTransport +import jp.orgflow.transport.api.FsmpTransportEvent +import jp.orgflow.transport.api.FsmpTransportFrame +import jp.orgflow.transport.api.FsmpTransportState +import jp.orgflow.transport.signaling.KtorSignalingClient +import jp.orgflow.transport.signaling.SignalingClient +import jp.orgflow.transport.signaling.SignalingEnvelope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.filter + +class KtorWebRtcFsmpTransport( + override val peerId: PeerId, + private val signaling: SignalingClient, + private val backpressure: DataChannelBackpressureController = DataChannelBackpressureController(), + private val channelFactory: (PeerId) -> DataChannelPort?, +) : FsmpTransport { + + private val channels = mutableMapOf<PeerId, FsmpDataChannelSession>() + private val _state = MutableSharedFlow<FsmpTransportEvent>(extraBufferCapacity = 64) + private val _incoming = MutableSharedFlow<Pair<PeerId, ByteArray>>(extraBufferCapacity = 256) + + override var state: FsmpTransportState = FsmpTransportState.IDLE + private set + + override val events: SharedFlow<FsmpTransportEvent> = _state + override val incoming: SharedFlow<Pair<PeerId, ByteArray>> = _incoming + + override suspend fun connect(target: PeerId) { + state = FsmpTransportState.CONNECTING + val port = channelFactory(target) ?: run { + state = FsmpTransportState.FAILED + return + } + val session = FsmpDataChannelSession(port) { from, bytes -> _incoming.tryEmit(from to bytes) } + session.attach() + port.setOnBufferedAmountChange { backpressure.onBufferedAmountChange(target, it) } + channels[target] = session + state = FsmpTransportState.CONNECTED + _state.tryEmit(FsmpTransportEvent.StateChanged(target, state)) + } + + override suspend fun disconnect(target: PeerId) { + channels.remove(target)?.close() + _state.tryEmit(FsmpTransportEvent.StateChanged(target, FsmpTransportState.CLOSED)) + } + + override suspend fun send(target: PeerId, bytes: ByteArray): Boolean { + val session = channels[target] ?: return false + return try { + backpressure.beforeSend(target, bytes) + session.send(bytes) + } catch (e: Exception) { + _state.tryEmit(FsmpTransportEvent.Backpressure(target, paused = true)) + false + } + } + + override suspend fun close() { + channels.values.forEach { it.close() } + channels.clear() + state = FsmpTransportState.CLOSED + signaling.close() + } + + companion object { + fun frameEvents(incoming: Flow<Pair<PeerId, ByteArray>>): Flow<Pair<PeerId, ByteArray>> = + incoming.filter { it.second.size >= 4 } + } +} diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/RouteFeedbackEmitter.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/RouteFeedbackEmitter.kt index dab650e..eef23c2 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/RouteFeedbackEmitter.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/RouteFeedbackEmitter.kt @@ -1,3 +1,25 @@ package jp.orgflow.transport.webrtc -// TODO(spec ch.14-18): implement RouteFeedbackEmitter per docs/spec.md +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.fsmp.message.RouteFeedback + +class RouteFeedbackEmitter(private val streamId: String = "route") { + + fun feedback(peerId: PeerId, success: Boolean, rttMs: Double, kind: String): RouteFeedback = RouteFeedback( + streamId = streamId, + fromPeerId = "self", + toPeerId = peerId.value, + success = success, + rttMs = rttMs, + kind = kind, + ) + + fun fromScores(peerId: PeerId, scores: List<CandidateScore>): RouteFeedback = RouteFeedback( + streamId = streamId, + fromPeerId = "self", + toPeerId = peerId.value, + success = true, + rttMs = scores.firstOrNull()?.score ?: 0.0, + kind = "ROUTE_SCORE", + ) +} diff --git a/modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/DataChannelBackpressureTest.kt b/modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/DataChannelBackpressureTest.kt index bb0034d..3247f9f 100644 --- a/modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/DataChannelBackpressureTest.kt +++ b/modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/DataChannelBackpressureTest.kt @@ -1,3 +1,53 @@ package jp.orgflow.transport -// TODO(spec ch.14-18): implement DataChannelBackpressureTest per docs/spec.md +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.fsmp.error.FsmpErrorCode +import jp.orgflow.fsmp.error.FsmpProtocolException +import jp.orgflow.transport.webrtc.DataChannelBackpressureController +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DataChannelBackpressureTest { + + private val controller = DataChannelBackpressureController( + highWatermarkBytes = 1000, + lowWatermarkBytes = 500, + maxInFlightBytes = 2000, + ) + private val peer = PeerId("p") + + @Test + fun pausesAtHighWatermark() { + assertFalse(controller.isPaused(peer)) + controller.onBufferedAmountChange(peer, 1000) + assertTrue(controller.isPaused(peer)) + } + + @Test + fun hysteresisResumesBelowLowWatermark() { + controller.onBufferedAmountChange(peer, 1000) + controller.onBufferedAmountChange(peer, 600) + assertTrue(controller.isPaused(peer)) + controller.onBufferedAmountChange(peer, 500) + assertFalse(controller.isPaused(peer)) + } + + @Test + fun pausedPeerRejectsSend() { + controller.onBufferedAmountChange(peer, 2000) + val ex = assertFailsWith<FsmpProtocolException> { controller.beforeSend(peer, ByteArray(10)) } + assertEquals(FsmpErrorCode.BUDGET_EXCEEDED, ex.error.code) + } + + @Test + fun inFlightBudgetEnforcedAndReleased() { + controller.beforeSend(peer, ByteArray(1500)) + assertEquals(1500L, controller.inFlightBytes(peer)) + assertFailsWith<FsmpProtocolException> { controller.beforeSend(peer, ByteArray(600)) } + controller.onAck(peer, 1500L) + assertEquals(0L, controller.inFlightBytes(peer)) + } +} diff --git a/modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/QrBootstrapValidatorTest.kt b/modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/QrBootstrapValidatorTest.kt index bfd9091..00bab6d 100644 --- a/modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/QrBootstrapValidatorTest.kt +++ b/modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/QrBootstrapValidatorTest.kt @@ -1,3 +1,64 @@ package jp.orgflow.transport -// TODO(spec ch.14-18): implement QrBootstrapValidatorTest per docs/spec.md +import jp.orgflow.transport.qr.QrBootstrapPayload +import jp.orgflow.transport.qr.QrBootstrapEncoder +import jp.orgflow.transport.qr.QrBootstrapValidator +import jp.orgflow.transport.qr.QrBootstrapExpiryPolicy +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class QrBootstrapValidatorTest { + + private fun payload(expiresAtMs: Long = 10_000) = QrBootstrapPayload( + workspaceId = "ws-1", + signalingEndpoint = "http://192.168.3.110:8080/signal", + sessionToken = "tok-abc123", + expiresAtMs = expiresAtMs, + ) + + @Test + fun roundtrip() { + val encoded = QrBootstrapEncoder.encode(payload()) + val decoded = QrBootstrapEncoder.decode(encoded) + assertNotNull(decoded) + assertEquals("ws-1", decoded.workspaceId) + assertEquals("tok-abc123", decoded.sessionToken) + } + + @Test + fun validPayloadPasses() { + val encoded = QrBootstrapEncoder.encode(payload()) + val validator = QrBootstrapValidator(nowMs = { 5_000L }) + val result = validator.validate(encoded) + assertTrue(result.valid) + assertEquals("ws-1", result.payload?.workspaceId) + } + + @Test + fun expiredPayloadRejected() { + val encoded = QrBootstrapEncoder.encode(payload(expiresAtMs = 4_000)) + val validator = QrBootstrapValidator(nowMs = { 5_000L }) + val result = validator.validate(encoded) + assertFalse(result.valid) + assertEquals("expired", result.reason) + } + + @Test + fun garbageRejected() { + val validator = QrBootstrapValidator(nowMs = { 0L }) + assertFalse(validator.validate("not-a-qr").valid) + assertFalse(validator.validate("FSMP1:###").valid) + } + + @Test + fun issuePolicyProducesValidWindow() { + val policy = QrBootstrapExpiryPolicy(defaultTtlMs = 1000) + val p = policy.issue("ws", "http://x", "tok-1", issuedAtMs = 100) + assertEquals(1100L, p.expiresAtMs) + assertTrue(policy.isExpired(p, nowMs = 1100)) + assertFalse(policy.isExpired(p, nowMs = 1099)) + } +} diff --git a/modules/orgflow-capture/build.gradle.kts b/modules/orgflow-capture/build.gradle.kts index bee562c..415430d 100644 --- a/modules/orgflow-capture/build.gradle.kts +++ b/modules/orgflow-capture/build.gradle.kts @@ -1,6 +1,26 @@ -// Placeholder module (spec ch.24 tree). Division owning this chapter upgrades -// this file to a KMP config when implementation starts. See NEMOTRON.md. -plugins { `base` } +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) +} group = "net.kukuri" version = "0.1.0" + +kotlin { + jvm() + wasmJs { nodejs() } + linuxX64() + + sourceSets { + commonMain.dependencies { + implementation(libs.serialization.json) + implementation(libs.coroutines.core) + implementation(libs.datetime) + implementation(project(":modules:orgflow-domain")) + implementation(project(":modules:orgflow-org")) + implementation(project(":modules:orgflow-content-store")) + } + commonTest.dependencies { implementation(libs.kotlin.test) } + jvmTest.dependencies { implementation(libs.kotlin.test) } + } +} diff --git a/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureCommitCoordinator.kt b/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureCommitCoordinator.kt index 0ceccd6..723c580 100644 --- a/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureCommitCoordinator.kt +++ b/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureCommitCoordinator.kt @@ -1,3 +1,43 @@ package jp.orgflow.capture -// TODO(spec ch.05): implement CaptureCommitCoordinator per docs/spec.md +interface CaptureCommitDelegate { + fun commitBatch(candidates: List<CaptureCommitCandidate>): List<String> +} + +class CaptureCommitCoordinator( + private val delegate: CaptureCommitDelegate, + private val batchWindowMillis: Long = 500L, +) { + private val pending = LinkedHashMap<String, CaptureCommitCandidate>() + private var windowStartMillis: Long? = null + + fun submit(result: CaptureResult, nowMillis: Long): Boolean { + val candidate = result.commitCandidate ?: return false + return submit(candidate, nowMillis) + } + + fun submit(candidate: CaptureCommitCandidate, nowMillis: Long): Boolean { + val start = windowStartMillis + if (start != null && pending.isNotEmpty() && nowMillis - start >= batchWindowMillis) { + flush() + } + if (pending.isEmpty()) { + windowStartMillis = nowMillis + } + if (pending.containsKey(candidate.dedupeKey)) { + return false + } + pending[candidate.dedupeKey] = candidate + return true + } + + fun flush(): List<String> { + if (pending.isEmpty()) return emptyList() + val committed = delegate.commitBatch(pending.values.toList()) + pending.clear() + windowStartMillis = null + return committed + } + + fun pendingCount(): Int = pending.size +} diff --git a/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureField.kt b/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureField.kt index 530f308..78819a8 100644 --- a/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureField.kt +++ b/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureField.kt @@ -1,3 +1,58 @@ package jp.orgflow.capture -// TODO(spec ch.05): implement CaptureField per docs/spec.md +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime + +enum class CaptureField { + WHEN, + WHERE, + WHO, + WHAT, + WHY, + HOW; + + val label: String + get() = when (this) { + WHEN -> "When" + WHERE -> "Where" + WHO -> "Who" + WHAT -> "What" + WHY -> "Why" + HOW -> "How" + } +} + +sealed interface CaptureFieldValue { + data class WhenValue( + val dateTime: LocalDateTime? = null, + val date: LocalDate? = null, + ) : CaptureFieldValue { + val present: Boolean get() = dateTime != null || date != null + + fun toIso(): String = (dateTime ?: date)?.toString() ?: "" + } + + data class TextValue(val text: String) : CaptureFieldValue { + val isBlank: Boolean get() = text.isBlank() + } + + data class MembersValue(val memberIds: List<String>) : CaptureFieldValue { + val isBlank: Boolean get() = memberIds.isEmpty() + } + + companion object { + fun whenDateTime(dateTime: LocalDateTime): CaptureFieldValue = WhenValue(dateTime = dateTime) + + fun whenDate(date: LocalDate): CaptureFieldValue = WhenValue(date = date) + + fun text(text: String): CaptureFieldValue = TextValue(text) + + fun members(vararg memberIds: String): CaptureFieldValue = MembersValue(memberIds.toList()) + } +} + +fun CaptureFieldValue.asWhenValue(): CaptureFieldValue.WhenValue? = this as? CaptureFieldValue.WhenValue + +fun CaptureFieldValue.asTextValue(): CaptureFieldValue.TextValue? = this as? CaptureFieldValue.TextValue + +fun CaptureFieldValue.asMembersValue(): CaptureFieldValue.MembersValue? = this as? CaptureFieldValue.MembersValue diff --git a/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureRequest.kt b/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureRequest.kt index 28a2642..8587c10 100644 --- a/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureRequest.kt +++ b/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureRequest.kt @@ -1,3 +1,17 @@ package jp.orgflow.capture -// TODO(spec ch.05): implement CaptureRequest per docs/spec.md +import jp.orgflow.contentstore.Cid +import kotlinx.datetime.LocalDateTime + +data class CaptureRequest( + val type: CaptureType? = null, + val fields: Map<CaptureField, CaptureFieldValue> = emptyMap(), + val attachments: List<Cid> = emptyList(), + val capturedAt: LocalDateTime, +) { + fun fieldValue(field: CaptureField): CaptureFieldValue? = fields[field] + + fun whatText(): String = fields[CaptureField.WHAT]?.asTextValue()?.text ?: "" + + fun whenValue(): CaptureFieldValue.WhenValue? = fields[CaptureField.WHEN]?.asWhenValue() +} diff --git a/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureResult.kt b/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureResult.kt index 2e3a6fa..e7464e1 100644 --- a/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureResult.kt +++ b/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureResult.kt @@ -1,3 +1,30 @@ package jp.orgflow.capture -// TODO(spec ch.05): implement CaptureResult per docs/spec.md +import jp.orgflow.contentstore.Cid +import kotlinx.datetime.LocalDateTime + +data class CaptureError( + val field: CaptureField?, + val message: String, +) + +data class CaptureCommitCandidate( + val type: CaptureType, + val title: String, + val orgSnippet: String, + val attachmentCids: List<Cid>, + val dedupeKey: String, + val createdAt: LocalDateTime, +) + +data class CaptureResult( + val type: CaptureType, + val detectedType: CaptureType?, + val template: CaptureTemplate, + val orgSnippet: String, + val attachments: List<Cid> = emptyList(), + val commitCandidate: CaptureCommitCandidate? = null, + val errors: List<CaptureError> = emptyList(), +) { + val isValid: Boolean get() = errors.isEmpty() && commitCandidate != null +} diff --git a/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureService.kt b/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureService.kt index 5fd2ade..a02a7a9 100644 --- a/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureService.kt +++ b/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureService.kt @@ -1,3 +1,134 @@ package jp.orgflow.capture -// TODO(spec ch.05): implement CaptureService per docs/spec.md +import jp.orgflow.contentstore.Cid +import jp.orgflow.org.model.OrgElement +import jp.orgflow.org.model.OrgHeading +import jp.orgflow.org.model.OrgParagraph +import jp.orgflow.org.model.OrgPlanning +import jp.orgflow.org.model.OrgPropertyDrawer +import jp.orgflow.org.model.OrgTag +import jp.orgflow.org.writer.OrgSubsetWriter + +class CaptureService( + private val registry: CaptureTemplateRegistry = CaptureTemplateRegistry(), +) { + private val writer = OrgSubsetWriter() + + fun detectType(text: String, hasAttachments: Boolean = false): CaptureType { + val trimmed = text.trim() + if (CHECKBOX_RX.containsMatchIn(trimmed)) return CaptureType.TODO + if (DATE_LIKE_RX.containsMatchIn(trimmed)) return CaptureType.SCHEDULE + if (trimmed.isEmpty() && hasAttachments) return CaptureType.MEDIA + return CaptureType.NOTE + } + + fun capture(request: CaptureRequest): CaptureResult { + val detected = request.type ?: run { + val textual = detectType(request.whatText(), request.attachments.isNotEmpty()) + val hasBody = request.whatText().isNotBlank() + if (textual == CaptureType.NOTE && hasBody && request.whenValue()?.present == true) CaptureType.SCHEDULE else textual + } + val template = registry.requireTemplate(detected) + val errors = validate(request, template) + if (errors.isNotEmpty()) { + return CaptureResult( + type = detected, + detectedType = if (request.type == null) detected else null, + template = template, + orgSnippet = "", + attachments = request.attachments, + errors = errors, + ) + } + val snippet = toOrgSnippet(request, template) + val title = buildTitle(request, template) + val candidate = CaptureCommitCandidate( + type = detected, + title = title, + orgSnippet = snippet, + attachmentCids = request.attachments, + dedupeKey = dedupeKey(detected, title, request.attachments), + createdAt = request.capturedAt, + ) + return CaptureResult( + type = detected, + detectedType = if (request.type == null) detected else null, + template = template, + orgSnippet = snippet, + attachments = request.attachments, + commitCandidate = candidate, + ) + } + + fun validate(request: CaptureRequest, template: CaptureTemplate): List<CaptureError> { + val errors = mutableListOf<CaptureError>() + template.requiredFields.forEach { field -> + val missing = when (val value = request.fields[field]) { + null -> true + is CaptureFieldValue.WhenValue -> !value.present + is CaptureFieldValue.TextValue -> value.isBlank + is CaptureFieldValue.MembersValue -> value.isBlank + } + if (missing) errors.add(CaptureError(field, "${template.labelFor(field)} is required")) + } + if (template.requiresAttachment && request.attachments.isEmpty()) { + errors.add(CaptureError(null, "At least one attachment is required")) + } + return errors + } + + fun toOrgSnippet(request: CaptureRequest, template: CaptureTemplate): String { + val heading = OrgHeading( + level = HEADING_LEVEL, + title = buildTitle(request, template), + tags = template.tags.map { OrgTag(it) }, + children = buildChildren(request, template), + ) + val out = StringBuilder() + writer.writeElement(out, heading) + return out.toString().trimEnd('\n') + } + + private fun buildChildren(request: CaptureRequest, template: CaptureTemplate): List<OrgElement> { + val children = mutableListOf<OrgElement>() + val whenValue = request.whenValue() + if (template.planningKind != null && whenValue != null && whenValue.present) { + children.add(OrgPlanning(template.planningKind, whenValue.toIso())) + } + val properties = linkedMapOf<String, String>() + template.orgMapping.forEach { (field, key) -> + if (field == CaptureField.WHEN && template.planningKind != null) return@forEach + when (val value = request.fields[field]) { + is CaptureFieldValue.WhenValue -> + if (value.present) properties[key] = value.toIso() + is CaptureFieldValue.TextValue -> + if (!value.isBlank) properties[key] = value.text + is CaptureFieldValue.MembersValue -> + if (!value.isBlank) properties[key] = value.memberIds.joinToString(",") + null -> {} + } + } + if (request.attachments.isNotEmpty()) { + properties["ATTACHMENTS"] = request.attachments.joinToString(" ") { it.toString() } + } + if (properties.isNotEmpty()) children.add(OrgPropertyDrawer(properties)) + val whatText = request.whatText().trim() + if (whatText.isNotEmpty()) children.add(OrgParagraph(whatText.lines())) + return children + } + + private fun buildTitle(request: CaptureRequest, template: CaptureTemplate): String { + val base = request.whatText().lines().firstOrNull { it.isNotBlank() }?.trim() ?: "" + val keyword = template.todoKeyword?.let { "$it " } ?: "" + return "$keyword${template.titlePrefix}$base" + } + + private fun dedupeKey(type: CaptureType, title: String, attachments: List<Cid>): String = + type.name + "|" + title + "|" + attachments.joinToString(",") { it.toString() } + + companion object { + private const val HEADING_LEVEL = 2 + private val CHECKBOX_RX = Regex("""\[[ xX]\]""") + private val DATE_LIKE_RX = Regex("""\d{4}-\d{1,2}-\d{1,2}|\d{1,2}:\d{2}""") + } +} diff --git a/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureTemplate.kt b/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureTemplate.kt index 26fd212..6484bd2 100644 --- a/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureTemplate.kt +++ b/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureTemplate.kt @@ -1,3 +1,29 @@ package jp.orgflow.capture -// TODO(spec ch.05): implement CaptureTemplate per docs/spec.md +import jp.orgflow.org.model.PlanningKind + +enum class CaptureType { + NOTE, + SCHEDULE, + TODO, + EXPERIMENT, + MEDIA, +} + +data class CaptureTemplate( + val type: CaptureType, + val requiredFields: Set<CaptureField>, + val orgMapping: Map<CaptureField, String>, + val fieldLabels: Map<CaptureField, String> = emptyMap(), + val tags: List<String> = emptyList(), + val todoKeyword: String? = null, + val planningKind: PlanningKind? = null, + val titlePrefix: String = "", + val requiresAttachment: Boolean = false, +) { + fun isRequired(field: CaptureField): Boolean = field in requiredFields + + fun labelFor(field: CaptureField): String = fieldLabels[field] ?: field.label + + fun orgPropertyKey(field: CaptureField): String? = orgMapping[field] +} diff --git a/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureTemplateRegistry.kt b/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureTemplateRegistry.kt index 319465b..1ae8e12 100644 --- a/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureTemplateRegistry.kt +++ b/modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureTemplateRegistry.kt @@ -1,3 +1,133 @@ package jp.orgflow.capture -// TODO(spec ch.05): implement CaptureTemplateRegistry per docs/spec.md +import jp.orgflow.org.model.PlanningKind + +class CaptureTemplateRegistry(private val templates: Map<CaptureType, CaptureTemplate> = defaults()) { + + fun templateFor(type: CaptureType): CaptureTemplate? = templates[type] + + fun requireTemplate(type: CaptureType): CaptureTemplate = + templates[type] ?: throw IllegalArgumentException("no capture template registered for ${type.name}") + + fun all(): Map<CaptureType, CaptureTemplate> = templates + + companion object { + fun defaults(): Map<CaptureType, CaptureTemplate> = mapOf( + CaptureType.NOTE to noteTemplate(), + CaptureType.SCHEDULE to scheduleTemplate(), + CaptureType.TODO to todoTemplate(), + CaptureType.EXPERIMENT to experimentTemplate(), + CaptureType.MEDIA to mediaTemplate(), + ) + + fun noteTemplate(): CaptureTemplate = CaptureTemplate( + type = CaptureType.NOTE, + requiredFields = setOf(CaptureField.WHAT), + orgMapping = mapOf( + CaptureField.WHEN to "DATE", + CaptureField.WHERE to "LOCATION", + CaptureField.WHO to "PARTICIPANTS", + CaptureField.WHY to "PURPOSE", + CaptureField.HOW to "METHOD", + ), + fieldLabels = mapOf( + CaptureField.WHEN to "Recorded at", + CaptureField.WHERE to "Place", + CaptureField.WHO to "Participants", + CaptureField.WHAT to "Body", + CaptureField.WHY to "Background", + CaptureField.HOW to "Means", + ), + tags = listOf("note"), + ) + + fun scheduleTemplate(): CaptureTemplate = CaptureTemplate( + type = CaptureType.SCHEDULE, + requiredFields = setOf(CaptureField.WHEN, CaptureField.WHAT), + orgMapping = mapOf( + CaptureField.WHEN to "SCHEDULED", + CaptureField.WHERE to "LOCATION", + CaptureField.WHO to "PARTICIPANTS", + CaptureField.WHY to "PURPOSE", + CaptureField.HOW to "PREPARATION", + ), + fieldLabels = mapOf( + CaptureField.WHEN to "Start datetime", + CaptureField.WHERE to "Place", + CaptureField.WHO to "Participants", + CaptureField.WHAT to "Content", + CaptureField.WHY to "Purpose", + CaptureField.HOW to "Preparation", + ), + planningKind = PlanningKind.SCHEDULED, + tags = listOf("schedule"), + ) + + fun todoTemplate(): CaptureTemplate = CaptureTemplate( + type = CaptureType.TODO, + requiredFields = setOf(CaptureField.WHAT), + orgMapping = mapOf( + CaptureField.WHEN to "DEADLINE", + CaptureField.WHERE to "LOCATION", + CaptureField.WHO to "ASSIGNEES", + CaptureField.WHY to "PURPOSE", + CaptureField.HOW to "HOWTO", + ), + fieldLabels = mapOf( + CaptureField.WHEN to "Deadline", + CaptureField.WHERE to "Place", + CaptureField.WHO to "Assignees", + CaptureField.WHAT to "Task", + CaptureField.WHY to "Purpose", + CaptureField.HOW to "Steps", + ), + todoKeyword = "TODO", + planningKind = PlanningKind.DEADLINE, + tags = listOf("todo"), + ) + + fun experimentTemplate(): CaptureTemplate = CaptureTemplate( + type = CaptureType.EXPERIMENT, + requiredFields = setOf(CaptureField.WHEN, CaptureField.WHAT), + orgMapping = mapOf( + CaptureField.WHEN to "DATE", + CaptureField.WHERE to "LOCATION", + CaptureField.WHO to "EXPERIMENTER", + CaptureField.WHY to "HYPOTHESIS", + CaptureField.HOW to "MEASUREMENT", + ), + fieldLabels = mapOf( + CaptureField.WHEN to "Experiment date", + CaptureField.WHERE to "Experiment place", + CaptureField.WHO to "Experimenter", + CaptureField.WHAT to "Procedure", + CaptureField.WHY to "Hypothesis", + CaptureField.HOW to "Measurement method", + ), + titlePrefix = "Experiment: ", + tags = listOf("experiment"), + ) + + fun mediaTemplate(): CaptureTemplate = CaptureTemplate( + type = CaptureType.MEDIA, + requiredFields = setOf(CaptureField.WHAT), + orgMapping = mapOf( + CaptureField.WHEN to "DATE", + CaptureField.WHERE to "LOCATION", + CaptureField.WHO to "PARTICIPANTS", + CaptureField.WHY to "PURPOSE", + CaptureField.HOW to "SHOOTING_SETUP", + ), + fieldLabels = mapOf( + CaptureField.WHEN to "Captured at", + CaptureField.WHERE to "Place", + CaptureField.WHO to "Participants", + CaptureField.WHAT to "Description", + CaptureField.WHY to "Purpose", + CaptureField.HOW to "Shooting setup", + ), + tags = listOf("media"), + requiresAttachment = true, + ) + } +} diff --git a/modules/orgflow-capture/src/commonTest/kotlin/jp/orgflow/capture/CaptureCommitCoordinatorTest.kt b/modules/orgflow-capture/src/commonTest/kotlin/jp/orgflow/capture/CaptureCommitCoordinatorTest.kt index 495f3ec..503a647 100644 --- a/modules/orgflow-capture/src/commonTest/kotlin/jp/orgflow/capture/CaptureCommitCoordinatorTest.kt +++ b/modules/orgflow-capture/src/commonTest/kotlin/jp/orgflow/capture/CaptureCommitCoordinatorTest.kt @@ -1,3 +1,88 @@ package jp.orgflow.capture -// TODO(spec ch.05): implement CaptureCommitCoordinatorTest per docs/spec.md +import kotlinx.datetime.LocalDateTime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CaptureCommitCoordinatorTest { + + private class RecordingDelegate : CaptureCommitDelegate { + val batches = mutableListOf<List<CaptureCommitCandidate>>() + private var commitCounter = 0 + + override fun commitBatch(candidates: List<CaptureCommitCandidate>): List<String> { + batches.add(candidates) + return candidates.map { "commit-${commitCounter++}" } + } + } + + private fun candidate(key: String): CaptureCommitCandidate = CaptureCommitCandidate( + type = CaptureType.NOTE, + title = "title-$key", + orgSnippet = "** title-$key", + attachmentCids = emptyList(), + dedupeKey = key, + createdAt = LocalDateTime(2026, 8, 27, 9, 0), + ) + + @Test + fun deduplicatesSameCandidateWithinWindow() { + val delegate = RecordingDelegate() + val coordinator = CaptureCommitCoordinator(delegate, batchWindowMillis = 500) + assertTrue(coordinator.submit(candidate("a"), 0)) + assertFalse(coordinator.submit(candidate("a"), 100)) + assertEquals(1, coordinator.pendingCount()) + } + + @Test + fun batchesSubmissionsWithinWindowIntoSingleCommit() { + val delegate = RecordingDelegate() + val coordinator = CaptureCommitCoordinator(delegate, batchWindowMillis = 500) + assertTrue(coordinator.submit(candidate("a"), 0)) + assertTrue(coordinator.submit(candidate("b"), 200)) + val receipts = coordinator.flush() + assertEquals(listOf("commit-0", "commit-1"), receipts) + assertEquals(1, delegate.batches.size) + assertEquals(listOf("a", "b"), delegate.batches.single().map { it.dedupeKey }) + assertEquals(0, coordinator.pendingCount()) + } + + @Test + fun submittingAfterWindowAutoFlushesPreviousBatch() { + val delegate = RecordingDelegate() + val coordinator = CaptureCommitCoordinator(delegate, batchWindowMillis = 500) + coordinator.submit(candidate("a"), 0) + coordinator.submit(candidate("b"), 200) + coordinator.submit(candidate("c"), 1000) + assertEquals(listOf(listOf("a", "b")), delegate.batches.map { batch -> batch.map { it.dedupeKey } }) + assertEquals(1, coordinator.pendingCount()) + coordinator.flush() + assertEquals(2, delegate.batches.size) + assertEquals(listOf("c"), delegate.batches[1].map { it.dedupeKey }) + } + + @Test + fun flushOnEmptyWindowCommitsNothing() { + val delegate = RecordingDelegate() + val coordinator = CaptureCommitCoordinator(delegate) + assertEquals(emptyList(), coordinator.flush()) + assertEquals(0, delegate.batches.size) + } + + @Test + fun rejectsInvalidCaptureResults() { + val delegate = RecordingDelegate() + val coordinator = CaptureCommitCoordinator(delegate) + val invalid = CaptureResult( + type = CaptureType.SCHEDULE, + detectedType = null, + template = CaptureTemplateRegistry.scheduleTemplate(), + orgSnippet = "", + errors = listOf(CaptureError(CaptureField.WHEN, "Start datetime is required")), + ) + assertFalse(coordinator.submit(invalid, 0)) + assertEquals(0, coordinator.pendingCount()) + } +} diff --git a/modules/orgflow-capture/src/commonTest/kotlin/jp/orgflow/capture/CaptureServiceTest.kt b/modules/orgflow-capture/src/commonTest/kotlin/jp/orgflow/capture/CaptureServiceTest.kt index 07233ba..0fb70a9 100644 --- a/modules/orgflow-capture/src/commonTest/kotlin/jp/orgflow/capture/CaptureServiceTest.kt +++ b/modules/orgflow-capture/src/commonTest/kotlin/jp/orgflow/capture/CaptureServiceTest.kt @@ -1,3 +1,175 @@ package jp.orgflow.capture -// TODO(spec ch.05): implement CaptureServiceTest per docs/spec.md +import jp.orgflow.contentstore.Cid +import kotlinx.datetime.LocalDateTime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CaptureServiceTest { + + private val service = CaptureService() + private val capturedAt = LocalDateTime(2026, 8, 27, 9, 30) + + @Test + fun detectsTodoFromCheckboxText() { + assertEquals(CaptureType.TODO, service.detectType("[ ] write the report")) + assertEquals(CaptureType.TODO, service.detectType("[x] done item with 2026-09-01 date")) + } + + @Test + fun detectsScheduleFromDateLikeText() { + assertEquals(CaptureType.SCHEDULE, service.detectType("lab meeting 2026-09-01")) + assertEquals(CaptureType.SCHEDULE, service.detectType("standup at 10:00")) + } + + @Test + fun detectsNoteForPlainText() { + assertEquals(CaptureType.NOTE, service.detectType("observed something odd in sample C")) + } + + @Test + fun detectsMediaWhenOnlyAttachmentsAreGiven() { + assertEquals(CaptureType.MEDIA, service.detectType("", hasAttachments = true)) + } + + @Test + fun scheduleCaptureProducesPlanningLineAndPropertyDrawer() { + val result = service.capture( + CaptureRequest( + fields = mapOf( + CaptureField.WHEN to CaptureFieldValue.whenDateTime(LocalDateTime(2026, 9, 1, 10, 0)), + CaptureField.WHAT to CaptureFieldValue.text("lab meeting"), + CaptureField.WHERE to CaptureFieldValue.text("room 302"), + CaptureField.WHO to CaptureFieldValue.members("m1", "m2"), + CaptureField.WHY to CaptureFieldValue.text("sync on results"), + ), + capturedAt = capturedAt, + ), + ) + assertTrue(result.isValid) + assertEquals(CaptureType.SCHEDULE, result.type) + assertEquals(CaptureType.SCHEDULE, result.detectedType) + assertTrue(result.orgSnippet.lines().first().startsWith("** ")) + assertTrue(result.orgSnippet.lines().first().endsWith(":schedule:")) + assertTrue(result.orgSnippet.contains("SCHEDULED: <2026-09-01")) + assertTrue(result.orgSnippet.contains(":PROPERTIES:")) + assertTrue(result.orgSnippet.contains(":LOCATION: room 302")) + assertTrue(result.orgSnippet.contains(":PARTICIPANTS: m1,m2")) + assertTrue(result.orgSnippet.contains(":PURPOSE: sync on results")) + assertTrue(result.orgSnippet.contains("lab meeting")) + } + + @Test + fun missingRequiredWhenIsReportedAsValidationError() { + val result = service.capture( + CaptureRequest( + type = CaptureType.SCHEDULE, + fields = mapOf(CaptureField.WHAT to CaptureFieldValue.text("lab meeting")), + capturedAt = capturedAt, + ), + ) + assertFalse(result.isValid) + assertNull(result.commitCandidate) + assertEquals(listOf(CaptureError(CaptureField.WHEN, "Start datetime is required")), result.errors) + } + + @Test + fun blankWhatIsReportedAsValidationError() { + val result = service.capture( + CaptureRequest( + fields = mapOf( + CaptureField.WHEN to CaptureFieldValue.whenDateTime(LocalDateTime(2026, 9, 1, 10, 0)), + CaptureField.WHAT to CaptureFieldValue.text(" "), + ), + capturedAt = capturedAt, + ), + ) + assertFalse(result.isValid) + assertEquals(CaptureError(CaptureField.WHAT, "Body is required"), result.errors.single()) + } + + @Test + fun todoCaptureUsesTodoKeywordInTitle() { + val result = service.capture( + CaptureRequest( + type = CaptureType.TODO, + fields = mapOf( + CaptureField.WHAT to CaptureFieldValue.text("submit paper"), + CaptureField.WHEN to CaptureFieldValue.whenDate(kotlinx.datetime.LocalDate(2026, 9, 30)), + ), + capturedAt = capturedAt, + ), + ) + assertTrue(result.isValid) + assertTrue(result.orgSnippet.startsWith("** TODO submit paper")) + assertTrue(result.orgSnippet.contains("DEADLINE: <2026-09-30>")) + assertNull(result.detectedType) + } + + @Test + fun experimentCaptureMapsFiveWOneHToOrgProperties() { + val result = service.capture( + CaptureRequest( + type = CaptureType.EXPERIMENT, + fields = mapOf( + CaptureField.WHEN to CaptureFieldValue.whenDate(kotlinx.datetime.LocalDate(2026, 8, 26)), + CaptureField.WHAT to CaptureFieldValue.text("measure leaf area"), + CaptureField.WHY to CaptureFieldValue.text("fertilizer increases growth"), + CaptureField.HOW to CaptureFieldValue.text("scanner + imagej"), + ), + capturedAt = capturedAt, + ), + ) + assertTrue(result.isValid) + assertTrue(result.orgSnippet.contains("** Experiment: measure leaf area")) + assertTrue(result.orgSnippet.contains(":DATE: 2026-08-26")) + assertTrue(result.orgSnippet.contains(":HYPOTHESIS: fertilizer increases growth")) + assertTrue(result.orgSnippet.contains(":MEASUREMENT: scanner + imagej")) + } + + @Test + fun attachmentsAreRecordedAsCidProperties() { + val cid = Cid("sha256", "aabbccdd") + val result = service.capture( + CaptureRequest( + type = CaptureType.MEDIA, + fields = mapOf(CaptureField.WHAT to CaptureFieldValue.text("leaf photo")), + attachments = listOf(cid), + capturedAt = capturedAt, + ), + ) + assertTrue(result.isValid) + assertTrue(result.orgSnippet.contains(":ATTACHMENTS: sha256:aabbccdd")) + assertEquals(listOf(cid), result.commitCandidate?.attachmentCids) + } + + @Test + fun mediaWithoutAttachmentFailsValidation() { + val result = service.capture( + CaptureRequest( + type = CaptureType.MEDIA, + fields = mapOf(CaptureField.WHAT to CaptureFieldValue.text("leaf photo")), + capturedAt = capturedAt, + ), + ) + assertFalse(result.isValid) + assertNull(result.errors.single().field) + } + + @Test + fun identicalRequestsProduceIdenticalDedupeKeys() { + val request = CaptureRequest( + fields = mapOf( + CaptureField.WHEN to CaptureFieldValue.whenDateTime(LocalDateTime(2026, 9, 1, 10, 0)), + CaptureField.WHAT to CaptureFieldValue.text("lab meeting"), + ), + capturedAt = capturedAt, + ) + val first = service.capture(request) + val second = service.capture(request) + assertEquals(first.commitCandidate?.dedupeKey, second.commitCandidate?.dedupeKey) + } +} diff --git a/modules/orgflow-capture/src/commonTest/kotlin/jp/orgflow/capture/CaptureTemplateRegistryTest.kt b/modules/orgflow-capture/src/commonTest/kotlin/jp/orgflow/capture/CaptureTemplateRegistryTest.kt index bbb9f5d..9e105bf 100644 --- a/modules/orgflow-capture/src/commonTest/kotlin/jp/orgflow/capture/CaptureTemplateRegistryTest.kt +++ b/modules/orgflow-capture/src/commonTest/kotlin/jp/orgflow/capture/CaptureTemplateRegistryTest.kt @@ -1,3 +1,53 @@ package jp.orgflow.capture -// TODO(spec ch.05): implement CaptureTemplateRegistryTest per docs/spec.md +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class CaptureTemplateRegistryTest { + + @Test + fun providesTemplateForEveryCaptureType() { + val registry = CaptureTemplateRegistry() + CaptureType.entries.forEach { type -> + assertNotNull(registry.templateFor(type), "missing template for $type") + } + assertEquals(CaptureType.entries.size, registry.all().size) + } + + @Test + fun scheduleTemplateRequiresWhenAndWhatAndUsesPlanning() { + val template = CaptureTemplateRegistry.scheduleTemplate() + assertEquals(setOf(CaptureField.WHEN, CaptureField.WHAT), template.requiredFields) + assertTrue(template.planningKind != null) + assertEquals("PARTICIPANTS", template.orgPropertyKey(CaptureField.WHO)) + assertEquals("Start datetime", template.labelFor(CaptureField.WHEN)) + assertTrue(template.isRequired(CaptureField.WHAT)) + } + + @Test + fun experimentTemplateMapsFiveWOneHToExperimentVocabulary() { + val template = CaptureTemplateRegistry.experimentTemplate() + assertEquals("DATE", template.orgPropertyKey(CaptureField.WHEN)) + assertEquals("EXPERIMENTER", template.orgPropertyKey(CaptureField.WHO)) + assertEquals("HYPOTHESIS", template.orgPropertyKey(CaptureField.WHY)) + assertEquals("MEASUREMENT", template.orgPropertyKey(CaptureField.HOW)) + assertEquals("Procedure", template.labelFor(CaptureField.WHAT)) + assertEquals("Hypothesis", template.labelFor(CaptureField.WHY)) + assertEquals("Experiment: ", template.titlePrefix) + } + + @Test + fun everyTemplateRequiresWhat() { + CaptureTemplateRegistry.defaults().values.forEach { template -> + assertTrue(template.isRequired(CaptureField.WHAT), "${template.type} must require What") + } + } + + @Test + fun mediaTemplateRequiresAttachment() { + assertTrue(CaptureTemplateRegistry.mediaTemplate().requiresAttachment) + assertTrue(!CaptureTemplateRegistry.noteTemplate().requiresAttachment) + } +} diff --git a/modules/orgflow-experiment/build.gradle.kts b/modules/orgflow-experiment/build.gradle.kts index bee562c..abe18f7 100644 --- a/modules/orgflow-experiment/build.gradle.kts +++ b/modules/orgflow-experiment/build.gradle.kts @@ -1,6 +1,23 @@ -// Placeholder module (spec ch.24 tree). Division owning this chapter upgrades -// this file to a KMP config when implementation starts. See NEMOTRON.md. -plugins { `base` } +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) +} group = "net.kukuri" version = "0.1.0" + +kotlin { + jvm() + wasmJs { nodejs() } + linuxX64() + + sourceSets { + commonMain.dependencies { + implementation(libs.serialization.json) + implementation(libs.coroutines.core) + implementation(project(":modules:orgflow-domain")) + } + commonTest.dependencies { implementation(libs.kotlin.test) } + jvmTest.dependencies { implementation(libs.kotlin.test) } + } +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/calculation/AggregateFunctions.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/calculation/AggregateFunctions.kt index d5591b8..2022423 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/calculation/AggregateFunctions.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/calculation/AggregateFunctions.kt @@ -1,3 +1,42 @@ package jp.orgflow.experiment.calculation -// TODO(spec ch.07): implement AggregateFunctions per docs/spec.md +import kotlin.math.sqrt + +object AggregateFunctions { + + fun sum(values: List<Double>): Double = values.sum() + + fun avg(values: List<Double>): Double { + require(values.isNotEmpty()) { "avg of empty list" } + return values.sum() / values.size + } + + fun min(values: List<Double>): Double { + require(values.isNotEmpty()) { "min of empty list" } + return values.min() + } + + fun max(values: List<Double>): Double { + require(values.isNotEmpty()) { "max of empty list" } + return values.max() + } + + fun count(values: List<Double>): Int = values.size + + fun median(values: List<Double>): Double { + require(values.isNotEmpty()) { "median of empty list" } + val sorted = values.sorted() + return if (sorted.size % 2 == 1) { + sorted[sorted.size / 2] + } else { + (sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2.0 + } + } + + fun stdev(values: List<Double>): Double { + require(values.isNotEmpty()) { "stdev of empty list" } + val mean = avg(values) + val variance = values.sumOf { (it - mean) * (it - mean) } / values.size + return sqrt(variance) + } +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/calculation/FilterFunctions.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/calculation/FilterFunctions.kt index 2a67c98..ebbc166 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/calculation/FilterFunctions.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/calculation/FilterFunctions.kt @@ -1,3 +1,33 @@ package jp.orgflow.experiment.calculation -// TODO(spec ch.07): implement FilterFunctions per docs/spec.md +import jp.orgflow.experiment.table.ExperimentRow +import jp.orgflow.experiment.table.ExperimentTable +import jp.orgflow.experiment.table.ExperimentValue + +object FilterFunctions { + + fun filterRows( + table: ExperimentTable, + predicate: (ExperimentRow) -> Boolean, + ): List<ExperimentRow> = table.rows.filter(predicate) + + fun filterByValue( + table: ExperimentTable, + columnName: String, + expected: ExperimentValue, + ): List<ExperimentRow> = table.rows.filter { it.value(columnName) == expected } + + fun filterNumeric( + table: ExperimentTable, + columnName: String, + predicate: (Double) -> Boolean, + ): List<ExperimentRow> = table.rows.filter { row -> + val coerced = NumericCoercion.coerce(row.value(columnName)) + coerced != null && predicate(coerced) + } + + fun filterNonEmpty( + table: ExperimentTable, + columnName: String, + ): List<ExperimentRow> = table.rows.filter { row -> !row.value(columnName).isEmpty } +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/calculation/GroupFunctions.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/calculation/GroupFunctions.kt index aac38ec..1651de0 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/calculation/GroupFunctions.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/calculation/GroupFunctions.kt @@ -1,3 +1,43 @@ package jp.orgflow.experiment.calculation -// TODO(spec ch.07): implement GroupFunctions per docs/spec.md +import jp.orgflow.experiment.table.ExperimentRow +import jp.orgflow.experiment.table.ExperimentTable +import jp.orgflow.experiment.table.toDisplayText + +data class GroupResult( + val key: String, + val rows: List<ExperimentRow>, + val aggregates: Map<String, Double>, +) + +object GroupFunctions { + + fun groupBy( + table: ExperimentTable, + columnName: String, + ): List<GroupResult> = table.rows + .groupBy { it.value(columnName).toDisplayText() } + .map { (key, rows) -> GroupResult(key, rows, emptyMap()) } + + fun groupByWithAggregates( + table: ExperimentTable, + keyColumnName: String, + valueColumnName: String, + ): List<GroupResult> = groupBy(table, keyColumnName).map { group -> + val numbers = NumericCoercion.coerceAll(group.rows.map { it.value(valueColumnName) }) + group.copy( + aggregates = mapOf( + "count" to numbers.size.toDouble(), + "sum" to AggregateFunctions.sum(numbers), + "avg" to if (numbers.isEmpty()) Double.NaN else AggregateFunctions.avg(numbers), + "min" to if (numbers.isEmpty()) Double.NaN else AggregateFunctions.min(numbers), + "max" to if (numbers.isEmpty()) Double.NaN else AggregateFunctions.max(numbers), + ) + ) + } + + fun countBy( + table: ExperimentTable, + columnName: String, + ): Map<String, Int> = table.rows.groupingBy { it.value(columnName).toDisplayText() }.eachCount() +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/calculation/NumericCoercion.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/calculation/NumericCoercion.kt index 67352b9..6d2f665 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/calculation/NumericCoercion.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/calculation/NumericCoercion.kt @@ -1,3 +1,26 @@ package jp.orgflow.experiment.calculation -// TODO(spec ch.07): implement NumericCoercion per docs/spec.md +import jp.orgflow.experiment.table.ExperimentValue + +object NumericCoercion { + + fun coerce(value: ExperimentValue): Double? = when (value) { + is ExperimentValue.NumberValue -> value.value + is ExperimentValue.StringValue -> parseNumber(value.text) + is ExperimentValue.BooleanValue -> if (value.flag) 1.0 else 0.0 + ExperimentValue.EmptyValue -> null + } + + fun parseNumber(text: String): Double? { + val trimmed = text.trim() + if (trimmed.isEmpty()) return null + return trimmed.replace(",", "").toDoubleOrNull() + } + + fun coerceAll(values: List<ExperimentValue>): List<Double> = values.mapNotNull { coerce(it) } + + fun coerceColumn(values: List<ExperimentValue>): List<Double> = coerceAll(values) + + fun requireNumeric(value: ExperimentValue): Double = + coerce(value) ?: throw IllegalArgumentException("non-numeric value: $value") +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/ChartAnchorFactory.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/ChartAnchorFactory.kt index 5caaf36..22d988d 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/ChartAnchorFactory.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/ChartAnchorFactory.kt @@ -1,3 +1,41 @@ package jp.orgflow.experiment.chart -// TODO(spec ch.07): implement ChartAnchorFactory per docs/spec.md +import jp.orgflow.experiment.table.ExperimentTable + +data class ChartAnchor( + val tableId: String, + val rowStart: Int, + val rowEnd: Int, + val columnNames: List<String>, + val commitId: String?, +) { + val region: String + get() = "$tableId!r${rowStart + 1}-r${rowEnd + 1}:${columnNames.joinToString(",")}" +} + +class ChartAnchorFactory { + + fun create( + table: ExperimentTable, + rowRange: IntRange, + columnNames: List<String>, + commitId: String? = table.sourceCommitId, + ): ChartAnchor = ChartAnchor( + tableId = table.id, + rowStart = rowRange.first, + rowEnd = rowRange.last, + columnNames = columnNames, + commitId = commitId, + ) + + fun forWholeTable( + table: ExperimentTable, + columnNames: List<String> = table.columns.map { it.name }, + ): ChartAnchor = create( + table = table, + rowRange = if (table.rows.isEmpty()) IntRange.EMPTY else IntRange(0, table.rows.size - 1), + columnNames = columnNames, + ) + + fun toOrgComment(anchor: ChartAnchor): String = "# chart-anchor: ${anchor.region}@${anchor.commitId ?: "uncommitted"}" +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/ChartAxis.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/ChartAxis.kt index 388145b..56d6bc1 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/ChartAxis.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/ChartAxis.kt @@ -1,3 +1,20 @@ package jp.orgflow.experiment.chart -// TODO(spec ch.07): implement ChartAxis per docs/spec.md +data class ChartAxis( + val label: String = "", + val min: Double? = null, + val max: Double? = null, +) { + val isAutoRange: Boolean get() = min == null && max == null + + companion object { + fun auto(label: String = ""): ChartAxis = ChartAxis(label = label) + + fun fit(label: String, values: List<Double>): ChartAxis = + if (values.isEmpty()) { + ChartAxis(label = label) + } else { + ChartAxis(label = label, min = values.min(), max = values.max()) + } + } +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/ChartSeries.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/ChartSeries.kt index a17d764..5e35dde 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/ChartSeries.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/ChartSeries.kt @@ -1,3 +1,21 @@ package jp.orgflow.experiment.chart -// TODO(spec ch.07): implement ChartSeries per docs/spec.md +data class ChartSeries( + val name: String, + val xValues: List<Double>, + val yValues: List<Double>, +) { + init { + require(xValues.size == yValues.size) { "x/y size mismatch for series $name" } + } + + val size: Int get() = yValues.size + + companion object { + fun numeric(name: String, yValues: List<Double>): ChartSeries = + ChartSeries(name, List(yValues.size) { index -> index.toDouble() }, yValues) + + fun paired(name: String, xValues: List<Double>, yValues: List<Double>): ChartSeries = + ChartSeries(name, xValues, yValues) + } +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/ChartSpec.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/ChartSpec.kt index 7571692..b609280 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/ChartSpec.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/ChartSpec.kt @@ -1,3 +1,21 @@ package jp.orgflow.experiment.chart -// TODO(spec ch.07): implement ChartSpec per docs/spec.md +enum class ChartType { + BAR, + LINE, + SCATTER, + PIE, +} + +data class ChartSpec( + val type: ChartType, + val title: String, + val series: List<ChartSeries>, + val xAxis: ChartAxis = ChartAxis(), + val yAxis: ChartAxis = ChartAxis(), + val sourceTableId: String? = null, + val sourceCommitId: String? = null, +) { + fun withSource(tableId: String?, commitId: String?): ChartSpec = + copy(sourceTableId = tableId, sourceCommitId = commitId) +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/GraphCardFactory.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/GraphCardFactory.kt index 7e72961..0801c69 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/GraphCardFactory.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/GraphCardFactory.kt @@ -1,3 +1,56 @@ package jp.orgflow.experiment.chart -// TODO(spec ch.07): implement GraphCardFactory per docs/spec.md +import jp.orgflow.domain.card.GraphCard +import jp.orgflow.domain.identity.CardId +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject + +class GraphCardFactory( + private val idGenerator: () -> CardId = { CardId("graph-card-${allocateId()}") }, +) { + private val json = Json + + fun create(spec: ChartSpec, cardId: CardId = idGenerator()): GraphCard = GraphCard( + id = cardId, + graphJson = toJson(spec), + ) + + fun toJson(spec: ChartSpec): String { + val root = buildJsonObject { + put("type", JsonPrimitive(spec.type.name)) + put("title", JsonPrimitive(spec.title)) + put("xAxis", axisObject(spec.xAxis)) + put("yAxis", axisObject(spec.yAxis)) + put("sourceTableId", spec.sourceTableId?.let { JsonPrimitive(it) } ?: JsonNull) + put("sourceCommitId", spec.sourceCommitId?.let { JsonPrimitive(it) } ?: JsonNull) + put("series", JsonArray(spec.series.map { seriesObject(it) })) + } + return json.encodeToString(JsonElement.serializer(), root) + } + + private fun axisObject(axis: ChartAxis): JsonObject = buildJsonObject { + put("label", JsonPrimitive(axis.label)) + put("min", axis.min?.let { JsonPrimitive(it) } ?: JsonNull) + put("max", axis.max?.let { JsonPrimitive(it) } ?: JsonNull) + } + + private fun seriesObject(series: ChartSeries): JsonObject = buildJsonObject { + put("name", JsonPrimitive(series.name)) + put("x", JsonArray(series.xValues.map { JsonPrimitive(it) })) + put("y", JsonArray(series.yValues.map { JsonPrimitive(it) })) + } + + companion object { + private var counter: Long = 0 + + private fun allocateId(): Long { + counter += 1 + return counter + } + } +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/GuiLispChartCompiler.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/GuiLispChartCompiler.kt index 8510fde..acad834 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/GuiLispChartCompiler.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/GuiLispChartCompiler.kt @@ -1,3 +1,85 @@ package jp.orgflow.experiment.chart -// TODO(spec ch.07): implement GuiLispChartCompiler per docs/spec.md +import jp.orgflow.experiment.lisp.GuiLispError +import jp.orgflow.experiment.lisp.GuiLispEvaluator +import jp.orgflow.experiment.lisp.LispList +import jp.orgflow.experiment.lisp.LispNumber +import jp.orgflow.experiment.lisp.LispString +import jp.orgflow.experiment.lisp.LispSymbol +import jp.orgflow.experiment.lisp.LispValue +import jp.orgflow.experiment.lisp.TableEnvironment +import jp.orgflow.experiment.table.ExperimentTable + +class GuiLispChartCompiler { + + fun compile( + expression: String, + table: ExperimentTable, + evaluator: GuiLispEvaluator = TableEnvironment(table).createEvaluator(), + ): ChartSpec { + val form = evaluator.parseForm(expression) + val list = form as? LispList + ?: throw GuiLispError.Type("chart expression must be a list but got ${form.render()}") + val head = list.items.firstOrNull() as? LispSymbol + ?: throw GuiLispError.Type("chart expression must start with a chart function") + val chartType = CHART_FUNCTIONS[head.name] + ?: throw GuiLispError.Type("unknown chart function: ${head.name}") + val args = list.items.drop(1) + if (args.size < 3) { + throw GuiLispError.Type("chart expression expects (chart-function title x-values y-values [series-name])") + } + val title = (evaluator.eval(args[0]) as? LispString)?.value + ?: throw GuiLispError.Type("chart title must be a string") + val xValue = evaluator.eval(args[1]) + val yValue = evaluator.eval(args[2]) + val seriesName = if (args.size >= 4) { + (evaluator.eval(args[3]) as? LispString)?.value ?: DEFAULT_SERIES_NAME + } else { + DEFAULT_SERIES_NAME + } + val yNumbers = toNumbers(yValue) + val xNumbers = toXNumbers(xValue) + if (xNumbers.size != yNumbers.size) { + throw GuiLispError.Type("chart x/y size mismatch: ${xNumbers.size} vs ${yNumbers.size}") + } + val series = ChartSeries(seriesName, xNumbers, yNumbers) + return ChartSpec( + type = chartType, + title = title, + series = listOf(series), + xAxis = ChartAxis.fit(X_AXIS_LABEL, xNumbers), + yAxis = ChartAxis.fit(Y_AXIS_LABEL, yNumbers), + sourceTableId = table.id, + sourceCommitId = table.sourceCommitId, + ) + } + + private fun toNumbers(value: LispValue): List<Double> { + val list = value as? LispList + ?: throw GuiLispError.Type("chart values must be a list but got ${value.render()}") + return list.items.map { item -> + (item as? LispNumber)?.value + ?: throw GuiLispError.Type("chart values must be numbers but got ${item.render()}") + } + } + + private fun toXNumbers(value: LispValue): List<Double> { + val list = value as? LispList + ?: throw GuiLispError.Type("chart categories must be a list but got ${value.render()}") + return list.items.mapIndexed { index, item -> + (item as? LispNumber)?.value ?: index.toDouble() + } + } + + companion object { + const val DEFAULT_SERIES_NAME = "series-1" + const val X_AXIS_LABEL = "X" + const val Y_AXIS_LABEL = "Y" + private val CHART_FUNCTIONS = mapOf( + "bar-chart" to ChartType.BAR, + "line-chart" to ChartType.LINE, + "scatter-plot" to ChartType.SCATTER, + "pie-chart" to ChartType.PIE, + ) + } +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/BuiltinFunction.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/BuiltinFunction.kt index 5404cca..6fd5655 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/BuiltinFunction.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/BuiltinFunction.kt @@ -1,3 +1,23 @@ package jp.orgflow.experiment.lisp -// TODO(spec ch.07): implement BuiltinFunction per docs/spec.md +class BuiltinFunction( + override val name: String, + val minArity: Int = 0, + val maxArity: Int? = null, + val rawArgIndexes: Set<Int> = emptySet(), + val invoker: ( + evaluator: GuiLispEvaluator, + context: GuiLispEvaluationContext, + args: List<LispValue>, + ) -> LispValue, +) : LispFunction { + fun checkArity(args: List<LispValue>) { + if (args.size < minArity) { + throw GuiLispError.Type("$name expects at least $minArity args but got ${args.size}") + } + val max = maxArity + if (max != null && args.size > max) { + throw GuiLispError.Type("$name expects at most $max args but got ${args.size}") + } + } +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/BuiltinFunctionRegistry.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/BuiltinFunctionRegistry.kt index a87c28d..4a19bb8 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/BuiltinFunctionRegistry.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/BuiltinFunctionRegistry.kt @@ -1,3 +1,195 @@ package jp.orgflow.experiment.lisp -// TODO(spec ch.07): implement BuiltinFunctionRegistry per docs/spec.md +import kotlin.math.sqrt + +class BuiltinFunctionRegistry { + private val functions = LinkedHashMap<String, BuiltinFunction>() + + fun register(function: BuiltinFunction) { + functions[function.name] = function + } + + fun lookup(name: String): LispFunction? = functions[name] + + fun names(): List<String> = functions.keys.toList() + + fun registerAll(other: BuiltinFunctionRegistry) { + other.functions.forEach { (name, function) -> functions[name] = function } + } + + fun installInto(context: GuiLispEvaluationContext) { + functions.forEach { (_, function) -> context.defineFunction(function) } + } + + companion object { + fun withDefaults(): BuiltinFunctionRegistry { + val registry = BuiltinFunctionRegistry() + defaults().forEach { registry.register(it) } + return registry + } + + fun defaults(): List<BuiltinFunction> = listOf( + arithmetic(), + comparisons(), + listOperations(), + aggregates(), + ).flatten() + + private fun arithmetic(): List<BuiltinFunction> = listOf( + BuiltinFunction("+", 0) { _, _, args -> + LispNumber(args.fold(0.0) { acc, value -> acc + number(value, "+") }) + }, + BuiltinFunction("-", 1) { _, _, args -> + if (args.size == 1) { + LispNumber(-number(args[0], "-")) + } else { + LispNumber(args.drop(1).fold(number(args[0], "-")) { acc, value -> acc - number(value, "-") }) + } + }, + BuiltinFunction("*", 0) { _, _, args -> + LispNumber(args.fold(1.0) { acc, value -> acc * number(value, "*") }) + }, + BuiltinFunction("/", 1) { _, _, args -> + if (args.size == 1) { + val divisor = number(args[0], "/") + if (divisor == 0.0) throw GuiLispError.Type("division by zero") + LispNumber(1.0 / divisor) + } else { + val first = number(args[0], "/") + val rest = args.drop(1).map { number(it, "/") } + rest.forEach { if (it == 0.0) throw GuiLispError.Type("division by zero") } + LispNumber(rest.fold(first) { acc, value -> acc / value }) + } + }, + ) + + private fun comparisons(): List<BuiltinFunction> = listOf( + BuiltinFunction("=", 2) { _, _, args -> + val first = args[0] + LispBoolean(args.all { equalsValue(first, it) }) + }, + BuiltinFunction("<", 2) { _, _, args -> LispBoolean(numericChain(args, "<") { a, b -> a < b }) }, + BuiltinFunction(">", 2) { _, _, args -> LispBoolean(numericChain(args, ">") { a, b -> a > b }) }, + BuiltinFunction("<=", 2) { _, _, args -> LispBoolean(numericChain(args, "<=") { a, b -> a <= b }) }, + BuiltinFunction(">=", 2) { _, _, args -> LispBoolean(numericChain(args, ">=") { a, b -> a >= b }) }, + BuiltinFunction("not", 1) { _, _, args -> LispBoolean(!isTruthy(args[0])) }, + ) + + private fun listOperations(): List<BuiltinFunction> = listOf( + BuiltinFunction("car", 1) { _, _, args -> + asList(args[0], "car").firstOrNull() ?: throw GuiLispError.Type("car of empty list") + }, + BuiltinFunction("cdr", 1) { _, _, args -> + LispList(asList(args[0], "cdr").drop(1)) + }, + BuiltinFunction("cons", 2) { _, context, args -> + makeList(listOf(args[0]) + asList(args[1], "cons"), context) + }, + BuiltinFunction("list", 0) { _, context, args -> makeList(args, context) }, + BuiltinFunction("length", 1) { _, _, args -> + LispNumber(asList(args[0], "length").size.toDouble()) + }, + BuiltinFunction("map", 2, rawArgIndexes = setOf(0)) { evaluator, context, args -> + val function = resolveFunction(evaluator, args[0]) + val mapped = asList(args[1], "map").map { evaluator.applyFunction(function, listOf(it)) } + makeList(mapped, context) + }, + BuiltinFunction("filter", 2, rawArgIndexes = setOf(0)) { evaluator, context, args -> + val predicate = resolveFunction(evaluator, args[0]) + val kept = asList(args[1], "filter").filter { isTruthy(evaluator.applyFunction(predicate, listOf(it))) } + makeList(kept, context) + }, + BuiltinFunction("reduce", 3, rawArgIndexes = setOf(0)) { evaluator, _, args -> + val function = resolveFunction(evaluator, args[0]) + var accumulator = args[1] + asList(args[2], "reduce").forEach { item -> + accumulator = evaluator.applyFunction(function, listOf(accumulator, item)) + } + accumulator + }, + ) + + private fun aggregates(): List<BuiltinFunction> = listOf( + BuiltinFunction("sum", 1) { _, _, args -> LispNumber(numericList(args[0], "sum").sum()) }, + BuiltinFunction("avg", 1) { _, _, args -> LispNumber(meanOf(numericList(args[0], "avg"), "avg")) }, + BuiltinFunction("min", 1) { _, _, args -> LispNumber(nonEmpty(numericList(args[0], "min"), "min").min()) }, + BuiltinFunction("max", 1) { _, _, args -> LispNumber(nonEmpty(numericList(args[0], "max"), "max").max()) }, + BuiltinFunction("count", 1) { _, _, args -> LispNumber(asList(args[0], "count").size.toDouble()) }, + BuiltinFunction("median", 1) { _, _, args -> LispNumber(medianOf(numericList(args[0], "median"))) }, + BuiltinFunction("stdev", 1) { _, _, args -> LispNumber(stdevOf(numericList(args[0], "stdev"))) }, + BuiltinFunction("group-count", 1) { _, context, args -> + val counts = LinkedHashMap<String, Int>() + asList(args[0], "group-count").forEach { item -> + val key = (item as? LispString)?.value ?: item.render() + counts[key] = (counts[key] ?: 0) + 1 + } + makeList( + counts.entries.map { (key, count) -> LispList(listOf(LispString(key), LispNumber(count.toDouble()))) }, + context, + ) + }, + ) + + private fun number(value: LispValue, name: String): Double = + (value as? LispNumber)?.value ?: throw GuiLispError.Type("$name expects a number but got ${value.render()}") + + private fun asList(value: LispValue, name: String): List<LispValue> = + (value as? LispList)?.items ?: throw GuiLispError.Type("$name expects a list but got ${value.render()}") + + private fun numericList(value: LispValue, name: String): List<Double> = + asList(value, name).map { number(it, name) } + + private fun makeList(items: List<LispValue>, context: GuiLispEvaluationContext): LispList { + context.checkListLength(items.size) + return LispList(items) + } + + private fun resolveFunction(evaluator: GuiLispEvaluator, value: LispValue): LispFunction = when (value) { + is LispSymbol -> evaluator.context.lookupFunction(value.name) ?: throw GuiLispError.Unbound(value.name) + else -> throw GuiLispError.Type("expected a function name but got ${value.render()}") + } + + private fun equalsValue(a: LispValue, b: LispValue): Boolean = when { + a is LispNumber && b is LispNumber -> a.value == b.value + a is LispString && b is LispString -> a.value == b.value + a is LispBoolean && b is LispBoolean -> a.value == b.value + else -> false + } + + private fun numericChain(args: List<LispValue>, name: String, predicate: (Double, Double) -> Boolean): Boolean { + var previous = number(args[0], name) + for (index in 1 until args.size) { + val current = number(args[index], name) + if (!predicate(previous, current)) return false + previous = current + } + return true + } + + private fun meanOf(values: List<Double>, name: String): Double { + if (values.isEmpty()) throw GuiLispError.Type("$name of empty list") + return values.sum() / values.size + } + + private fun nonEmpty(values: List<Double>, name: String): List<Double> { + if (values.isEmpty()) throw GuiLispError.Type("$name of empty list") + return values + } + + private fun medianOf(values: List<Double>): Double { + val sorted = nonEmpty(values, "median").sorted() + return if (sorted.size % 2 == 1) { + sorted[sorted.size / 2] + } else { + (sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2.0 + } + } + + private fun stdevOf(values: List<Double>): Double { + val nonEmptyValues = nonEmpty(values, "stdev") + val mean = nonEmptyValues.sum() / nonEmptyValues.size + val variance = nonEmptyValues.sumOf { (it - mean) * (it - mean) } / nonEmptyValues.size + return sqrt(variance) + } + } +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/GuiLispError.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/GuiLispError.kt index f3add7d..604752b 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/GuiLispError.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/GuiLispError.kt @@ -1,3 +1,11 @@ package jp.orgflow.experiment.lisp -// TODO(spec ch.07): implement GuiLispError per docs/spec.md +sealed class GuiLispError(message: String) : RuntimeException(message) { + class Parse(message: String, val position: Int) : GuiLispError("$message (at $position)") + + class Limit(message: String) : GuiLispError(message) + + class Type(message: String) : GuiLispError(message) + + class Unbound(val name: String) : GuiLispError("unbound symbol: $name") +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/GuiLispEvaluationContext.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/GuiLispEvaluationContext.kt index c99a83d..4fa8ece 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/GuiLispEvaluationContext.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/GuiLispEvaluationContext.kt @@ -1,3 +1,85 @@ package jp.orgflow.experiment.lisp -// TODO(spec ch.07): implement GuiLispEvaluationContext per docs/spec.md +data class EvaluationLimits( + val maxRecursionDepth: Int = 100, + val maxSteps: Int = 10000, + val maxListLength: Int = 10000, +) + +sealed interface LispFunction { + val name: String +} + +data class UserDefinedFunction( + override val name: String, + val parameters: List<String>, + val body: LispList, +) : LispFunction + +class GuiLispEvaluationContext( + val limits: EvaluationLimits = EvaluationLimits(), +) { + private val globalVariables = mutableMapOf<String, LispValue>() + private val globalFunctions = mutableMapOf<String, LispFunction>() + private val scopes = mutableListOf<MutableMap<String, LispValue>>() + private var stepCount: Int = 0 + private var depth: Int = 0 + + fun defineGlobal(name: String, value: LispValue) { + globalVariables[name] = value + } + + fun defineFunction(function: LispFunction) { + globalFunctions[function.name] = function + } + + fun lookupVariable(name: String): LispValue? { + for (index in scopes.indices.reversed()) { + val value = scopes[index][name] + if (value != null) return value + } + return globalVariables[name] + } + + fun lookupFunction(name: String): LispFunction? = globalFunctions[name] + + fun registeredFunctions(): List<String> = globalFunctions.keys.sorted() + + fun pushScope(bindings: Map<String, LispValue>) { + scopes.add(bindings.toMutableMap()) + } + + fun popScope() { + scopes.removeAt(scopes.size - 1) + } + + fun consumeStep() { + stepCount += 1 + if (stepCount > limits.maxSteps) { + throw GuiLispError.Limit("evaluation exceeded ${limits.maxSteps} steps") + } + } + + fun enterCall() { + depth += 1 + if (depth > limits.maxRecursionDepth) { + throw GuiLispError.Limit("recursion exceeded depth ${limits.maxRecursionDepth}") + } + } + + fun exitCall() { + depth -= 1 + } + + fun checkListLength(size: Int) { + if (size > limits.maxListLength) { + throw GuiLispError.Limit("list length exceeded ${limits.maxListLength}") + } + } + + fun reset() { + stepCount = 0 + depth = 0 + scopes.clear() + } +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/GuiLispEvaluator.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/GuiLispEvaluator.kt index d7f0351..f893674 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/GuiLispEvaluator.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/GuiLispEvaluator.kt @@ -1,3 +1,191 @@ package jp.orgflow.experiment.lisp -// TODO(spec ch.07): implement GuiLispEvaluator per docs/spec.md +class GuiLispEvaluator( + val context: GuiLispEvaluationContext = GuiLispEvaluationContext(), + registry: BuiltinFunctionRegistry? = null, +) { + private val grammar = GuiLispGrammar() + + init { + (registry ?: BuiltinFunctionRegistry.withDefaults()).installInto(context) + } + + fun evaluate(source: String): LispValue { + context.reset() + val program = grammar.parse(source) + var result: LispValue = LispList(emptyList()) + program.items.forEach { form -> + result = eval(form) + } + return result + } + + fun evaluateWithBindings(source: String, bindings: Map<String, LispValue>): LispValue { + context.reset() + val form = grammar.parseExpression(source) + context.pushScope(bindings) + return try { + eval(form) + } finally { + context.popScope() + } + } + + fun parseForm(source: String): LispValue = grammar.parseExpression(source) + + fun eval(form: LispValue): LispValue { + context.consumeStep() + return when (form) { + is LispList -> evalList(form) + is LispSymbol -> evalSymbol(form) + else -> form + } + } + + fun applyFunction(function: LispFunction, args: List<LispValue>): LispValue { + context.enterCall() + try { + return when (function) { + is BuiltinFunction -> { + function.checkArity(args) + function.invoker(this, context, args) + } + is UserDefinedFunction -> applyUserFunction(function, args) + } + } finally { + context.exitCall() + } + } + + private fun applyUserFunction(function: UserDefinedFunction, args: List<LispValue>): LispValue { + if (args.size != function.parameters.size) { + throw GuiLispError.Type("${function.name} expects ${function.parameters.size} args but got ${args.size}") + } + val bindings = function.parameters.zip(args).toMap() + context.pushScope(bindings) + try { + var result: LispValue = LispList(emptyList()) + function.body.items.forEach { bodyForm -> + result = eval(bodyForm) + } + return result + } finally { + context.popScope() + } + } + + private fun evalSymbol(symbol: LispSymbol): LispValue = when (symbol.name) { + TRUE_SYMBOL -> LispBoolean(true) + FALSE_SYMBOL -> LispBoolean(false) + NIL_SYMBOL -> LispList(emptyList()) + else -> context.lookupVariable(symbol.name) ?: throw GuiLispError.Unbound(symbol.name) + } + + private fun evalList(form: LispList): LispValue { + if (form.isEmpty) throw GuiLispError.Type("cannot evaluate empty list") + val head = form.items.first() + if (head is LispSymbol && head.name in SPECIAL_FORMS) { + return evalSpecialForm(head.name, form.items.drop(1)) + } + val function = when (head) { + is LispSymbol -> context.lookupFunction(head.name) ?: throw GuiLispError.Unbound(head.name) + else -> throw GuiLispError.Type("cannot call ${head.render()}") + } + val rawIndexes = (function as? BuiltinFunction)?.rawArgIndexes ?: emptySet() + val args = form.items.drop(1).mapIndexed { index, arg -> if (index in rawIndexes) arg else eval(arg) } + return applyFunction(function, args) + } + + private fun evalSpecialForm(name: String, rest: List<LispValue>): LispValue = when (name) { + "if" -> evalIf(rest) + "cond" -> evalCond(rest) + "let" -> evalLet(rest) + "defun" -> evalDefun(rest) + "and" -> evalAnd(rest) + "or" -> evalOr(rest) + else -> throw GuiLispError.Type("unknown special form: $name") + } + + private fun evalIf(rest: List<LispValue>): LispValue { + if (rest.size < 2) throw GuiLispError.Type("if expects (if test then [else])") + return if (isTruthy(eval(rest[0]))) { + eval(rest[1]) + } else if (rest.size >= 3) { + eval(rest[2]) + } else { + LispList(emptyList()) + } + } + + private fun evalCond(rest: List<LispValue>): LispValue { + rest.forEach { clause -> + val items = (clause as? LispList)?.items ?: throw GuiLispError.Type("cond clause must be a list") + if (items.isEmpty()) throw GuiLispError.Type("cond clause must not be empty") + val test = items.first() + val isElse = test is LispSymbol && test.name == ELSE_SYMBOL + if (isElse || isTruthy(eval(test))) { + var result: LispValue = LispList(emptyList()) + items.drop(1).forEach { result = eval(it) } + return result + } + } + return LispList(emptyList()) + } + + private fun evalLet(rest: List<LispValue>): LispValue { + if (rest.size < 2) throw GuiLispError.Type("let expects (let ((name value) ...) body ...)") + val bindingPairs = (rest[0] as? LispList)?.items ?: throw GuiLispError.Type("let bindings must be a list") + val bindings = LinkedHashMap<String, LispValue>() + bindingPairs.forEach { pair -> + val items = (pair as? LispList)?.items ?: throw GuiLispError.Type("let binding must be a (name value) list") + if (items.size != 2 || items[0] !is LispSymbol) { + throw GuiLispError.Type("let binding must be a (name value) list") + } + bindings[(items[0] as LispSymbol).name] = eval(items[1]) + } + context.pushScope(bindings) + return try { + var result: LispValue = LispList(emptyList()) + rest.drop(1).forEach { result = eval(it) } + result + } finally { + context.popScope() + } + } + + private fun evalDefun(rest: List<LispValue>): LispValue { + if (rest.size < 3) throw GuiLispError.Type("defun expects (defun name (params) body ...)") + val nameSymbol = rest[0] as? LispSymbol ?: throw GuiLispError.Type("defun name must be a symbol") + val paramsForm = rest[1] as? LispList ?: throw GuiLispError.Type("defun parameters must be a list") + val parameters = paramsForm.items.map { param -> + (param as? LispSymbol)?.name ?: throw GuiLispError.Type("defun parameter must be a symbol") + } + context.defineFunction(UserDefinedFunction(nameSymbol.name, parameters, LispList(rest.drop(2)))) + return nameSymbol + } + + private fun evalAnd(rest: List<LispValue>): LispValue { + var result: LispValue = LispBoolean(true) + rest.forEach { form -> + result = eval(form) + if (!isTruthy(result)) return LispBoolean(false) + } + return result + } + + private fun evalOr(rest: List<LispValue>): LispValue { + rest.forEach { form -> + val result = eval(form) + if (isTruthy(result)) return result + } + return LispBoolean(false) + } + + companion object { + private const val TRUE_SYMBOL = "true" + private const val FALSE_SYMBOL = "false" + private const val NIL_SYMBOL = "nil" + private const val ELSE_SYMBOL = "else" + private val SPECIAL_FORMS = setOf("if", "cond", "let", "defun", "and", "or") + } +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/GuiLispGrammar.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/GuiLispGrammar.kt index 0e6e3f7..95a2fa2 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/GuiLispGrammar.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/GuiLispGrammar.kt @@ -1,3 +1,168 @@ package jp.orgflow.experiment.lisp -// TODO(spec ch.07): implement GuiLispGrammar per docs/spec.md +enum class GuiLispTokenKind { + LEFT_PAREN, + RIGHT_PAREN, + NUMBER, + STRING, + SYMBOL, +} + +data class GuiLispToken(val kind: GuiLispTokenKind, val text: String, val position: Int) + +class GuiLispGrammar { + + fun tokenize(source: String): List<GuiLispToken> { + val tokens = mutableListOf<GuiLispToken>() + var index = 0 + while (index < source.length) { + val char = source[index] + when { + char.isWhitespace() -> index += 1 + char == ';' -> { + while (index < source.length && source[index] != '\n') index += 1 + } + char == '(' -> { + tokens.add(GuiLispToken(GuiLispTokenKind.LEFT_PAREN, "(", index)) + index += 1 + } + char == ')' -> { + tokens.add(GuiLispToken(GuiLispTokenKind.RIGHT_PAREN, ")", index)) + index += 1 + } + char == '"' -> { + val (text, next) = readString(source, index) + tokens.add(GuiLispToken(GuiLispTokenKind.STRING, text, index)) + index = next + } + else -> { + val (text, next) = readAtom(source, index) + val kind = if (isNumberText(text)) GuiLispTokenKind.NUMBER else GuiLispTokenKind.SYMBOL + tokens.add(GuiLispToken(kind, text, index)) + index = next + } + } + } + return tokens + } + + fun parse(source: String): LispList { + val parser = TokenParser(tokenize(source)) + val forms = mutableListOf<LispValue>() + while (parser.hasNext()) { + forms.add(parser.parseForm()) + } + return LispList(forms) + } + + fun parseExpression(source: String): LispValue { + val program = parse(source) + if (program.size != 1) throw GuiLispError.Parse("expected exactly one expression but got ${program.size}", 0) + return program.items.first() + } + + private fun readString(source: String, start: Int): Pair<String, Int> { + val builder = StringBuilder() + var index = start + 1 + while (index < source.length) { + val char = source[index] + when { + char == '\\' -> { + if (index + 1 >= source.length) throw GuiLispError.Parse("unterminated escape sequence", index) + val escaped = source[index + 1] + builder.append( + when (escaped) { + 'n' -> '\n' + 't' -> '\t' + else -> escaped + } + ) + index += 2 + } + char == '"' -> return builder.toString() to (index + 1) + else -> { + builder.append(char) + index += 1 + } + } + } + throw GuiLispError.Parse("unterminated string literal", start) + } + + private fun readAtom(source: String, start: Int): Pair<String, Int> { + val builder = StringBuilder() + var index = start + while (index < source.length) { + val char = source[index] + if (char.isWhitespace() || char == '(' || char == ')' || char == '"' || char == ';') break + builder.append(char) + index += 1 + } + return builder.toString() to index + } + + private fun isNumberText(text: String): Boolean { + if (text.isEmpty()) return false + val body = if (text[0] == '+' || text[0] == '-') text.substring(1) else text + if (body.isEmpty()) return false + var seenDigit = false + var seenDot = false + var seenExponent = false + var index = 0 + while (index < body.length) { + val char = body[index] + when { + char.isDigit() -> seenDigit = true + char == '.' && !seenDot && !seenExponent -> seenDot = true + (char == 'e' || char == 'E') && seenDigit && !seenExponent -> { + seenExponent = true + if (index + 1 < body.length && (body[index + 1] == '+' || body[index + 1] == '-')) index += 1 + } + else -> return false + } + index += 1 + } + return seenDigit + } + + private class TokenParser(private val tokens: List<GuiLispToken>) { + private var cursor = 0 + + fun hasNext(): Boolean = cursor < tokens.size + + fun parseForm(): LispValue { + val token = tokens[cursor] + return when (token.kind) { + GuiLispTokenKind.LEFT_PAREN -> parseList() + GuiLispTokenKind.RIGHT_PAREN -> throw GuiLispError.Parse("unexpected ')'", token.position) + GuiLispTokenKind.NUMBER -> { + cursor += 1 + val value = token.text.toDoubleOrNull() ?: throw GuiLispError.Parse("invalid number: ${token.text}", token.position) + LispNumber(value) + } + GuiLispTokenKind.STRING -> { + cursor += 1 + LispString(token.text) + } + GuiLispTokenKind.SYMBOL -> { + cursor += 1 + LispSymbol(token.text) + } + } + } + + private fun parseList(): LispValue { + val open = tokens[cursor] + cursor += 1 + val items = mutableListOf<LispValue>() + while (true) { + if (cursor >= tokens.size) throw GuiLispError.Parse("missing ')'", open.position) + if (tokens[cursor].kind == GuiLispTokenKind.RIGHT_PAREN) { + cursor += 1 + return LispList(items) + } + items.add(parseForm()) + } + } + } +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispBoolean.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispBoolean.kt index 9f40c1c..1d196bc 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispBoolean.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispBoolean.kt @@ -1,3 +1,5 @@ package jp.orgflow.experiment.lisp -// TODO(spec ch.07): implement LispBoolean per docs/spec.md +data class LispBoolean(val value: Boolean) : LispValue { + override fun render(): String = if (value) "true" else "false" +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispList.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispList.kt index 1fa5664..74ad6f1 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispList.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispList.kt @@ -1,3 +1,15 @@ package jp.orgflow.experiment.lisp -// TODO(spec ch.07): implement LispList per docs/spec.md +data class LispList(val items: List<LispValue>) : LispValue { + val size: Int get() = items.size + + val isEmpty: Boolean get() = items.isEmpty() + + operator fun get(index: Int): LispValue = items[index] + + override fun render(): String = items.joinToString(" ", prefix = "(", postfix = ")") { it.render() } + + companion object { + fun of(vararg values: LispValue): LispList = LispList(values.toList()) + } +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispNumber.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispNumber.kt index e5e0fba..b9c3d12 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispNumber.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispNumber.kt @@ -1,3 +1,12 @@ package jp.orgflow.experiment.lisp -// TODO(spec ch.07): implement LispNumber per docs/spec.md +import kotlin.math.floor + +data class LispNumber(val value: Double) : LispValue { + override fun render(): String = + if (value == floor(value) && !value.isInfinite() && !value.isNaN()) value.toLong().toString() else value.toString() + + companion object { + fun of(value: Int): LispNumber = LispNumber(value.toDouble()) + } +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispString.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispString.kt index d92df35..a0dcf8b 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispString.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispString.kt @@ -1,3 +1,8 @@ package jp.orgflow.experiment.lisp -// TODO(spec ch.07): implement LispString per docs/spec.md +data class LispString(val value: String) : LispValue { + override fun render(): String { + val escaped = value.replace("\\", "\\\\").replace("\"", "\\\"") + return "\"$escaped\"" + } +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispSymbol.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispSymbol.kt index 8f5117a..89a3010 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispSymbol.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispSymbol.kt @@ -1,3 +1,5 @@ package jp.orgflow.experiment.lisp -// TODO(spec ch.07): implement LispSymbol per docs/spec.md +data class LispSymbol(val name: String) : LispValue { + override fun render(): String = name +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispValue.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispValue.kt index ab7b45e..be39442 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispValue.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispValue.kt @@ -1,3 +1,11 @@ package jp.orgflow.experiment.lisp -// TODO(spec ch.07): implement LispValue per docs/spec.md +sealed interface LispValue { + fun render(): String +} + +fun isTruthy(value: LispValue): Boolean = when (value) { + is LispBoolean -> value.value + is LispList -> value.items.isNotEmpty() + else -> true +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/TableEnvironment.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/TableEnvironment.kt index 3eaf317..5831227 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/TableEnvironment.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/TableEnvironment.kt @@ -1,3 +1,52 @@ package jp.orgflow.experiment.lisp -// TODO(spec ch.07): implement TableEnvironment per docs/spec.md +import jp.orgflow.experiment.table.ExperimentTable +import jp.orgflow.experiment.table.ExperimentValue +import kotlin.math.floor + +class TableEnvironment(val table: ExperimentTable) { + + fun createEvaluator(limits: EvaluationLimits = EvaluationLimits()): GuiLispEvaluator { + val registry = BuiltinFunctionRegistry.withDefaults() + tableBuiltins().forEach { registry.register(it) } + return GuiLispEvaluator(GuiLispEvaluationContext(limits), registry) + } + + fun tableBuiltins(): List<BuiltinFunction> = listOf( + BuiltinFunction("row-count", 0) { _, _, _ -> + LispNumber(table.rows.size.toDouble()) + }, + BuiltinFunction("column-names", 0) { _, _, _ -> + LispList(table.columns.map { column -> LispString(column.name) }) + }, + BuiltinFunction("column-values", 1) { _, _, args -> + val name = stringArg(args, 0, "column-values") + LispList(table.columnValues(name).map { toLispValue(it) }) + }, + BuiltinFunction("get-cell", 2) { _, _, args -> + val rowIndex = intArg(args, 0, "get-cell") + val name = stringArg(args, 1, "get-cell") + val row = table.rows.getOrNull(rowIndex) + ?: throw GuiLispError.Type("row index out of range: $rowIndex") + toLispValue(row.value(name)) + }, + ) + + private fun stringArg(args: List<LispValue>, index: Int, name: String): String = + (args.getOrNull(index) as? LispString)?.value + ?: throw GuiLispError.Type("$name expects a string at argument ${index + 1}") + + private fun intArg(args: List<LispValue>, index: Int, name: String): Int { + val value = (args.getOrNull(index) as? LispNumber)?.value + ?: throw GuiLispError.Type("$name expects a row index at argument ${index + 1}") + if (value != floor(value)) throw GuiLispError.Type("$name expects an integer row index but got $value") + return value.toInt() + } + + private fun toLispValue(value: ExperimentValue): LispValue = when (value) { + is ExperimentValue.NumberValue -> LispNumber(value.value) + is ExperimentValue.StringValue -> LispString(value.text) + is ExperimentValue.BooleanValue -> LispBoolean(value.flag) + ExperimentValue.EmptyValue -> LispList(emptyList()) + } +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentCell.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentCell.kt index 9a1dde7..feb6252 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentCell.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentCell.kt @@ -1,3 +1,21 @@ package jp.orgflow.experiment.table -// TODO(spec ch.07): implement ExperimentCell per docs/spec.md +data class ExperimentCell( + val value: ExperimentValue, + val evidence: List<ExperimentEvidence> = emptyList(), +) { + fun withEvidence(evidence: List<ExperimentEvidence>): ExperimentCell = copy(evidence = this.evidence + evidence) + + companion object { + fun empty(): ExperimentCell = ExperimentCell(ExperimentValue.EmptyValue) + + fun number(value: Double, evidence: List<ExperimentEvidence> = emptyList()): ExperimentCell = + ExperimentCell(ExperimentValue.NumberValue(value), evidence) + + fun text(value: String, evidence: List<ExperimentEvidence> = emptyList()): ExperimentCell = + ExperimentCell(ExperimentValue.StringValue(value), evidence) + + fun flag(value: Boolean, evidence: List<ExperimentEvidence> = emptyList()): ExperimentCell = + ExperimentCell(ExperimentValue.BooleanValue(value), evidence) + } +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentColumn.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentColumn.kt index d2c20c9..55cc600 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentColumn.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentColumn.kt @@ -1,3 +1,16 @@ package jp.orgflow.experiment.table -// TODO(spec ch.07): implement ExperimentColumn per docs/spec.md +enum class ColumnType { + NUMBER, + TEXT, + BOOLEAN, +} + +data class ExperimentColumn( + val name: String, + val type: ColumnType, + val unit: String? = null, + val derivedExpression: String? = null, +) { + val isDerived: Boolean get() = derivedExpression != null +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentEvidence.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentEvidence.kt index d24ee4f..1c27c03 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentEvidence.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentEvidence.kt @@ -1,3 +1,9 @@ package jp.orgflow.experiment.table -// TODO(spec ch.07): implement ExperimentEvidence per docs/spec.md +import jp.orgflow.domain.identity.CardId + +data class ExperimentEvidence( + val cardId: CardId, + val commitId: String, + val note: String = "", +) diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentRow.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentRow.kt index c4c279f..4e6848f 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentRow.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentRow.kt @@ -1,3 +1,10 @@ package jp.orgflow.experiment.table -// TODO(spec ch.07): implement ExperimentRow per docs/spec.md +data class ExperimentRow( + val id: String, + val cells: Map<String, ExperimentCell>, +) { + operator fun get(columnName: String): ExperimentCell? = cells[columnName] + + fun value(columnName: String): ExperimentValue = cells[columnName]?.value ?: ExperimentValue.EmptyValue +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentTable.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentTable.kt index b12c444..f3dd9ce 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentTable.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentTable.kt @@ -1,3 +1,22 @@ package jp.orgflow.experiment.table -// TODO(spec ch.07): implement ExperimentTable per docs/spec.md +data class ExperimentTable( + val id: String, + val title: String, + val columns: List<ExperimentColumn> = emptyList(), + val rows: List<ExperimentRow> = emptyList(), + val sourceCommitId: String? = null, +) { + fun columnByName(name: String): ExperimentColumn? = columns.firstOrNull { it.name == name } + + fun rowById(id: String): ExperimentRow? = rows.firstOrNull { it.id == id } + + fun withRow(row: ExperimentRow): ExperimentTable = + copy(rows = rows.filterNot { it.id == row.id } + row) + + fun withoutRow(rowId: String): ExperimentTable = + copy(rows = rows.filterNot { it.id == rowId }) + + fun columnValues(columnName: String): List<ExperimentValue> = + rows.mapNotNull { row -> row.value(columnName).takeIf { value -> !value.isEmpty } } +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentTableRepository.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentTableRepository.kt index a16a7b9..fe4e2aa 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentTableRepository.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentTableRepository.kt @@ -1,3 +1,27 @@ package jp.orgflow.experiment.table -// TODO(spec ch.07): implement ExperimentTableRepository per docs/spec.md +interface ExperimentTableRepository { + fun save(table: ExperimentTable) + + fun findById(id: String): ExperimentTable? + + fun findAll(): List<ExperimentTable> + + fun delete(id: String) +} + +class InMemoryExperimentTableRepository : ExperimentTableRepository { + private val tables = LinkedHashMap<String, ExperimentTable>() + + override fun save(table: ExperimentTable) { + tables[table.id] = table + } + + override fun findById(id: String): ExperimentTable? = tables[id] + + override fun findAll(): List<ExperimentTable> = tables.values.toList() + + override fun delete(id: String) { + tables.remove(id) + } +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentTableService.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentTableService.kt index 9ddff74..9a8ecc3 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentTableService.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentTableService.kt @@ -1,3 +1,172 @@ package jp.orgflow.experiment.table -// TODO(spec ch.07): implement ExperimentTableService per docs/spec.md +import jp.orgflow.experiment.lisp.GuiLispEvaluator +import jp.orgflow.experiment.lisp.LispBoolean +import jp.orgflow.experiment.lisp.LispList +import jp.orgflow.experiment.lisp.LispNumber +import jp.orgflow.experiment.lisp.LispString +import jp.orgflow.experiment.lisp.LispValue + +data class TableValidationError( + val rowId: String?, + val columnName: String, + val message: String, +) + +sealed interface RowMutationResult { + data class Success(val table: ExperimentTable) : RowMutationResult + + data class Failure(val table: ExperimentTable, val errors: List<TableValidationError>) : RowMutationResult +} + +class ExperimentTableService( + private val repository: ExperimentTableRepository, + private val idGenerator: () -> String = { "exp-${nextId()}" }, +) { + private val evaluator = GuiLispEvaluator() + + fun create( + title: String, + columns: List<ExperimentColumn>, + id: String = idGenerator(), + sourceCommitId: String? = null, + ): ExperimentTable { + val table = ExperimentTable( + id = id, + title = title, + columns = columns, + sourceCommitId = sourceCommitId, + ) + repository.save(table) + return table + } + + fun find(tableId: String): ExperimentTable? = repository.findById(tableId) + + fun findAll(): List<ExperimentTable> = repository.findAll() + + fun delete(tableId: String) = repository.delete(tableId) + + fun addRow( + tableId: String, + cells: Map<String, ExperimentCell>, + rowId: String = idGenerator(), + ): RowMutationResult { + val table = repository.findById(tableId) + ?: throw IllegalArgumentException("unknown table: $tableId") + val errors = validateCells(table, rowId, cells) + if (errors.isNotEmpty()) return RowMutationResult.Failure(table, errors) + val updated = recomputeDerivedColumns(table.withRow(ExperimentRow(rowId, cells))) + repository.save(updated) + return RowMutationResult.Success(updated) + } + + fun updateCell( + tableId: String, + rowId: String, + columnName: String, + cell: ExperimentCell, + ): RowMutationResult { + val table = repository.findById(tableId) + ?: throw IllegalArgumentException("unknown table: $tableId") + val row = table.rowById(rowId) + ?: return RowMutationResult.Failure( + table, + listOf(TableValidationError(rowId, columnName, "unknown row: $rowId")), + ) + val merged = row.cells + (columnName to cell) + val errors = validateCells(table, rowId, merged) + if (errors.isNotEmpty()) return RowMutationResult.Failure(table, errors) + val updated = recomputeDerivedColumns(table.withRow(row.copy(cells = merged))) + repository.save(updated) + return RowMutationResult.Success(updated) + } + + fun removeRow(tableId: String, rowId: String): RowMutationResult { + val table = repository.findById(tableId) + ?: throw IllegalArgumentException("unknown table: $tableId") + if (table.rowById(rowId) == null) { + return RowMutationResult.Failure( + table, + listOf(TableValidationError(rowId, "", "unknown row: $rowId")), + ) + } + val updated = table.withoutRow(rowId) + repository.save(updated) + return RowMutationResult.Success(updated) + } + + fun refreshDerivedColumns(tableId: String): ExperimentTable { + val table = repository.findById(tableId) + ?: throw IllegalArgumentException("unknown table: $tableId") + val updated = recomputeDerivedColumns(table) + repository.save(updated) + return updated + } + + fun validateCells( + table: ExperimentTable, + rowId: String?, + cells: Map<String, ExperimentCell>, + ): List<TableValidationError> { + val errors = mutableListOf<TableValidationError>() + cells.forEach { (columnName, cell) -> + val column = table.columnByName(columnName) + if (column == null) { + errors.add(TableValidationError(rowId, columnName, "unknown column: $columnName")) + return@forEach + } + val message = validateCellType(column, cell.value) + if (message != null) errors.add(TableValidationError(rowId, columnName, message)) + } + return errors + } + + fun validateCellType(column: ExperimentColumn, value: ExperimentValue): String? { + if (value.isEmpty) return null + val matches = when (column.type) { + ColumnType.NUMBER -> value is ExperimentValue.NumberValue + ColumnType.TEXT -> value is ExperimentValue.StringValue + ColumnType.BOOLEAN -> value is ExperimentValue.BooleanValue + } + return if (matches) { + null + } else { + "column ${column.name} expects ${column.type.name} but got ${value::class.simpleName}" + } + } + + private fun recomputeDerivedColumns(table: ExperimentTable): ExperimentTable { + val derivedColumns = table.columns.filter { it.isDerived } + if (derivedColumns.isEmpty()) return table + val rows = table.rows.map { row -> + val bindings = table.columns.associate { column -> column.name to toLispValue(row.value(column.name)) } + var cells = row.cells + derivedColumns.forEach { column -> + val expression = column.derivedExpression ?: return@forEach + val evaluated = runCatching { evaluator.evaluateWithBindings(expression, bindings) }.getOrNull() + val cellValue = (evaluated as? LispNumber)?.let { ExperimentValue.NumberValue(it.value) } + ?: ExperimentValue.EmptyValue + cells = cells + (column.name to ExperimentCell(cellValue)) + } + row.copy(cells = cells) + } + return table.copy(rows = rows) + } + + private fun toLispValue(value: ExperimentValue): LispValue = when (value) { + is ExperimentValue.NumberValue -> LispNumber(value.value) + is ExperimentValue.StringValue -> LispString(value.text) + is ExperimentValue.BooleanValue -> LispBoolean(value.flag) + ExperimentValue.EmptyValue -> LispList(emptyList()) + } + + companion object { + private var idCounter: Long = 0 + + private fun nextId(): String { + idCounter += 1 + return idCounter.toString() + } + } +} diff --git a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentValue.kt b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentValue.kt index 01965de..1fd360a 100644 --- a/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentValue.kt +++ b/modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentValue.kt @@ -1,3 +1,36 @@ package jp.orgflow.experiment.table -// TODO(spec ch.07): implement ExperimentValue per docs/spec.md +sealed interface ExperimentValue { + data class NumberValue(val value: Double) : ExperimentValue + + data class StringValue(val text: String) : ExperimentValue + + data class BooleanValue(val flag: Boolean) : ExperimentValue + + object EmptyValue : ExperimentValue { + override fun toString(): String = "EmptyValue" + } + + val isEmpty: Boolean + get() = this is EmptyValue + + companion object { + val EMPTY: ExperimentValue = EmptyValue + + fun number(value: Double): ExperimentValue = NumberValue(value) + + fun text(value: String): ExperimentValue = StringValue(value) + + fun flag(value: Boolean): ExperimentValue = BooleanValue(value) + } +} + +fun ExperimentValue.toDisplayText(): String = when (this) { + is ExperimentValue.NumberValue -> + if (value == floor(value) && !value.isInfinite() && !value.isNaN()) value.toLong().toString() else value.toString() + is ExperimentValue.StringValue -> text + is ExperimentValue.BooleanValue -> flag.toString() + ExperimentValue.EmptyValue -> "" +} + +private fun floor(value: Double): Double = kotlin.math.floor(value) diff --git a/modules/orgflow-experiment/src/commonTest/kotlin/jp/orgflow/experiment/ChartCompilerTest.kt b/modules/orgflow-experiment/src/commonTest/kotlin/jp/orgflow/experiment/ChartCompilerTest.kt index 078aa61..502f25a 100644 --- a/modules/orgflow-experiment/src/commonTest/kotlin/jp/orgflow/experiment/ChartCompilerTest.kt +++ b/modules/orgflow-experiment/src/commonTest/kotlin/jp/orgflow/experiment/ChartCompilerTest.kt @@ -1,3 +1,159 @@ package jp.orgflow.experiment -// TODO(spec ch.07): implement ChartCompilerTest per docs/spec.md +import jp.orgflow.domain.identity.CardId +import jp.orgflow.experiment.chart.ChartAnchorFactory +import jp.orgflow.experiment.chart.ChartType +import jp.orgflow.experiment.chart.GraphCardFactory +import jp.orgflow.experiment.chart.GuiLispChartCompiler +import jp.orgflow.experiment.lisp.GuiLispError +import jp.orgflow.experiment.lisp.TableEnvironment +import jp.orgflow.experiment.table.ColumnType +import jp.orgflow.experiment.table.ExperimentCell +import jp.orgflow.experiment.table.ExperimentColumn +import jp.orgflow.experiment.table.ExperimentRow +import jp.orgflow.experiment.table.ExperimentTable +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class ChartCompilerTest { + + private val compiler = GuiLispChartCompiler() + + private fun table(): ExperimentTable = ExperimentTable( + id = "tbl-1", + title = "growth", + sourceCommitId = "commit-9", + columns = listOf( + ExperimentColumn("day", ColumnType.NUMBER), + ExperimentColumn("height", ColumnType.NUMBER, unit = "cm"), + ), + rows = listOf( + ExperimentRow("r1", mapOf("day" to ExperimentCell.number(1.0), "height" to ExperimentCell.number(5.0))), + ExperimentRow("r2", mapOf("day" to ExperimentCell.number(2.0), "height" to ExperimentCell.number(8.0))), + ExperimentRow("r3", mapOf("day" to ExperimentCell.number(3.0), "height" to ExperimentCell.number(13.0))), + ), + ) + + @Test + fun compilesBarChartExpressionFromTableColumns() { + val spec = compiler.compile( + "(bar-chart \"Growth\" (column-values \"day\") (column-values \"height\"))", + table(), + ) + assertEquals(ChartType.BAR, spec.type) + assertEquals("Growth", spec.title) + assertEquals(1, spec.series.size) + val series = spec.series[0] + assertEquals("series-1", series.name) + assertEquals(listOf(1.0, 2.0, 3.0), series.xValues) + assertEquals(listOf(5.0, 8.0, 13.0), series.yValues) + assertEquals(1.0, spec.xAxis.min) + assertEquals(3.0, spec.xAxis.max) + assertEquals(5.0, spec.yAxis.min) + assertEquals(13.0, spec.yAxis.max) + assertEquals("tbl-1", spec.sourceTableId) + assertEquals("commit-9", spec.sourceCommitId) + } + + @Test + fun compilesLineAndScatterCharts() { + val line = compiler.compile( + "(line-chart \"L\" (column-values \"day\") (column-values \"height\"))", + table(), + ) + assertEquals(ChartType.LINE, line.type) + val scatter = compiler.compile( + "(scatter-plot \"S\" (column-values \"day\") (column-values \"height\"))", + table(), + ) + assertEquals(ChartType.SCATTER, scatter.type) + } + + @Test + fun acceptsComputedSeriesAndOptionalName() { + val evaluator = TableEnvironment(table()).createEvaluator() + evaluator.evaluate("(defun double-height (v) (* 2 v))") + val spec = compiler.compile( + "(bar-chart \"Doubled\" (column-values \"day\") (map double-height (column-values \"height\")) \"doubled\")", + table(), + evaluator, + ) + assertEquals(listOf(10.0, 16.0, 26.0), spec.series[0].yValues) + assertEquals("doubled", spec.series[0].name) + } + + @Test + fun pieChartUsesIndicesForStringCategories() { + val categorical = ExperimentTable( + id = "tbl-2", + title = "mix", + columns = listOf( + ExperimentColumn("label", ColumnType.TEXT), + ExperimentColumn("share", ColumnType.NUMBER), + ), + rows = listOf( + ExperimentRow("r1", mapOf("label" to ExperimentCell.text("sun"), "share" to ExperimentCell.number(5.0))), + ExperimentRow("r2", mapOf("label" to ExperimentCell.text("shade"), "share" to ExperimentCell.number(8.0))), + ), + ) + val spec = compiler.compile( + "(pie-chart \"Mix\" (column-values \"label\") (column-values \"share\"))", + categorical, + ) + assertEquals(ChartType.PIE, spec.type) + assertEquals(listOf(0.0, 1.0), spec.series[0].xValues) + assertEquals(listOf(5.0, 8.0), spec.series[0].yValues) + } + + @Test + fun nonChartExpressionFailsWithTypeError() { + assertFailsWith<GuiLispError.Type> { compiler.compile("(+ 1 2)", table()) } + } + + @Test + fun unknownChartFunctionFailsWithTypeError() { + assertFailsWith<GuiLispError.Type> { compiler.compile("(histogram \"H\" (column-values \"day\"))", table()) } + } + + @Test + fun mismatchedAxisLengthsFailWithTypeError() { + val sparse = table().copy( + rows = listOf( + ExperimentRow("r1", mapOf("day" to ExperimentCell.number(1.0), "height" to ExperimentCell.number(5.0))), + ExperimentRow("r2", mapOf("day" to ExperimentCell.number(2.0), "height" to ExperimentCell.empty())), + ), + ) + assertFailsWith<GuiLispError.Type> { + compiler.compile("(bar-chart \"B\" (column-values \"day\") (column-values \"height\"))", sparse) + } + } + + @Test + fun graphCardFactorySerializesSpecToJson() { + val spec = compiler.compile( + "(bar-chart \"Growth\" (column-values \"day\") (column-values \"height\"))", + table(), + ) + val card = GraphCardFactory().create(spec, cardId = CardId("c1")) + assertEquals(CardId("c1"), card.id) + assertTrue(card.graphJson.contains("\"type\":\"BAR\"")) + assertTrue(card.graphJson.contains("\"title\":\"Growth\"")) + assertTrue(card.graphJson.contains("13.0")) + assertTrue(card.graphJson.contains("\"sourceCommitId\":\"commit-9\"")) + } + + @Test + fun anchorFactoryLinksChartToTableRegion() { + val anchor = ChartAnchorFactory().forWholeTable(table()) + assertEquals("tbl-1", anchor.tableId) + assertEquals(0, anchor.rowStart) + assertEquals(2, anchor.rowEnd) + assertEquals(listOf("day", "height"), anchor.columnNames) + assertEquals("commit-9", anchor.commitId) + assertEquals("tbl-1!r1-r3:day,height", anchor.region) + val partial = ChartAnchorFactory().create(table(), IntRange(1, 2), listOf("height")) + assertEquals("tbl-1!r2-r3:height", partial.region) + } +} diff --git a/modules/orgflow-experiment/src/commonTest/kotlin/jp/orgflow/experiment/ExperimentTableServiceTest.kt b/modules/orgflow-experiment/src/commonTest/kotlin/jp/orgflow/experiment/ExperimentTableServiceTest.kt index 600552e..88b85c3 100644 --- a/modules/orgflow-experiment/src/commonTest/kotlin/jp/orgflow/experiment/ExperimentTableServiceTest.kt +++ b/modules/orgflow-experiment/src/commonTest/kotlin/jp/orgflow/experiment/ExperimentTableServiceTest.kt @@ -1,3 +1,165 @@ package jp.orgflow.experiment -// TODO(spec ch.07): implement ExperimentTableServiceTest per docs/spec.md +import jp.orgflow.experiment.table.ColumnType +import jp.orgflow.experiment.table.ExperimentCell +import jp.orgflow.experiment.table.ExperimentColumn +import jp.orgflow.experiment.table.ExperimentTableService +import jp.orgflow.experiment.table.ExperimentValue +import jp.orgflow.experiment.table.InMemoryExperimentTableRepository +import jp.orgflow.experiment.table.RowMutationResult +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ExperimentTableServiceTest { + + private lateinit var service: ExperimentTableService + + @BeforeTest + fun setUp() { + service = ExperimentTableService(InMemoryExperimentTableRepository()) + } + + private fun columns(): List<ExperimentColumn> = listOf( + ExperimentColumn("day", ColumnType.NUMBER), + ExperimentColumn("temp", ColumnType.NUMBER, unit = "C"), + ExperimentColumn("note", ColumnType.TEXT), + ExperimentColumn("ok", ColumnType.BOOLEAN), + ExperimentColumn("twice", ColumnType.NUMBER, derivedExpression = "(* 2 temp)"), + ) + + @Test + fun createsTableAndStoresIt() { + val table = service.create("temperature log", columns(), id = "t1", sourceCommitId = "c0") + assertEquals("t1", table.id) + assertEquals("temperature log", table.title) + assertEquals(5, table.columns.size) + assertEquals("c0", table.sourceCommitId) + assertEquals(table, service.find("t1")) + assertNull(service.find("missing")) + } + + @Test + fun addRowPersistsCellsAndComputesDerivedColumns() { + service.create("t", columns(), id = "t1") + val result = service.addRow( + "t1", + mapOf( + "day" to ExperimentCell.number(1.0), + "temp" to ExperimentCell.number(21.0), + "note" to ExperimentCell.text("warm"), + "ok" to ExperimentCell.flag(true), + ), + rowId = "row-1", + ) + assertTrue(result is RowMutationResult.Success) + val table = (result as RowMutationResult.Success).table + assertEquals(ExperimentValue.NumberValue(42.0), table.rows[0].value("twice")) + assertEquals(table, service.find("t1")) + } + + @Test + fun addRowAllowsMissingCells() { + service.create("t", columns(), id = "t1") + val result = service.addRow("t1", mapOf("day" to ExperimentCell.number(2.0)), rowId = "row-1") + assertTrue(result is RowMutationResult.Success) + val row = (result as RowMutationResult.Success).table.rows[0] + assertTrue(row.value("temp").isEmpty) + assertTrue(row.value("twice").isEmpty) + } + + @Test + fun addRowRejectsWrongColumnType() { + service.create("t", columns(), id = "t1") + val result = service.addRow( + "t1", + mapOf( + "day" to ExperimentCell.number(1.0), + "temp" to ExperimentCell.text("hot"), + ), + rowId = "row-1", + ) + assertTrue(result is RowMutationResult.Failure) + val errors = (result as RowMutationResult.Failure).errors + assertEquals(1, errors.size) + assertEquals("temp", errors[0].columnName) + assertEquals("row-1", errors[0].rowId) + assertTrue(errors[0].message.contains("NUMBER")) + assertEquals(0, service.find("t1")!!.rows.size) + } + + @Test + fun addRowRejectsBooleanInNumberColumn() { + service.create("t", columns(), id = "t1") + val result = service.addRow("t1", mapOf("day" to ExperimentCell.flag(true)), rowId = "row-1") + assertTrue(result is RowMutationResult.Failure) + } + + @Test + fun addRowRejectsUnknownColumn() { + service.create("t", columns(), id = "t1") + val result = service.addRow("t1", mapOf("mystery" to ExperimentCell.number(1.0)), rowId = "row-1") + assertTrue(result is RowMutationResult.Failure) + assertEquals("unknown column: mystery", (result as RowMutationResult.Failure).errors.single().message) + } + + @Test + fun updateCellValidatesAndRecomputesDerivedValue() { + service.create("t", columns(), id = "t1") + service.addRow( + "t1", + mapOf( + "day" to ExperimentCell.number(1.0), + "temp" to ExperimentCell.number(21.0), + "note" to ExperimentCell.text("warm"), + "ok" to ExperimentCell.flag(false), + ), + rowId = "row-1", + ) + val updated = service.updateCell("t1", "row-1", "temp", ExperimentCell.number(25.0)) + assertTrue(updated is RowMutationResult.Success) + val stored = service.find("t1")!! + assertEquals( + 25.0, + (stored.rows[0].value("temp") as ExperimentValue.NumberValue).value, + ) + assertEquals( + 50.0, + (stored.rows[0].value("twice") as ExperimentValue.NumberValue).value, + ) + val rejected = service.updateCell("t1", "row-1", "temp", ExperimentCell.text("hot")) + assertTrue(rejected is RowMutationResult.Failure) + assertEquals(25.0, (service.find("t1")!!.rows[0].value("temp") as ExperimentValue.NumberValue).value) + } + + @Test + fun updateCellOnUnknownRowFails() { + service.create("t", columns(), id = "t1") + val result = service.updateCell("t1", "nope", "temp", ExperimentCell.number(1.0)) + assertTrue(result is RowMutationResult.Failure) + } + + @Test + fun removeRowDeletesIt() { + service.create("t", columns(), id = "t1") + service.addRow("t1", mapOf("day" to ExperimentCell.number(1.0)), rowId = "row-1") + val result = service.removeRow("t1", "row-1") + assertTrue(result is RowMutationResult.Success) + assertEquals(0, service.find("t1")!!.rows.size) + assertTrue(service.removeRow("t1", "row-1") is RowMutationResult.Failure) + } + + @Test + fun refreshDerivedColumnsRecomputesAllRows() { + service.create("t", columns(), id = "t1") + service.addRow("t1", mapOf("day" to ExperimentCell.number(1.0), "temp" to ExperimentCell.number(10.0)), rowId = "r1") + service.addRow("t1", mapOf("day" to ExperimentCell.number(2.0), "temp" to ExperimentCell.number(11.0)), rowId = "r2") + val refreshed = service.refreshDerivedColumns("t1") + assertEquals( + listOf(20.0, 22.0), + refreshed.rows.map { row -> (row.value("twice") as ExperimentValue.NumberValue).value }, + ) + } +} diff --git a/modules/orgflow-experiment/src/commonTest/kotlin/jp/orgflow/experiment/GuiLispEvaluatorTest.kt b/modules/orgflow-experiment/src/commonTest/kotlin/jp/orgflow/experiment/GuiLispEvaluatorTest.kt index 2cbeb9f..5733338 100644 --- a/modules/orgflow-experiment/src/commonTest/kotlin/jp/orgflow/experiment/GuiLispEvaluatorTest.kt +++ b/modules/orgflow-experiment/src/commonTest/kotlin/jp/orgflow/experiment/GuiLispEvaluatorTest.kt @@ -1,3 +1,202 @@ package jp.orgflow.experiment -// TODO(spec ch.07): implement GuiLispEvaluatorTest per docs/spec.md +import jp.orgflow.experiment.lisp.EvaluationLimits +import jp.orgflow.experiment.lisp.GuiLispError +import jp.orgflow.experiment.lisp.GuiLispEvaluationContext +import jp.orgflow.experiment.lisp.GuiLispEvaluator +import jp.orgflow.experiment.lisp.LispBoolean +import jp.orgflow.experiment.lisp.LispList +import jp.orgflow.experiment.lisp.LispNumber +import jp.orgflow.experiment.lisp.LispString +import jp.orgflow.experiment.lisp.TableEnvironment +import jp.orgflow.experiment.table.ColumnType +import jp.orgflow.experiment.table.ExperimentCell +import jp.orgflow.experiment.table.ExperimentColumn +import jp.orgflow.experiment.table.ExperimentRow +import jp.orgflow.experiment.table.ExperimentTable +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class GuiLispEvaluatorTest { + + private fun number(value: LispNumber): Double = value.value + + @Test + fun evaluatesArithmetic() { + val evaluator = GuiLispEvaluator() + assertEquals(6.0, number(evaluator.evaluate("(+ 1 2 3)") as LispNumber)) + assertEquals(-4.0, number(evaluator.evaluate("(- 1 5)") as LispNumber)) + assertEquals(12.0, number(evaluator.evaluate("(* 3 4)") as LispNumber)) + assertEquals(2.5, number(evaluator.evaluate("(/ 10 4)") as LispNumber)) + assertEquals(0.5, number(evaluator.evaluate("(/ 2)") as LispNumber)) + assertEquals(-3.0, number(evaluator.evaluate("(- 3)") as LispNumber)) + } + + @Test + fun evaluatesComparisonsAndConditionals() { + val evaluator = GuiLispEvaluator() + assertEquals(false, (evaluator.evaluate("(= 1 2)") as LispBoolean).value) + assertEquals(true, (evaluator.evaluate("(= 2 2.0)") as LispBoolean).value) + assertEquals(true, (evaluator.evaluate("(< 1 2 3)") as LispBoolean).value) + assertEquals(false, (evaluator.evaluate("(<= 3 2)") as LispBoolean).value) + assertEquals(true, (evaluator.evaluate("(if (< 1 2) true false)") as LispBoolean).value) + assertEquals(2.0, number(evaluator.evaluate("(if (> 3 2) (+ 1 1) 0)") as LispNumber)) + } + + @Test + fun evaluatesLetAndCondAndLogic() { + val evaluator = GuiLispEvaluator() + assertEquals(7.0, number(evaluator.evaluate("(let ((a 3) (b 4)) (+ a b))") as LispNumber)) + assertEquals("big", (evaluator.evaluate("(cond ((< 5 1) \"small\") (else \"big\"))") as LispString).value) + assertEquals(false, (evaluator.evaluate("(and true false)") as LispBoolean).value) + assertEquals(true, (evaluator.evaluate("(or false true)") as LispBoolean).value) + } + + @Test + fun evaluatesListOperations() { + val evaluator = GuiLispEvaluator() + assertEquals(1.0, number(evaluator.evaluate("(car (list 1 2 3))") as LispNumber)) + assertEquals( + listOf(2.0, 3.0), + (evaluator.evaluate("(cdr (list 1 2 3))") as LispList).items.map { number(it as LispNumber) }, + ) + assertEquals(3.0, number(evaluator.evaluate("(length (list 9 8 7))") as LispNumber)) + assertEquals(6.0, number(evaluator.evaluate("(reduce + 0 (list 1 2 3))") as LispNumber)) + assertEquals( + listOf(1.0, 2.0), + (evaluator.evaluate("(cons 1 (list 2))") as LispList).items.map { number(it as LispNumber) }, + ) + } + + @Test + fun evaluatesAggregates() { + val evaluator = GuiLispEvaluator() + assertEquals(6.0, number(evaluator.evaluate("(sum (list 1 2 3))") as LispNumber)) + assertEquals(2.0, number(evaluator.evaluate("(avg (list 1 2 3))") as LispNumber)) + assertEquals(1.0, number(evaluator.evaluate("(min (list 1 2 3))") as LispNumber)) + assertEquals(3.0, number(evaluator.evaluate("(max (list 1 2 3))") as LispNumber)) + assertEquals(3.0, number(evaluator.evaluate("(count (list \"a\" \"b\" \"c\"))") as LispNumber)) + assertEquals(2.5, number(evaluator.evaluate("(median (list 1 2 3 4))") as LispNumber)) + assertEquals(2.0, number(evaluator.evaluate("(median (list 3 1 2))") as LispNumber)) + assertEquals( + listOf("a", 2.0, "b", 1.0), + (evaluator.evaluate("(group-count (list \"a\" \"b\" \"a\"))") as LispList).items + .flatMap { pair -> (pair as LispList).items.map { item -> if (item is LispString) item.value else number(item as LispNumber) } }, + ) + } + + @Test + fun defunSupportsRecursionWithinLimits() { + val evaluator = GuiLispEvaluator() + val result = evaluator.evaluate( + """ + (defun fact (n) (if (= n 0) 1 (* n (fact (- n 1))))) + (fact 5) + """.trimIndent(), + ) + assertEquals(120.0, number(result as LispNumber)) + } + + @Test + fun defunRecursionIsCappedByRecursionLimit() { + val evaluator = GuiLispEvaluator( + context = GuiLispEvaluationContext(EvaluationLimits(maxRecursionDepth = 100, maxSteps = 1000000)), + ) + val error = assertFailsWith<GuiLispError.Limit> { + evaluator.evaluate("(defun loop (n) (loop n)) (loop 1)") + } + assertTrue(error.message!!.contains("depth")) + } + + @Test + fun defunRecursionIsCappedByStepLimit() { + val evaluator = GuiLispEvaluator( + context = GuiLispEvaluationContext(EvaluationLimits(maxRecursionDepth = 5000, maxSteps = 120)), + ) + val error = assertFailsWith<GuiLispError.Limit> { + evaluator.evaluate("(defun down (n) (if (= n 0) 0 (down (- n 1)))) (down 1000)") + } + assertTrue(error.message!!.contains("steps")) + } + + @Test + fun listLengthLimitIsEnforced() { + val evaluator = GuiLispEvaluator( + context = GuiLispEvaluationContext(EvaluationLimits(maxListLength = 5)), + ) + assertFailsWith<GuiLispError.Limit> { evaluator.evaluate("(list 1 2 3 4 5 6)") } + } + + @Test + fun unboundSymbolRaisesUnboundError() { + val evaluator = GuiLispEvaluator() + assertFailsWith<GuiLispError.Unbound> { evaluator.evaluate("(+ 1 missing)") } + } + + @Test + fun typeErrorsAreReported() { + val evaluator = GuiLispEvaluator() + assertFailsWith<GuiLispError.Type> { evaluator.evaluate("(car 5)") } + assertFailsWith<GuiLispError.Type> { evaluator.evaluate("(+ 1 \"two\")") } + assertFailsWith<GuiLispError.Type> { evaluator.evaluate("(/ 1 0)") } + assertFailsWith<GuiLispError.Type> { evaluator.evaluate("(avg (list))") } + } + + @Test + fun tableAccessReadsCellsAndColumns() { + val evaluator = TableEnvironment(sampleTable()).createEvaluator() + assertEquals(3.0, number(evaluator.evaluate("(row-count)") as LispNumber)) + assertEquals( + listOf("day", "temp"), + (evaluator.evaluate("(column-names)") as LispList).items.map { (it as LispString).value }, + ) + assertEquals( + listOf(20.0, 21.5, 23.0), + (evaluator.evaluate("(column-values \"temp\")") as LispList).items.map { number(it as LispNumber) }, + ) + assertEquals(21.5, number(evaluator.evaluate("(get-cell 1 \"temp\")") as LispNumber)) + assertEquals(21.5, number(evaluator.evaluate("(avg (column-values \"temp\"))") as LispNumber)) + assertEquals(64.5, number(evaluator.evaluate("(sum (column-values \"temp\"))") as LispNumber)) + } + + @Test + fun mapAndFilterWorkWithDefunPredicates() { + val evaluator = TableEnvironment(sampleTable()).createEvaluator() + val result = evaluator.evaluate( + """ + (defun hot (v) (> v 21)) + (filter hot (column-values "temp")) + """.trimIndent(), + ) + assertEquals( + listOf(21.5, 23.0), + (result as LispList).items.map { number(it as LispNumber) }, + ) + val doubled = evaluator.evaluate( + """ + (defun double (v) (* 2 v)) + (map double (column-values "day")) + """.trimIndent(), + ) + assertEquals( + listOf(2.0, 4.0, 6.0), + (doubled as LispList).items.map { number(it as LispNumber) }, + ) + } + + private fun sampleTable(): ExperimentTable = ExperimentTable( + id = "t1", + title = "temperature", + columns = listOf( + ExperimentColumn("day", ColumnType.NUMBER), + ExperimentColumn("temp", ColumnType.NUMBER, unit = "C"), + ), + rows = listOf( + ExperimentRow("r1", mapOf("day" to ExperimentCell.number(1.0), "temp" to ExperimentCell.number(20.0))), + ExperimentRow("r2", mapOf("day" to ExperimentCell.number(2.0), "temp" to ExperimentCell.number(21.5))), + ExperimentRow("r3", mapOf("day" to ExperimentCell.number(3.0), "temp" to ExperimentCell.number(23.0))), + ), + ) +} diff --git a/modules/orgflow-experiment/src/commonTest/kotlin/jp/orgflow/experiment/GuiLispGrammarTest.kt b/modules/orgflow-experiment/src/commonTest/kotlin/jp/orgflow/experiment/GuiLispGrammarTest.kt index 15c83d2..606abb8 100644 --- a/modules/orgflow-experiment/src/commonTest/kotlin/jp/orgflow/experiment/GuiLispGrammarTest.kt +++ b/modules/orgflow-experiment/src/commonTest/kotlin/jp/orgflow/experiment/GuiLispGrammarTest.kt @@ -1,3 +1,114 @@ package jp.orgflow.experiment -// TODO(spec ch.07): implement GuiLispGrammarTest per docs/spec.md +import jp.orgflow.experiment.lisp.GuiLispError +import jp.orgflow.experiment.lisp.GuiLispGrammar +import jp.orgflow.experiment.lisp.GuiLispTokenKind +import jp.orgflow.experiment.lisp.LispList +import jp.orgflow.experiment.lisp.LispNumber +import jp.orgflow.experiment.lisp.LispString +import jp.orgflow.experiment.lisp.LispSymbol +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class GuiLispGrammarTest { + + private val grammar = GuiLispGrammar() + + @Test + fun tokenizesNumbersStringsSymbolsAndParens() { + val tokens = grammar.tokenize("(sum 1 2.5 \"a b\" x1)") + assertEquals( + listOf( + GuiLispTokenKind.LEFT_PAREN, + GuiLispTokenKind.SYMBOL, + GuiLispTokenKind.NUMBER, + GuiLispTokenKind.NUMBER, + GuiLispTokenKind.STRING, + GuiLispTokenKind.SYMBOL, + GuiLispTokenKind.RIGHT_PAREN, + ), + tokens.map { it.kind }, + ) + assertEquals("a b", tokens[4].text) + } + + @Test + fun ignoresComments() { + val tokens = grammar.tokenize("; header line\n(+ 1 2) ; trailing comment") + assertEquals( + listOf( + GuiLispTokenKind.LEFT_PAREN, + GuiLispTokenKind.SYMBOL, + GuiLispTokenKind.NUMBER, + GuiLispTokenKind.NUMBER, + GuiLispTokenKind.RIGHT_PAREN, + ), + tokens.map { it.kind }, + ) + } + + @Test + fun parsesNestedSExpressions() { + val form = grammar.parseExpression("(+ 1 (* 2 3))") + val list = form as LispList + assertEquals(3, list.size) + assertEquals("+", (list.items[0] as LispSymbol).name) + assertEquals(1.0, (list.items[1] as LispNumber).value) + val inner = list.items[2] as LispList + assertEquals("*", (inner.items[0] as LispSymbol).name) + assertEquals(3.0, (inner.items[2] as LispNumber).value) + } + + @Test + fun parsesNegativeNumbers() { + val form = grammar.parseExpression("(- -3.5 4)") + val list = form as LispList + assertEquals(-3.5, (list.items[1] as LispNumber).value) + } + + @Test + fun parsesStringsWithEscapes() { + val form = grammar.parseExpression("(note \"line\\nnext\" \"quote\\\"inside\")") + val list = form as LispList + assertEquals("line\nnext", (list.items[1] as LispString).value) + assertEquals("quote\"inside", (list.items[2] as LispString).value) + } + + @Test + fun renderRoundTripsThroughParser() { + val source = "(bar-chart \"Growth\" (list 1 2 3) (list 4 5 6))" + val form = grammar.parseExpression(source) + assertEquals(source, form.render()) + assertEquals(form, grammar.parseExpression(form.render())) + } + + @Test + fun programParsesMultipleTopLevelForms() { + val program = grammar.parse("(defun f (x) x) (f 1)") + assertEquals(2, program.size) + } + + @Test + fun unbalancedOpenParenFailsWithParseError() { + val error = assertFailsWith<GuiLispError.Parse> { grammar.parse("(+ 1 2") } + assertTrue(error.message!!.contains("missing")) + } + + @Test + fun unexpectedCloseParenFailsWithParseError() { + assertFailsWith<GuiLispError.Parse> { grammar.parse(")") } + } + + @Test + fun unterminatedStringFailsWithParseError() { + val error = assertFailsWith<GuiLispError.Parse> { grammar.tokenize("\"abc") } + assertTrue(error.message!!.contains("unterminated string")) + } + + @Test + fun parseExpressionRejectsMultipleForms() { + assertFailsWith<GuiLispError.Parse> { grammar.parseExpression("1 2") } + } +} diff --git a/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/CommitInfo.kt b/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/CommitInfo.kt index d351a3d..76eb1f0 100644 --- a/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/CommitInfo.kt +++ b/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/CommitInfo.kt @@ -1,3 +1,9 @@ package jp.orgflow.gitpoa.api -// TODO(spec ch.12): implement CommitInfo per docs/spec.md +data class CommitInfo( + val hash: String, + val parents: List<String>, + val message: String, + val activityId: String, + val timestampIso: String, +) diff --git a/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/DiffInfo.kt b/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/DiffInfo.kt index 8e6b3a1..3d88d8b 100644 --- a/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/DiffInfo.kt +++ b/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/DiffInfo.kt @@ -1,3 +1,8 @@ package jp.orgflow.gitpoa.api -// TODO(spec ch.12): implement DiffInfo per docs/spec.md +data class DiffInfo( + val path: String, + val oldHash: String?, + val newHash: String?, + val changeType: String, +) diff --git a/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/GitPoaService.kt b/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/GitPoaService.kt index 032cd01..1328783 100644 --- a/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/GitPoaService.kt +++ b/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/GitPoaService.kt @@ -1,32 +1 @@ package jp.orgflow.gitpoa.api - -data class CommitInfo( - val hash: String, - val parents: List<String>, - val message: String, - val activityId: String, - val timestampIso: String, -) - -data class DiffInfo( - val path: String, - val oldHash: String?, - val newHash: String?, - val changeType: String, -) - -data class TreeInfo( - val hash: String, - val path: String, -) - -data class TagInfo( - val name: String, - val commitHash: String, -) - -data class TreeEntryInfo( - val path: String, - val objectId: String, - val type: String, -)
\ No newline at end of file diff --git a/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/TagInfo.kt b/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/TagInfo.kt index 72ef710..ff72515 100644 --- a/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/TagInfo.kt +++ b/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/TagInfo.kt @@ -1,3 +1,6 @@ package jp.orgflow.gitpoa.api -// TODO(spec ch.12): implement TagInfo per docs/spec.md +data class TagInfo( + val name: String, + val commitHash: String, +) diff --git a/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/TreeEntryInfo.kt b/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/TreeEntryInfo.kt index 6048a53..b7d2943 100644 --- a/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/TreeEntryInfo.kt +++ b/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/TreeEntryInfo.kt @@ -1,3 +1,7 @@ package jp.orgflow.gitpoa.api -// TODO(spec ch.11): implement TreeEntryInfo per docs/spec.md +data class TreeEntryInfo( + val path: String, + val objectId: String, + val type: String, +) diff --git a/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/TreeInfo.kt b/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/TreeInfo.kt index 37f5992..4f2f0ae 100644 --- a/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/TreeInfo.kt +++ b/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/TreeInfo.kt @@ -1,3 +1,6 @@ package jp.orgflow.gitpoa.api -// TODO(spec ch.11): implement TreeInfo per docs/spec.md +data class TreeInfo( + val hash: String, + val path: String, +) diff --git a/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/gossip/CommitHashGossip.kt b/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/gossip/CommitHashGossip.kt index 85cd5ac..d91b943 100644 --- a/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/gossip/CommitHashGossip.kt +++ b/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/gossip/CommitHashGossip.kt @@ -1,11 +1,5 @@ package jp.orgflow.gitpoa.gossip -data class CommitObservation( - val commitHash: String, - val peerId: String, - val timestampIso: String, -) - class CommitHashGossip { private val observations = mutableListOf<CommitObservation>() @@ -17,4 +11,4 @@ class CommitHashGossip { observations.filter { it.commitHash == commitHash } fun allObservations(): List<CommitObservation> = observations.toList() -}
\ No newline at end of file +} diff --git a/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/gossip/CommitObservation.kt b/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/gossip/CommitObservation.kt index 32bc199..120200b 100644 --- a/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/gossip/CommitObservation.kt +++ b/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/gossip/CommitObservation.kt @@ -1,3 +1,7 @@ package jp.orgflow.gitpoa.gossip -// TODO(spec ch.12): implement CommitObservation per docs/spec.md +data class CommitObservation( + val commitHash: String, + val peerId: String, + val timestampIso: String, +) diff --git a/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/policy/PoaState.kt b/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/policy/PoaState.kt index ea56fcd..33e64e1 100644 --- a/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/policy/PoaState.kt +++ b/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/policy/PoaState.kt @@ -1,3 +1,8 @@ package jp.orgflow.gitpoa.policy -// TODO(spec ch.12): implement PoaState per docs/spec.md +enum class PoaState { + INITIAL, + PENDING_COMMIT, + COMMITTED, + VERIFIED, +} diff --git a/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/policy/PoaStateMachine.kt b/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/policy/PoaStateMachine.kt index b93cb3e..4d5ba56 100644 --- a/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/policy/PoaStateMachine.kt +++ b/modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/policy/PoaStateMachine.kt @@ -1,12 +1,5 @@ package jp.orgflow.gitpoa.policy -enum class PoaState { - INITIAL, - PENDING_COMMIT, - COMMITTED, - VERIFIED, -} - class PoaStateMachine { private var state: PoaState = PoaState.INITIAL @@ -23,4 +16,4 @@ class PoaStateMachine { fun currentState(): PoaState = state fun canCommit(): Boolean = state == PoaState.PENDING_COMMIT fun isVerified(): Boolean = state == PoaState.VERIFIED -}
\ No newline at end of file +} diff --git a/modules/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/ActivityTreeTest.kt b/modules/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/ActivityTreeTest.kt index 0f72a7a..e96f857 100644 --- a/modules/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/ActivityTreeTest.kt +++ b/modules/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/ActivityTreeTest.kt @@ -1,3 +1,68 @@ package jp.orgflow.tree -// TODO(spec ch.11): implement ActivityTreeTest per docs/spec.md +import jp.orgflow.tree.activity.ActivityGroupNode +import jp.orgflow.tree.activity.ActivityTree +import jp.orgflow.tree.activity.ActivityTreeBuilder +import jp.orgflow.tree.activity.CardNode +import jp.orgflow.tree.activity.ProjectNode +import jp.orgflow.tree.activity.TaskNode +import jp.orgflow.tree.activity.WorkspaceNode +import kukuri.core.identity.ActivityId +import kukuri.core.identity.CardId +import kukuri.core.identity.ProjectId +import kukuri.core.identity.TaskId +import kukuri.core.identity.WorkspaceId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ActivityTreeTest { + @Test + fun builderComposesWorkspaceAndProject() { + val tree = ActivityTreeBuilder() + .workspace(WorkspaceNode("ws-1", WorkspaceId("ws-1"), "Main")) + .project("ws-1", ProjectNode("proj-1", ProjectId("proj-1"), "Research")) + .build() + + assertEquals(WorkspaceId("ws-1"), tree.workspaceId) + assertEquals(2, tree.allNodes().size) + assertNotNull(tree.findById("ws-1")) + assertNotNull(tree.findById("proj-1")) + } + + @Test + fun allNodesTraversesChildren() { + val task = TaskNode("task-1", TaskId("task-1"), "Write report", true) + val group = ActivityGroupNode( + nodeId = "act-1", + activityId = ActivityId("act-1"), + title = "Experiment", + children = listOf(task), + ) + val tree = ActivityTree(WorkspaceId("ws-1"), listOf(group)) + + assertEquals(listOf("act-1", "task-1"), tree.allNodes().map { it.nodeId }) + } + + @Test + fun groupNodeKeepsCardsAndTasks() { + val card = CardNode("card-1", CardId("card-1"), "note") + val task = TaskNode("task-1", TaskId("task-1"), "Write report") + val group = ActivityGroupNode("act-1", ActivityId("act-1"), "Experiment", listOf(card), listOf(task)) + val tree = ActivityTree(WorkspaceId("ws-1"), listOf(group)) + + val found = tree.findById("act-1") as ActivityGroupNode + assertEquals(listOf(card), found.cards) + assertEquals(listOf(task), found.tasks) + } + + @Test + fun findByIdReturnsNullForUnknownId() { + val tree = ActivityTree(WorkspaceId("ws-1"), emptyList()) + + assertNull(tree.findById("missing")) + assertTrue(tree.allNodes().isEmpty()) + } +} diff --git a/modules/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/AnchorTreeTest.kt b/modules/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/AnchorTreeTest.kt index 8121910..0b0372e 100644 --- a/modules/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/AnchorTreeTest.kt +++ b/modules/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/AnchorTreeTest.kt @@ -1,3 +1,59 @@ package jp.orgflow.tree -// TODO(spec ch.11): implement AnchorTreeTest per docs/spec.md +import jp.orgflow.tree.anchor.AnchorDependency +import jp.orgflow.tree.anchor.AnchorId +import jp.orgflow.tree.anchor.AnchorTree +import jp.orgflow.tree.anchor.AnchorTreeBuilder +import jp.orgflow.tree.anchor.AnchorType +import jp.orgflow.tree.anchor.SeparatorAnchor +import jp.orgflow.tree.common.Edge +import jp.orgflow.tree.common.GraphValidation +import jp.orgflow.tree.common.TreeAlgorithms +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AnchorTreeTest { + @Test + fun builderProducesNodesDependenciesAndSeparators() { + val a = AnchorId("a") + val b = AnchorId("b") + val tree = AnchorTreeBuilder() + .addNode(a, AnchorType.BASE) + .addNode(b, AnchorType.FULL) + .addDependency(a, b, "parent-child") + .addSeparator(a, "section") + .build() + + assertEquals(mapOf(a to AnchorType.BASE, b to AnchorType.FULL), tree.nodes) + assertEquals(listOf(AnchorDependency(a, b, "parent-child")), tree.dependenciesOf(a)) + assertEquals(listOf(AnchorDependency(a, b, "parent-child")), tree.dependentsOf(b)) + assertEquals(listOf(SeparatorAnchor(a, "section")), tree.separators) + } + + @Test + fun unknownAnchorHasNoDependencies() { + val tree = AnchorTree() + + assertTrue(tree.dependenciesOf(AnchorId("x")).isEmpty()) + assertTrue(tree.dependentsOf(AnchorId("x")).isEmpty()) + } + + @Test + fun duplicateAnchorIdsAreDetected() { + val result = GraphValidation.checkDuplicateIds(listOf("a", "b", "a")) + + assertFalse(result.valid) + assertEquals(listOf("Duplicate id: a"), result.errors) + } + + @Test + fun anchorDependenciesStayAcyclic() { + val edges = listOf(Edge("a", "b"), Edge("b", "c")) + + assertFalse(TreeAlgorithms.hasCycle(edges)) + assertEquals(listOf("a", "b", "c"), TreeAlgorithms.topologicalSort(edges)) + assertTrue(TreeAlgorithms.hasCycle(listOf(Edge("a", "b"), Edge("b", "a")))) + } +} diff --git a/modules/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/GitTreeSnapshotTest.kt b/modules/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/GitTreeSnapshotTest.kt index f4cf621..dce6f0d 100644 --- a/modules/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/GitTreeSnapshotTest.kt +++ b/modules/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/GitTreeSnapshotTest.kt @@ -1,3 +1,49 @@ package jp.orgflow.tree -// TODO(spec ch.11): implement GitTreeSnapshotTest per docs/spec.md +import jp.orgflow.tree.git.GitObjectType +import jp.orgflow.tree.git.GitTreeEntry +import jp.orgflow.tree.git.GitTreeSnapshot +import jp.orgflow.tree.git.SourceCommit +import jp.orgflow.tree.git.SourcePath +import jp.orgflow.tree.git.SourceTree +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class GitTreeSnapshotTest { + @Test + fun sourcePathParsesAndJoinsSegments() { + val path = SourcePath.parse("activities/a-1.org") + + assertEquals(listOf("activities", "a-1.org"), path.segments) + assertEquals("activities/a-1.org", path.path) + } + + @Test + fun sourceTreeFindsEntryByPath() { + val entry = GitTreeEntry("activities/a-1.org", "abc123", GitObjectType.BLOB) + val tree = SourceTree(SourcePath.parse("activities"), listOf(entry)) + + assertEquals(entry, tree.entryByPath("activities/a-1.org")) + assertNull(tree.entryByPath("missing.org")) + } + + @Test + fun snapshotHoldsCommitAndTree() { + val commit = SourceCommit( + hash = "c1", + parents = listOf("c0"), + message = "[PoA] a-1: update status", + rootTreeHash = "t1", + ) + val tree = SourceTree(SourcePath.parse("")) + val snapshot = GitTreeSnapshot(commit, tree) + + assertEquals("c1", snapshot.commit.hash) + assertEquals(listOf("c0"), snapshot.commit.parents) + assertEquals("t1", snapshot.commit.rootTreeHash) + assertTrue(snapshot.tree.entries.isEmpty()) + } +} diff --git a/modules/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/TreeMappingVerifierTest.kt b/modules/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/TreeMappingVerifierTest.kt index 9338006..57f3884 100644 --- a/modules/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/TreeMappingVerifierTest.kt +++ b/modules/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/TreeMappingVerifierTest.kt @@ -1,3 +1,70 @@ package jp.orgflow.tree -// TODO(spec ch.11): implement TreeMappingVerifierTest per docs/spec.md +import jp.orgflow.tree.activity.ProjectNode +import jp.orgflow.tree.activity.WorkspaceNode +import jp.orgflow.tree.anchor.AnchorId +import jp.orgflow.tree.anchor.AnchorTree +import jp.orgflow.tree.anchor.AnchorTreeBuilder +import jp.orgflow.tree.anchor.AnchorType +import jp.orgflow.tree.git.GitTreeSnapshot +import jp.orgflow.tree.git.SourceCommit +import jp.orgflow.tree.git.SourcePath +import jp.orgflow.tree.git.SourceTree +import kukuri.core.identity.ProjectId +import kukuri.core.identity.WorkspaceId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TreeMappingVerifierTest { + @Test + fun uniqueNodeIdsPassVerification() { + val nodes = listOf( + WorkspaceNode( + nodeId = "ws-1", + workspaceId = WorkspaceId("ws-1"), + title = "Main", + children = listOf(ProjectNode("proj-1", ProjectId("proj-1"), "Research")), + ), + ) + + val result = TreeMappingVerifier().verify(nodes, null, null) + + assertTrue(result.valid) + assertTrue(result.errors.isEmpty()) + } + + @Test + fun duplicateNodeIdsAreReported() { + val nodes = listOf( + WorkspaceNode( + nodeId = "dup", + workspaceId = WorkspaceId("ws-1"), + title = "Main", + children = listOf(ProjectNode("dup", ProjectId("proj-1"), "Research")), + ), + ) + + val result = TreeMappingVerifier().verify(nodes, null, null) + + assertFalse(result.valid) + assertEquals(listOf("Duplicate id: dup"), result.errors) + } + + @Test + fun verificationToleratesGitSnapshotAndAnchorTree() { + val nodes = listOf(WorkspaceNode("ws-1", WorkspaceId("ws-1"), "Main")) + val snapshot = GitTreeSnapshot( + commit = SourceCommit(hash = "c1", rootTreeHash = "t1"), + tree = SourceTree(SourcePath.parse("")), + ) + val anchorTree: AnchorTree = AnchorTreeBuilder() + .addNode(AnchorId("anchor-1"), AnchorType.BASE) + .build() + + val result = TreeMappingVerifier().verify(nodes, snapshot, anchorTree) + + assertTrue(result.valid) + } +} diff --git a/modules/orgflow-tree/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/ActivityTreeTest.kt b/modules/orgflow-tree/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/ActivityTreeTest.kt index 0f72a7a..e96f857 100644 --- a/modules/orgflow-tree/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/ActivityTreeTest.kt +++ b/modules/orgflow-tree/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/ActivityTreeTest.kt @@ -1,3 +1,68 @@ package jp.orgflow.tree -// TODO(spec ch.11): implement ActivityTreeTest per docs/spec.md +import jp.orgflow.tree.activity.ActivityGroupNode +import jp.orgflow.tree.activity.ActivityTree +import jp.orgflow.tree.activity.ActivityTreeBuilder +import jp.orgflow.tree.activity.CardNode +import jp.orgflow.tree.activity.ProjectNode +import jp.orgflow.tree.activity.TaskNode +import jp.orgflow.tree.activity.WorkspaceNode +import kukuri.core.identity.ActivityId +import kukuri.core.identity.CardId +import kukuri.core.identity.ProjectId +import kukuri.core.identity.TaskId +import kukuri.core.identity.WorkspaceId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ActivityTreeTest { + @Test + fun builderComposesWorkspaceAndProject() { + val tree = ActivityTreeBuilder() + .workspace(WorkspaceNode("ws-1", WorkspaceId("ws-1"), "Main")) + .project("ws-1", ProjectNode("proj-1", ProjectId("proj-1"), "Research")) + .build() + + assertEquals(WorkspaceId("ws-1"), tree.workspaceId) + assertEquals(2, tree.allNodes().size) + assertNotNull(tree.findById("ws-1")) + assertNotNull(tree.findById("proj-1")) + } + + @Test + fun allNodesTraversesChildren() { + val task = TaskNode("task-1", TaskId("task-1"), "Write report", true) + val group = ActivityGroupNode( + nodeId = "act-1", + activityId = ActivityId("act-1"), + title = "Experiment", + children = listOf(task), + ) + val tree = ActivityTree(WorkspaceId("ws-1"), listOf(group)) + + assertEquals(listOf("act-1", "task-1"), tree.allNodes().map { it.nodeId }) + } + + @Test + fun groupNodeKeepsCardsAndTasks() { + val card = CardNode("card-1", CardId("card-1"), "note") + val task = TaskNode("task-1", TaskId("task-1"), "Write report") + val group = ActivityGroupNode("act-1", ActivityId("act-1"), "Experiment", listOf(card), listOf(task)) + val tree = ActivityTree(WorkspaceId("ws-1"), listOf(group)) + + val found = tree.findById("act-1") as ActivityGroupNode + assertEquals(listOf(card), found.cards) + assertEquals(listOf(task), found.tasks) + } + + @Test + fun findByIdReturnsNullForUnknownId() { + val tree = ActivityTree(WorkspaceId("ws-1"), emptyList()) + + assertNull(tree.findById("missing")) + assertTrue(tree.allNodes().isEmpty()) + } +} diff --git a/modules/orgflow-tree/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/AnchorTreeTest.kt b/modules/orgflow-tree/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/AnchorTreeTest.kt index 8121910..0b0372e 100644 --- a/modules/orgflow-tree/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/AnchorTreeTest.kt +++ b/modules/orgflow-tree/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/AnchorTreeTest.kt @@ -1,3 +1,59 @@ package jp.orgflow.tree -// TODO(spec ch.11): implement AnchorTreeTest per docs/spec.md +import jp.orgflow.tree.anchor.AnchorDependency +import jp.orgflow.tree.anchor.AnchorId +import jp.orgflow.tree.anchor.AnchorTree +import jp.orgflow.tree.anchor.AnchorTreeBuilder +import jp.orgflow.tree.anchor.AnchorType +import jp.orgflow.tree.anchor.SeparatorAnchor +import jp.orgflow.tree.common.Edge +import jp.orgflow.tree.common.GraphValidation +import jp.orgflow.tree.common.TreeAlgorithms +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AnchorTreeTest { + @Test + fun builderProducesNodesDependenciesAndSeparators() { + val a = AnchorId("a") + val b = AnchorId("b") + val tree = AnchorTreeBuilder() + .addNode(a, AnchorType.BASE) + .addNode(b, AnchorType.FULL) + .addDependency(a, b, "parent-child") + .addSeparator(a, "section") + .build() + + assertEquals(mapOf(a to AnchorType.BASE, b to AnchorType.FULL), tree.nodes) + assertEquals(listOf(AnchorDependency(a, b, "parent-child")), tree.dependenciesOf(a)) + assertEquals(listOf(AnchorDependency(a, b, "parent-child")), tree.dependentsOf(b)) + assertEquals(listOf(SeparatorAnchor(a, "section")), tree.separators) + } + + @Test + fun unknownAnchorHasNoDependencies() { + val tree = AnchorTree() + + assertTrue(tree.dependenciesOf(AnchorId("x")).isEmpty()) + assertTrue(tree.dependentsOf(AnchorId("x")).isEmpty()) + } + + @Test + fun duplicateAnchorIdsAreDetected() { + val result = GraphValidation.checkDuplicateIds(listOf("a", "b", "a")) + + assertFalse(result.valid) + assertEquals(listOf("Duplicate id: a"), result.errors) + } + + @Test + fun anchorDependenciesStayAcyclic() { + val edges = listOf(Edge("a", "b"), Edge("b", "c")) + + assertFalse(TreeAlgorithms.hasCycle(edges)) + assertEquals(listOf("a", "b", "c"), TreeAlgorithms.topologicalSort(edges)) + assertTrue(TreeAlgorithms.hasCycle(listOf(Edge("a", "b"), Edge("b", "a")))) + } +} diff --git a/modules/orgflow-tree/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/GitTreeSnapshotTest.kt b/modules/orgflow-tree/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/GitTreeSnapshotTest.kt index f4cf621..dce6f0d 100644 --- a/modules/orgflow-tree/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/GitTreeSnapshotTest.kt +++ b/modules/orgflow-tree/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/GitTreeSnapshotTest.kt @@ -1,3 +1,49 @@ package jp.orgflow.tree -// TODO(spec ch.11): implement GitTreeSnapshotTest per docs/spec.md +import jp.orgflow.tree.git.GitObjectType +import jp.orgflow.tree.git.GitTreeEntry +import jp.orgflow.tree.git.GitTreeSnapshot +import jp.orgflow.tree.git.SourceCommit +import jp.orgflow.tree.git.SourcePath +import jp.orgflow.tree.git.SourceTree +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class GitTreeSnapshotTest { + @Test + fun sourcePathParsesAndJoinsSegments() { + val path = SourcePath.parse("activities/a-1.org") + + assertEquals(listOf("activities", "a-1.org"), path.segments) + assertEquals("activities/a-1.org", path.path) + } + + @Test + fun sourceTreeFindsEntryByPath() { + val entry = GitTreeEntry("activities/a-1.org", "abc123", GitObjectType.BLOB) + val tree = SourceTree(SourcePath.parse("activities"), listOf(entry)) + + assertEquals(entry, tree.entryByPath("activities/a-1.org")) + assertNull(tree.entryByPath("missing.org")) + } + + @Test + fun snapshotHoldsCommitAndTree() { + val commit = SourceCommit( + hash = "c1", + parents = listOf("c0"), + message = "[PoA] a-1: update status", + rootTreeHash = "t1", + ) + val tree = SourceTree(SourcePath.parse("")) + val snapshot = GitTreeSnapshot(commit, tree) + + assertEquals("c1", snapshot.commit.hash) + assertEquals(listOf("c0"), snapshot.commit.parents) + assertEquals("t1", snapshot.commit.rootTreeHash) + assertTrue(snapshot.tree.entries.isEmpty()) + } +} diff --git a/modules/orgflow-tree/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/TreeMappingVerifierTest.kt b/modules/orgflow-tree/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/TreeMappingVerifierTest.kt index 9338006..57f3884 100644 --- a/modules/orgflow-tree/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/TreeMappingVerifierTest.kt +++ b/modules/orgflow-tree/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/TreeMappingVerifierTest.kt @@ -1,3 +1,70 @@ package jp.orgflow.tree -// TODO(spec ch.11): implement TreeMappingVerifierTest per docs/spec.md +import jp.orgflow.tree.activity.ProjectNode +import jp.orgflow.tree.activity.WorkspaceNode +import jp.orgflow.tree.anchor.AnchorId +import jp.orgflow.tree.anchor.AnchorTree +import jp.orgflow.tree.anchor.AnchorTreeBuilder +import jp.orgflow.tree.anchor.AnchorType +import jp.orgflow.tree.git.GitTreeSnapshot +import jp.orgflow.tree.git.SourceCommit +import jp.orgflow.tree.git.SourcePath +import jp.orgflow.tree.git.SourceTree +import kukuri.core.identity.ProjectId +import kukuri.core.identity.WorkspaceId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TreeMappingVerifierTest { + @Test + fun uniqueNodeIdsPassVerification() { + val nodes = listOf( + WorkspaceNode( + nodeId = "ws-1", + workspaceId = WorkspaceId("ws-1"), + title = "Main", + children = listOf(ProjectNode("proj-1", ProjectId("proj-1"), "Research")), + ), + ) + + val result = TreeMappingVerifier().verify(nodes, null, null) + + assertTrue(result.valid) + assertTrue(result.errors.isEmpty()) + } + + @Test + fun duplicateNodeIdsAreReported() { + val nodes = listOf( + WorkspaceNode( + nodeId = "dup", + workspaceId = WorkspaceId("ws-1"), + title = "Main", + children = listOf(ProjectNode("dup", ProjectId("proj-1"), "Research")), + ), + ) + + val result = TreeMappingVerifier().verify(nodes, null, null) + + assertFalse(result.valid) + assertEquals(listOf("Duplicate id: dup"), result.errors) + } + + @Test + fun verificationToleratesGitSnapshotAndAnchorTree() { + val nodes = listOf(WorkspaceNode("ws-1", WorkspaceId("ws-1"), "Main")) + val snapshot = GitTreeSnapshot( + commit = SourceCommit(hash = "c1", rootTreeHash = "t1"), + tree = SourceTree(SourcePath.parse("")), + ) + val anchorTree: AnchorTree = AnchorTreeBuilder() + .addNode(AnchorId("anchor-1"), AnchorType.BASE) + .build() + + val result = TreeMappingVerifier().verify(nodes, snapshot, anchorTree) + + assertTrue(result.valid) + } +} diff --git a/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/ActivityNode.kt b/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/ActivityNode.kt index a43b256..c3d2687 100644 --- a/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/ActivityNode.kt +++ b/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/ActivityNode.kt @@ -1,27 +1,9 @@ package jp.orgflow.tree.activity import kukuri.core.identity.ActivityId -import kukuri.core.identity.CardId -import kukuri.core.identity.ProjectId -import kukuri.core.identity.TaskId -import kukuri.core.identity.WorkspaceId sealed class ActivityNode(open val nodeId: String) -data class WorkspaceNode( - override val nodeId: String, - val workspaceId: WorkspaceId, - val title: String, - val children: List<ActivityNode> = emptyList(), -) : ActivityNode(nodeId) - -data class ProjectNode( - override val nodeId: String, - val projectId: ProjectId, - val title: String, - val children: List<ActivityNode> = emptyList(), -) : ActivityNode(nodeId) - data class ActivityGroupNode( override val nodeId: String, val activityId: ActivityId, @@ -30,22 +12,3 @@ data class ActivityGroupNode( val tasks: List<TaskNode> = emptyList(), val children: List<ActivityNode> = emptyList(), ) : ActivityNode(nodeId) - -data class CardNode( - override val nodeId: String, - val cardId: CardId, - val contentType: String, -) : ActivityNode(nodeId) - -data class TaskNode( - override val nodeId: String, - val taskId: TaskId, - val title: String, - val done: Boolean = false, -) : ActivityNode(nodeId) - -data class PresentationNode( - override val nodeId: String, - val presentationId: String, - val title: String, -) : ActivityNode(nodeId)
\ No newline at end of file diff --git a/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/CardNode.kt b/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/CardNode.kt index d9bf744..ccdb179 100644 --- a/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/CardNode.kt +++ b/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/CardNode.kt @@ -1,3 +1,9 @@ package jp.orgflow.tree.activity -// TODO(spec ch.11): implement CardNode per docs/spec.md +import kukuri.core.identity.CardId + +data class CardNode( + override val nodeId: String, + val cardId: CardId, + val contentType: String, +) : ActivityNode(nodeId) diff --git a/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/PresentationNode.kt b/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/PresentationNode.kt index 7392fc5..dffb300 100644 --- a/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/PresentationNode.kt +++ b/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/PresentationNode.kt @@ -1,3 +1,7 @@ package jp.orgflow.tree.activity -// TODO(spec ch.10): implement PresentationNode per docs/spec.md +data class PresentationNode( + override val nodeId: String, + val presentationId: String, + val title: String, +) : ActivityNode(nodeId) diff --git a/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/ProjectNode.kt b/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/ProjectNode.kt index 9c9ab89..839a942 100644 --- a/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/ProjectNode.kt +++ b/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/ProjectNode.kt @@ -1,3 +1,10 @@ package jp.orgflow.tree.activity -// TODO(spec ch.11): implement ProjectNode per docs/spec.md +import kukuri.core.identity.ProjectId + +data class ProjectNode( + override val nodeId: String, + val projectId: ProjectId, + val title: String, + val children: List<ActivityNode> = emptyList(), +) : ActivityNode(nodeId) diff --git a/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/TaskNode.kt b/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/TaskNode.kt index 26154e3..23fd922 100644 --- a/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/TaskNode.kt +++ b/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/TaskNode.kt @@ -1,3 +1,10 @@ package jp.orgflow.tree.activity -// TODO(spec ch.06): implement TaskNode per docs/spec.md +import kukuri.core.identity.TaskId + +data class TaskNode( + override val nodeId: String, + val taskId: TaskId, + val title: String, + val done: Boolean = false, +) : ActivityNode(nodeId) diff --git a/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/WorkspaceNode.kt b/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/WorkspaceNode.kt index d2af1ae..a485419 100644 --- a/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/WorkspaceNode.kt +++ b/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/WorkspaceNode.kt @@ -1,3 +1,10 @@ package jp.orgflow.tree.activity -// TODO(spec ch.11): implement WorkspaceNode per docs/spec.md +import kukuri.core.identity.WorkspaceId + +data class WorkspaceNode( + override val nodeId: String, + val workspaceId: WorkspaceId, + val title: String, + val children: List<ActivityNode> = emptyList(), +) : ActivityNode(nodeId) diff --git a/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/anchor/AnchorId.kt b/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/anchor/AnchorId.kt index 538c6da..35cea7d 100644 --- a/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/anchor/AnchorId.kt +++ b/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/anchor/AnchorId.kt @@ -1,4 +1,4 @@ package jp.orgflow.tree.anchor -@JvmInline -value class AnchorId(val value: String)
\ No newline at end of file +// value class unsupported on wasmJs (spec ch.02) -> data class +data class AnchorId(val value: String) diff --git a/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/common/TreeAlgorithms.kt b/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/common/TreeAlgorithms.kt index b182594..ffd236e 100644 --- a/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/common/TreeAlgorithms.kt +++ b/modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/common/TreeAlgorithms.kt @@ -35,7 +35,7 @@ object TreeAlgorithms { val adj = mutableMapOf<String, MutableList<String>>() for (e in edges) { adj.getOrPut(e.from) { mutableListOf() } += e.to - inDegree.putIfAbsent(e.from, 0) + if (e.from !in inDegree) inDegree[e.from] = 0 inDegree[e.to] = (inDegree[e.to] ?: 0) + 1 } val queue = ArrayDeque(inDegree.filter { it.value == 0 }.keys) diff --git a/modules/orgflow-ui/build.gradle.kts b/modules/orgflow-ui/build.gradle.kts index 4888ccd..4c66f03 100644 --- a/modules/orgflow-ui/build.gradle.kts +++ b/modules/orgflow-ui/build.gradle.kts @@ -1 +1,30 @@ -// TODO: configure per docs/spec.md (modules/orgflow-ui/build.gradle.kts) +import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.compose.multiplatform) +} + +group = "net.kukuri" +version = "0.1.0" + +kotlin { + jvm() + + sourceSets { + commonMain.dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material) + implementation(compose.ui) + implementation(project(":modules:orgflow-domain")) + implementation(project(":modules:orgflow-presentation")) + implementation(libs.coroutines.core) + implementation(libs.datetime) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + } + } +} diff --git a/modules/orgflow-ui/src/commonMain/composeResources/drawable/orgflow_logo.xml b/modules/orgflow-ui/src/commonMain/composeResources/drawable/orgflow_logo.xml index edd88f3..5257b44 100644 --- a/modules/orgflow-ui/src/commonMain/composeResources/drawable/orgflow_logo.xml +++ b/modules/orgflow-ui/src/commonMain/composeResources/drawable/orgflow_logo.xml @@ -1 +1,10 @@ -TODO: per docs/spec.md (modules/orgflow-ui/src/commonMain/composeResources/drawable/orgflow_logo.xml) +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#3949AB" + android:pathData="M4,4 L12,20 L20,4 L16.5,4 L12,12.5 L7.5,4 Z" /> +</vector> diff --git a/modules/orgflow-ui/src/commonMain/composeResources/values/strings.xml b/modules/orgflow-ui/src/commonMain/composeResources/values/strings.xml index 28c9724..57393bf 100644 --- a/modules/orgflow-ui/src/commonMain/composeResources/values/strings.xml +++ b/modules/orgflow-ui/src/commonMain/composeResources/values/strings.xml @@ -1 +1,13 @@ -TODO: per docs/spec.md (modules/orgflow-ui/src/commonMain/composeResources/values/strings.xml) +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="app_name">kukuri OrgFlow</string> + <string name="nav_home">Home</string> + <string name="nav_notes">Notes</string> + <string name="nav_capture">Capture</string> + <string name="nav_agenda">Agenda</string> + <string name="nav_experiment">Experiment</string> + <string name="nav_presentation">Presentation</string> + <string name="nav_distribution">Distribution</string> + <string name="nav_workspace">Workspace</string> + <string name="nav_onboarding">Onboarding</string> +</resources> diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowApp.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowApp.kt index cd72e4c..2fcf685 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowApp.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowApp.kt @@ -1,3 +1,54 @@ package jp.orgflow.ui -// TODO(spec ch.UI): implement OrgFlowApp per docs/spec.md +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun OrgFlowApp( + navigation: OrgFlowNavigation = remember { OrgFlowNavigation() }, +) { + OrgFlowTheme { + Scaffold { padding -> + Row(modifier = Modifier.fillMaxSize().padding(padding)) { + Column(modifier = Modifier.fillMaxHeight().width(160.dp)) { + Text("kukuri", fontSize = 18.sp, modifier = Modifier.padding(12.dp)) + OrgFlowRoute.all.forEach { route -> + Text( + text = route.id, + fontSize = 13.sp, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + .clickable { navigation.navigate(route) }, + color = if (route == navigation.current) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface, + ) + } + } + Column(modifier = Modifier.fillMaxSize()) { + when (navigation.current) { + OrgFlowRoute.Onboarding -> jp.orgflow.ui.onboarding.OnboardingScreen() + OrgFlowRoute.Home -> jp.orgflow.ui.home.HomeScreen() + OrgFlowRoute.Notes -> jp.orgflow.ui.notes.NotesScreen() + OrgFlowRoute.Capture -> jp.orgflow.ui.capture.CaptureScreen() + OrgFlowRoute.Agenda -> jp.orgflow.ui.agenda.AgendaScreen() + OrgFlowRoute.Experiment -> jp.orgflow.ui.experiment.ExperimentTableScreen() + OrgFlowRoute.Presentation -> jp.orgflow.ui.presentation.PresentationBuilderScreen() + OrgFlowRoute.Distribution -> jp.orgflow.ui.distribution.DistributionScreen() + OrgFlowRoute.Workspace -> jp.orgflow.ui.workspace.WorkspaceScreen() + } + } + } + } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowNavigation.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowNavigation.kt index f3de215..58986e7 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowNavigation.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowNavigation.kt @@ -1,3 +1,29 @@ package jp.orgflow.ui -// TODO(spec ch.?): implement OrgFlowNavigation per docs/spec.md +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +class OrgFlowNavigation(initial: OrgFlowRoute = OrgFlowRoute.Home) { + var current: OrgFlowRoute by mutableStateOf(initial) + private set + private val backStack = ArrayDeque<OrgFlowRoute>() + + fun navigate(route: OrgFlowRoute) { + if (route == current) return + backStack.addLast(current) + current = route + } + + fun back(): Boolean { + val previous = backStack.removeLastOrNull() ?: return false + current = previous + return true + } + + fun canGoBack(): Boolean = backStack.isNotEmpty() + + fun replace(route: OrgFlowRoute) { + current = route + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowRoute.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowRoute.kt index 53e6d4c..2b909b2 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowRoute.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowRoute.kt @@ -1,3 +1,17 @@ package jp.orgflow.ui -// TODO(spec ch.?): implement OrgFlowRoute per docs/spec.md +sealed class OrgFlowRoute(val id: String) { + data object Onboarding : OrgFlowRoute("onboarding") + data object Home : OrgFlowRoute("home") + data object Notes : OrgFlowRoute("notes") + data object Capture : OrgFlowRoute("capture") + data object Agenda : OrgFlowRoute("agenda") + data object Experiment : OrgFlowRoute("experiment") + data object Presentation : OrgFlowRoute("presentation") + data object Distribution : OrgFlowRoute("distribution") + data object Workspace : OrgFlowRoute("workspace") + + companion object { + val all: List<OrgFlowRoute> = listOf(Home, Notes, Capture, Agenda, Experiment, Presentation, Distribution, Workspace) + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowTheme.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowTheme.kt index f7bc6e1..78a56c7 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowTheme.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowTheme.kt @@ -1,3 +1,29 @@ package jp.orgflow.ui -// TODO(spec ch.?): implement OrgFlowTheme per docs/spec.md +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material.MaterialTheme +import androidx.compose.material.darkColors +import androidx.compose.material.lightColors +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val LightPalette = lightColors( + primary = Color(0xFF3F51B5), + primaryVariant = Color(0xFF303F9F), + secondary = Color(0xFF00897B), + surface = Color(0xFFFAFAFA), +) + +private val DarkPalette = darkColors( + primary = Color(0xFF8C9EFF), + secondary = Color(0xFF4DB6AC), +) + +@Composable +fun OrgFlowTheme(content: @Composable () -> Unit) { + val dark = isSystemInDarkTheme() + MaterialTheme( + colors = if (dark) DarkPalette else LightPalette, + content = content, + ) +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/agenda/AgendaScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/agenda/AgendaScreen.kt index 8e18fe4..98964e3 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/agenda/AgendaScreen.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/agenda/AgendaScreen.kt @@ -1,3 +1,42 @@ package jp.orgflow.ui.agenda -// TODO(spec ch.06): implement AgendaScreen per docs/spec.md +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Checkbox +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import jp.orgflow.ui.component.EmptyContent + +@Composable +fun AgendaScreen(viewModel: AgendaViewModel = remember { AgendaViewModel() }) { + Column(modifier = Modifier.fillMaxSize().padding(12.dp)) { + Text("Agenda", fontSize = 17.sp) + if (viewModel.items.isEmpty()) { + EmptyContent("Nothing scheduled") + } else { + LazyColumn { + items(viewModel.items, key = { it.id }) { item -> + Row( + modifier = Modifier.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = item.done, onCheckedChange = { viewModel.toggleDone(item.id) }) + Column { + Text(item.title, fontSize = 14.sp) + Text(item.scheduledAt.toString(), fontSize = 11.sp) + } + } + } + } + } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/agenda/AgendaViewModel.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/agenda/AgendaViewModel.kt index 34c6742..f83df47 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/agenda/AgendaViewModel.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/agenda/AgendaViewModel.kt @@ -1,3 +1,28 @@ package jp.orgflow.ui.agenda -// TODO(spec ch.06): implement AgendaViewModel per docs/spec.md +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import jp.orgflow.domain.calendar.AgendaItem +import kotlinx.datetime.LocalDateTime + +class AgendaViewModel { + var items: List<AgendaItem> by mutableStateOf(sampleItems()) + private set + + fun toggleDone(id: String) { + items = items.map { if (it.id == id) it.copy(done = !it.done) else it } + } + + fun forDate(date: LocalDateTime): List<AgendaItem> = + items.filter { it.scheduledAt.date == date.date }.sortedBy { it.scheduledAt.toString() } + + fun overdue(now: LocalDateTime): List<AgendaItem> = + items.filter { !it.done && it.scheduledAt < now } + + private fun sampleItems(): List<AgendaItem> = listOf( + AgendaItem("a1", "Team sync", LocalDateTime(2026, 8, 27, 10, 0)), + AgendaItem("a2", "Write experiment report", LocalDateTime(2026, 8, 27, 15, 0)), + AgendaItem("a3", "Pack rehearsal", LocalDateTime(2026, 8, 28, 9, 30)), + ) +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureScreen.kt index 5291abc..ffd427d 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureScreen.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureScreen.kt @@ -1,3 +1,48 @@ package jp.orgflow.ui.capture -// TODO(spec ch.05): implement CaptureScreen per docs/spec.md +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Button +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.FilterChip +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun CaptureScreen(viewModel: CaptureViewModel = remember { CaptureViewModel() }) { + Column(modifier = Modifier.fillMaxSize().padding(12.dp)) { + Text("Capture", fontSize = 17.sp) + Row(modifier = Modifier.padding(vertical = 6.dp)) { + QuickCaptureShortcuts.options.forEach { option -> + FilterChip( + selected = viewModel.form.templateType == option.type, + onClick = { viewModel.selectType(option.type) }, + modifier = Modifier.padding(end = 6.dp), + ) { + Text(option.label, fontSize = 12.sp) + } + } + } + FiveW1HFormSection( + state = viewModel.form, + onFieldChange = { field, value -> viewModel.updateField(field, value) }, + ) + Row(modifier = Modifier.padding(top = 8.dp)) { + Button( + onClick = { viewModel.submit() }, + enabled = viewModel.form.isSubmittable(), + ) { Text("Capture!") } + } + viewModel.lastResult?.let { result -> + Text("org preview:", fontSize = 12.sp, modifier = Modifier.padding(top = 12.dp)) + Text(result.orgSnippet, fontSize = 12.sp) + } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureViewModel.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureViewModel.kt index 5e0362b..b5a065c 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureViewModel.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureViewModel.kt @@ -1,3 +1,48 @@ package jp.orgflow.ui.capture -// TODO(spec ch.05): implement CaptureViewModel per docs/spec.md +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +data class CaptureUiResult( + val orgSnippet: String, + val templateType: String, +) + +class CaptureViewModel { + var form: CaptureFormState by mutableStateOf(CaptureFormState(templateType = QuickCaptureShortcuts.defaultType())) + private set + + var lastResult: CaptureUiResult? by mutableStateOf(null) + private set + + fun selectType(type: String) { + form = form.copy(templateType = type) + } + + fun updateField(field: FiveW1HField, value: String) { + form = form.copy(fields = form.fields + (field to value)) + } + + fun attach(cardId: jp.orgflow.domain.identity.CardId) { + form = form.copy(attachments = form.attachments + cardId) + } + + fun submit(): Boolean { + if (!form.isSubmittable()) return false + val whenText = form.fields[FiveW1HField.WHAT] ?: "" + val snippet = buildString { + append("* ").append(form.templateType).append(": ").append(whenText.take(40)).appendLine() + form.fields[FiveW1HField.WHEN]?.takeIf { it.isNotBlank() }?.let { append(" SCHEDULED: <").append(it).appendLine(">") } + form.fields[FiveW1HField.WHY]?.takeIf { it.isNotBlank() }?.let { append(" :WHY: ").append(it).appendLine(" :END:") } + if (form.attachments.isNotEmpty()) append(" attachments: ").append(form.attachments.joinToString { it.value }).appendLine() + } + lastResult = CaptureUiResult(snippet, form.templateType) + return true + } + + fun reset() { + form = CaptureFormState(templateType = form.templateType) + lastResult = null + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/FiveW1HFormSection.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/FiveW1HFormSection.kt index d91b8ec..709228b 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/FiveW1HFormSection.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/FiveW1HFormSection.kt @@ -1,3 +1,61 @@ package jp.orgflow.ui.capture -// TODO(spec ch.05): implement FiveW1HFormSection per docs/spec.md +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import jp.orgflow.domain.identity.CardId + +enum class FiveW1HField(val required: Boolean, val label: String) { + WHEN(true, "When"), + WHERE(false, "Where"), + WHO(false, "Who"), + WHAT(true, "What"), + WHY(false, "Why"), + HOW(false, "How"), +} + +data class CaptureFormState( + val templateType: String = "Note", + val fields: Map<FiveW1HField, String> = emptyMap(), + val attachments: List<CardId> = emptyList(), +) { + fun missingRequired(): List<FiveW1HField> = + FiveW1HField.entries.filter { it.required && fields[it].isNullOrBlank() } + + fun isSubmittable(): Boolean = missingRequired().isEmpty() +} + +@Composable +fun FiveW1HFormSection( + state: CaptureFormState, + onFieldChange: (FiveW1HField, String) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth().padding(vertical = 4.dp)) { + FiveW1HField.entries.forEach { field -> + val value = state.fields[field] ?: "" + OutlinedTextField( + value = value, + onValueChange = { onFieldChange(field, it) }, + label = { Text(field.label + if (field.required) " *" else "") }, + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + singleLine = field != FiveW1HField.WHAT, + ) + } + val missing = state.missingRequired() + if (missing.isNotEmpty()) { + Text( + "missing required: " + missing.joinToString { it.label }, + fontSize = 11.sp, + color = MaterialTheme.colors.error, + ) + } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/QuickCaptureShortcuts.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/QuickCaptureShortcuts.kt index 4f8d482..ca3c929 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/QuickCaptureShortcuts.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/QuickCaptureShortcuts.kt @@ -1,3 +1,21 @@ package jp.orgflow.ui.capture -// TODO(spec ch.05): implement QuickCaptureShortcuts per docs/spec.md +data class QuickCaptureOption( + val type: String, + val label: String, + val emojiHint: String, +) + +object QuickCaptureShortcuts { + val options: List<QuickCaptureOption> = listOf( + QuickCaptureOption("Note", "Quick note", "memo"), + QuickCaptureOption("Schedule", "Schedule", "calendar"), + QuickCaptureOption("Todo", "Todo", "check"), + QuickCaptureOption("Experiment", "Experiment value", "flask"), + QuickCaptureOption("Media", "Media attachment", "image"), + ) + + fun defaultType(): String = options.first().type + + fun templateFor(type: String): QuickCaptureOption? = options.firstOrNull { it.type == type } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/chart/UiChartSpec.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/chart/UiChartSpec.kt new file mode 100644 index 0000000..74fd2a3 --- /dev/null +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/chart/UiChartSpec.kt @@ -0,0 +1,30 @@ +package jp.orgflow.ui.chart + +data class UiChartSpec( + val title: String, + val kind: UiChartKind, + val xLabel: String, + val yLabel: String, + val series: List<UiChartSeries>, + val sourceTable: String? = null, + val sourceCommit: String? = null, +) + +enum class UiChartKind { + BAR, + LINE, + SCATTER, + PIE, +} + +data class UiChartSeries( + val name: String, + val points: List<UiChartPoint>, + val unit: String = "", +) + +data class UiChartPoint( + val label: String, + val x: Double, + val y: Double, +) diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/ActivityCardView.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/ActivityCardView.kt index b704f59..4c7df42 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/ActivityCardView.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/ActivityCardView.kt @@ -1,3 +1,25 @@ package jp.orgflow.ui.component -// TODO(spec ch.?): implement ActivityCardView per docs/spec.md +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Card +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import jp.orgflow.domain.activity.OrgFlowActivity + +@Composable +fun ActivityCardView(activity: OrgFlowActivity, modifier: Modifier = Modifier, onClick: () -> Unit = {}) { + Card(modifier = modifier.padding(8.dp), elevation = 2.dp) { + Column(modifier = Modifier.padding(12.dp)) { + Row { + Text(activity.title, fontSize = 15.sp, modifier = Modifier.padding(end = 8.dp)) + Text(activity.metadata.status.name, fontSize = 11.sp, modifier = Modifier.padding(top = 3.dp)) + } + Text("cards=${activity.cards.size} tasks=${activity.tasks.size}", fontSize = 12.sp) + } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/ConfirmationDialog.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/ConfirmationDialog.kt index 02a8ca0..7c0422a 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/ConfirmationDialog.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/ConfirmationDialog.kt @@ -1,3 +1,24 @@ package jp.orgflow.ui.component -// TODO(spec ch.?): implement ConfirmationDialog per docs/spec.md +import androidx.compose.material.AlertDialog +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable + +@Composable +fun ConfirmationDialog( + title: String, + message: String, + confirmLabel: String = "Confirm", + dismissLabel: String = "Cancel", + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + title = { Text(title) }, + text = { Text(message) }, + confirmButton = { TextButton(onClick = onConfirm) { Text(confirmLabel) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(dismissLabel) } }, + onDismissRequest = onDismiss, + ) +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/EmptyContent.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/EmptyContent.kt index 44b96ac..d6e4de6 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/EmptyContent.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/EmptyContent.kt @@ -1,3 +1,17 @@ package jp.orgflow.ui.component -// TODO(spec ch.?): implement EmptyContent per docs/spec.md +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.sp + +@Composable +fun EmptyContent(message: String, modifier: Modifier = Modifier) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(message, fontSize = 14.sp, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)) + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/EntityDetailScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/EntityDetailScreen.kt index 038377d..04dd1c7 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/EntityDetailScreen.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/EntityDetailScreen.kt @@ -1,3 +1,27 @@ package jp.orgflow.ui.component -// TODO(spec ch.?): implement EntityDetailScreen per docs/spec.md +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun EntityDetailScreen( + title: String, + fields: List<Pair<String, String>>, +) { + Column( + modifier = Modifier.fillMaxSize().padding(12.dp).verticalScroll(rememberScrollState()), + ) { + Text(title, fontSize = 17.sp) + fields.forEach { (label, value) -> + Text("$label: $value", fontSize = 13.sp, modifier = Modifier.padding(vertical = 2.dp)) + } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/EntityListScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/EntityListScreen.kt index 630c743..1e1078d 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/EntityListScreen.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/EntityListScreen.kt @@ -1,3 +1,42 @@ package jp.orgflow.ui.component -// TODO(spec ch.?): implement EntityListScreen per docs/spec.md +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Card +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun <T> EntityListScreen( + title: String, + entities: List<T>, + keyOf: (T) -> String, + summaryOf: (T) -> String, + emptyMessage: String = "Nothing here yet", + onSelect: (T) -> Unit, +) { + Column(modifier = Modifier.fillMaxSize().padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(title, fontSize = 17.sp) + if (entities.isEmpty()) { + EmptyContent(emptyMessage) + } else { + LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) { + items(entities, key = keyOf) { entity -> + Card(elevation = 1.dp) { + Column(modifier = Modifier.padding(10.dp)) { + Text(keyOf(entity), fontSize = 13.sp) + Text(summaryOf(entity), fontSize = 11.sp) + } + } + } + } + } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/ErrorBanner.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/ErrorBanner.kt index e44eaf6..16ee4f3 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/ErrorBanner.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/ErrorBanner.kt @@ -1,3 +1,23 @@ package jp.orgflow.ui.component -// TODO(spec ch.?): implement ErrorBanner per docs/spec.md +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun ErrorBanner(message: String, modifier: Modifier = Modifier) { + Text( + text = message, + color = Color.White, + fontSize = 13.sp, + modifier = modifier + .background(MaterialTheme.colors.error) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/ErrorContent.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/ErrorContent.kt index 091d56e..1a6de95 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/ErrorContent.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/ErrorContent.kt @@ -1,3 +1,23 @@ package jp.orgflow.ui.component -// TODO(spec ch.?): implement ErrorContent per docs/spec.md +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun ErrorContent(message: String, modifier: Modifier = Modifier) { + Box(modifier = modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Error", fontSize = 16.sp, color = MaterialTheme.colors.error) + Text(message, fontSize = 13.sp) + } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/LoadableState.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/LoadableState.kt index eb6b1ba..fe67c79 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/LoadableState.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/LoadableState.kt @@ -1,3 +1,28 @@ package jp.orgflow.ui.component -// TODO(spec ch.?): implement LoadableState per docs/spec.md +import androidx.compose.runtime.Composable + +sealed interface LoadableState<out T> { + data object Loading : LoadableState<Nothing> + data class Content<T>(val value: T) : LoadableState<T> + data class Error(val message: String) : LoadableState<Nothing> + + companion object { + fun <T> loading(): LoadableState<T> = Loading + fun <T> content(value: T): LoadableState<T> = Content(value) + fun <T> error(message: String): LoadableState<T> = Error(message) + } +} + +@Composable +fun <T> LoadableState<T>.render( + onLoading: @Composable () -> Unit, + onError: @Composable (String) -> Unit, + onContent: @Composable (T) -> Unit, +) { + when (this) { + is LoadableState.Loading -> onLoading() + is LoadableState.Error -> onError(message) + is LoadableState.Content -> onContent(value) + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/LoadingContent.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/LoadingContent.kt index a368db5..dec20ae 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/LoadingContent.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/LoadingContent.kt @@ -1,3 +1,16 @@ package jp.orgflow.ui.component -// TODO(spec ch.?): implement LoadingContent per docs/spec.md +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun LoadingContent(modifier: Modifier = Modifier) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/OfflineStatusBanner.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/OfflineStatusBanner.kt index cc35fea..288fe8a 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/OfflineStatusBanner.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/OfflineStatusBanner.kt @@ -1,3 +1,23 @@ package jp.orgflow.ui.component -// TODO(spec ch.?): implement OfflineStatusBanner per docs/spec.md +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun OfflineStatusBanner(offline: Boolean, modifier: Modifier = Modifier) { + if (!offline) return + Text( + text = "Offline — queued changes will sync via FSMP when a peer reconnects", + fontSize = 12.sp, + color = MaterialTheme.colors.onPrimary, + modifier = modifier + .background(MaterialTheme.colors.primary) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/PeerBadge.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/PeerBadge.kt index 19d58e9..bde274a 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/PeerBadge.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/PeerBadge.kt @@ -1,3 +1,36 @@ package jp.orgflow.ui.component -// TODO(spec ch.?): implement PeerBadge per docs/spec.md +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +enum class UiPeerState { SYNCED, SYNCING, STALE } + +object PeerBadge { + fun label(state: UiPeerState): String = when (state) { + UiPeerState.SYNCED -> "synced" + UiPeerState.SYNCING -> "syncing" + UiPeerState.STALE -> "stale" + } +} + +@Composable +fun PeerBadge(name: String, state: UiPeerState, modifier: Modifier = Modifier) { + val color = when (state) { + UiPeerState.SYNCED -> MaterialTheme.colors.secondary + UiPeerState.SYNCING -> MaterialTheme.colors.primary + UiPeerState.STALE -> MaterialTheme.colors.error + } + Text( + text = "$name ${PeerBadge.label(state)}", + fontSize = 11.sp, + color = MaterialTheme.colors.onPrimary, + modifier = modifier.background(color, CircleShape).padding(horizontal = 10.dp, vertical = 3.dp), + ) +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/SaveableEditorState.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/SaveableEditorState.kt index 533e116..de65765 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/SaveableEditorState.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/SaveableEditorState.kt @@ -1,3 +1,42 @@ package jp.orgflow.ui.component -// TODO(spec ch.?): implement SaveableEditorState per docs/spec.md +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +class SaveableEditorState(initialText: String = "") { + private val undoStack = ArrayDeque<String>() + private val redoStack = ArrayDeque<String>() + + var text: String by mutableStateOf(initialText) + private set + + fun edit(newText: String) { + if (newText == text) return + undoStack.addLast(text) + if (undoStack.size > capacity) undoStack.removeFirst() + redoStack.clear() + text = newText + } + + fun undo(): Boolean { + val previous = undoStack.removeLastOrNull() ?: return false + redoStack.addLast(text) + text = previous + return true + } + + fun redo(): Boolean { + val next = redoStack.removeLastOrNull() ?: return false + undoStack.addLast(text) + text = next + return true + } + + fun canUndo(): Boolean = undoStack.isNotEmpty() + fun canRedo(): Boolean = redoStack.isNotEmpty() + + companion object { + private const val capacity = 100 + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/SourceCommitBadge.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/SourceCommitBadge.kt index 667565d..49c215f 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/SourceCommitBadge.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/SourceCommitBadge.kt @@ -1,3 +1,24 @@ package jp.orgflow.ui.component -// TODO(spec ch.?): implement SourceCommitBadge per docs/spec.md +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun SourceCommitBadge(commitHash: String?, modifier: Modifier = Modifier) { + val short = commitHash?.take(8) ?: "no-commit" + Text( + text = "@$short", + fontSize = 11.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f), + modifier = modifier + .background(MaterialTheme.colors.onSurface.copy(alpha = 0.08f), RoundedCornerShape(4.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/WaterlineBadge.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/WaterlineBadge.kt index 0319076..c396d39 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/WaterlineBadge.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/WaterlineBadge.kt @@ -1,3 +1,33 @@ package jp.orgflow.ui.component -// TODO(spec ch.?): implement WaterlineBadge per docs/spec.md +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +enum class UiWaterline { PREVIEW, BASE, USABLE, FULL } + +object WaterlineBadge { + fun label(level: UiWaterline): String = when (level) { + UiWaterline.PREVIEW -> "Preview" + UiWaterline.BASE -> "Base" + UiWaterline.USABLE -> "Usable" + UiWaterline.FULL -> "Full" + } +} + +@Composable +fun WaterlineBadge(level: UiWaterline, modifier: Modifier = Modifier) { + Text( + text = WaterlineBadge.label(level), + fontSize = 11.sp, + color = MaterialTheme.colors.onPrimary, + modifier = modifier + .background(MaterialTheme.colors.primary) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/distribution/DistributionScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/distribution/DistributionScreen.kt index 4d6da0f..1fce62b 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/distribution/DistributionScreen.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/distribution/DistributionScreen.kt @@ -1,3 +1,28 @@ package jp.orgflow.ui.distribution -// TODO(spec ch.?): implement DistributionScreen per docs/spec.md +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun DistributionScreen(viewModel: DistributionViewModel = remember { DistributionViewModel() }) { + Column(modifier = Modifier.fillMaxSize().padding(12.dp)) { + Text("Distribution", fontSize = 17.sp) + Text("pack: ${viewModel.overview.packName}", fontSize = 12.sp) + Text("avg waterline ratio: ${viewModel.overview.averageRatio()}", fontSize = 12.sp) + TextButton(onClick = { viewModel.tick() }) { Text("advance sync") } + WaterlineProgressBoard(viewModel.overview.peers) + PeerSyncMap(viewModel.overview.peers) + val below = viewModel.overview.belowUsable() + if (below.isNotEmpty()) { + Text("awaiting Usable: " + below.joinToString { it.peerName }, fontSize = 12.sp) + } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/distribution/DistributionViewModel.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/distribution/DistributionViewModel.kt index f1856d5..98eaf85 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/distribution/DistributionViewModel.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/distribution/DistributionViewModel.kt @@ -1,3 +1,51 @@ package jp.orgflow.ui.distribution -// TODO(spec ch.?): implement DistributionViewModel per docs/spec.md +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import jp.orgflow.ui.component.UiWaterline + +data class PeerWaterline( + val peerName: String, + val level: UiWaterline, + val ratio: Double, +) + +data class DistributionOverview( + val packName: String, + val peers: List<PeerWaterline>, +) { + fun belowUsable(): List<PeerWaterline> = peers.filter { it.level.ordinal < UiWaterline.USABLE.ordinal } + + fun averageRatio(): Double = if (peers.isEmpty()) 0.0 else peers.sumOf { it.ratio } / peers.size +} + +class DistributionViewModel { + var overview: DistributionOverview by mutableStateOf( + DistributionOverview( + "activity-pack-2026-08-27", + listOf( + PeerWaterline("alpha", UiWaterline.FULL, 1.0), + PeerWaterline("beta", UiWaterline.USABLE, 0.92), + PeerWaterline("gamma", UiWaterline.BASE, 0.4), + ), + ), + ) + private set + + fun tick(): Unit { + overview = overview.copy( + peers = overview.peers.map { peer -> + val next = (peer.ratio + 0.05).coerceAtMost(1.0) + peer.copy(ratio = next, level = levelFor(next)) + }, + ) + } + + fun levelFor(ratio: Double): UiWaterline = when { + ratio >= 1.0 -> UiWaterline.FULL + ratio >= 0.9 -> UiWaterline.USABLE + ratio >= 0.25 -> UiWaterline.BASE + else -> UiWaterline.PREVIEW + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/distribution/PeerSyncMap.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/distribution/PeerSyncMap.kt index 7774978..6f6c091 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/distribution/PeerSyncMap.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/distribution/PeerSyncMap.kt @@ -1,3 +1,30 @@ package jp.orgflow.ui.distribution -// TODO(spec ch.?): implement PeerSyncMap per docs/spec.md +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Card +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import jp.orgflow.ui.component.PeerBadge +import jp.orgflow.ui.component.UiPeerState + +@Composable +fun PeerSyncMap(peers: List<PeerWaterline>, modifier: Modifier = Modifier) { + Column(modifier = modifier.padding(vertical = 6.dp)) { + peers.forEach { peer -> + val state = when { + peer.level.name == "FULL" -> UiPeerState.SYNCED + peer.ratio > 0.0 -> UiPeerState.SYNCING + else -> UiPeerState.STALE + } + PeerBadge(name = peer.peerName, state = state, modifier = Modifier.padding(vertical = 2.dp)) + } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/distribution/WaterlineProgressBoard.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/distribution/WaterlineProgressBoard.kt index cfa87e9..4bfaa55 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/distribution/WaterlineProgressBoard.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/distribution/WaterlineProgressBoard.kt @@ -1,3 +1,36 @@ package jp.orgflow.ui.distribution -// TODO(spec ch.?): implement WaterlineProgressBoard per docs/spec.md +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import jp.orgflow.ui.component.PeerBadge +import jp.orgflow.ui.component.UiPeerState +import jp.orgflow.ui.component.WaterlineBadge + +@Composable +fun WaterlineProgressBoard(peers: List<PeerWaterline>, modifier: Modifier = Modifier) { + Column(modifier = modifier.padding(vertical = 6.dp)) { + peers.forEach { peer -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(peer.peerName, fontSize = 12.sp, modifier = Modifier.padding(end = 8.dp)) + LinearProgressIndicator( + progress = peer.ratio.toFloat(), + modifier = Modifier.weight(1f).height(6.dp).padding(end = 8.dp), + ) + WaterlineBadge(peer.level) + } + } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/ExperimentTableScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/ExperimentTableScreen.kt index 3f8d5f5..c38c835 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/ExperimentTableScreen.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/ExperimentTableScreen.kt @@ -1,3 +1,64 @@ package jp.orgflow.ui.experiment -// TODO(spec ch.07): implement ExperimentTableScreen per docs/spec.md +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Tab +import androidx.compose.material.TabRow +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun ExperimentTableScreen(viewModel: ExperimentTableViewModel = remember { ExperimentTableViewModel() }) { + var tab by remember { mutableIntStateOf(0) } + Column(modifier = Modifier.padding(12.dp)) { + Text("Experiment", fontSize = 17.sp) + TabRow(selectedTabIndex = tab) { + Tab(selected = tab == 0, onClick = { tab = 0 }) { Text("Table", modifier = Modifier.padding(8.dp)) } + Tab(selected = tab == 1, onClick = { tab = 1 }) { Text("Graph", modifier = Modifier.padding(8.dp)) } + Tab(selected = tab == 2, onClick = { tab = 2 }) { Text("GUI-Lisp", modifier = Modifier.padding(8.dp)) } + } + when (tab) { + 0 -> { + viewModel.rows.forEach { row -> + Row(modifier = Modifier.padding(vertical = 2.dp)) { + viewModel.columns.forEach { column -> + OutlinedTextField( + value = row.cells[column.name] ?: "", + onValueChange = { viewModel.updateCell(row.id, column.name, it) }, + label = { Text(column.name, fontSize = 10.sp) }, + modifier = Modifier.padding(end = 6.dp).padding(vertical = 1.dp), + textStyle = androidx.compose.ui.text.TextStyle(fontSize = 12.sp), + ) + } + } + } + TextButton(onClick = { viewModel.addRow() }) { Text("+ row") } + viewModel.columns.firstOrNull { it.numeric }?.let { col -> + Text("mean(${col.name}) = ${viewModel.mean(col.name)}", fontSize = 12.sp) + } + } + 1 -> GraphTab(viewModel) + 2 -> Column(modifier = Modifier.padding(top = 8.dp)) { + val editorState = remember { androidx.compose.ui.text.input.TextFieldValue("") } + var text by remember { mutableStateOf("(avg (column \"yield\"))") } + GuiLispEditor( + code = text, + onCodeChange = { text = it }, + ) + GuiLispErrorHint(text) + GuiLispLivePreview(text) + } + } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/ExperimentTableViewModel.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/ExperimentTableViewModel.kt index b55e9f6..61f1ff9 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/ExperimentTableViewModel.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/ExperimentTableViewModel.kt @@ -1,3 +1,48 @@ package jp.orgflow.ui.experiment -// TODO(spec ch.07): implement ExperimentTableViewModel per docs/spec.md +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +data class ExperimentColumnUi( + val name: String, + val numeric: Boolean, +) + +data class ExperimentRowUi( + val id: String, + val cells: Map<String, String>, +) + +class ExperimentTableViewModel { + var columns: List<ExperimentColumnUi> by mutableStateOf( + listOf(ExperimentColumnUi("trial", false), ExperimentColumnUi("temp", true), ExperimentColumnUi("yield", true)), + ) + private set + + var rows: List<ExperimentRowUi> by mutableStateOf( + listOf( + ExperimentRowUi("r1", mapOf("trial" to "A", "temp" to "21.0", "yield" to "0.62")), + ExperimentRowUi("r2", mapOf("trial" to "B", "temp" to "22.5", "yield" to "0.71")), + ), + ) + private set + + fun updateCell(rowId: String, column: String, value: String) { + rows = rows.map { if (it.id == rowId) it.copy(cells = it.cells + (column to value)) else it } + } + + fun addRow(): String { + val id = "r${rows.size + 1}" + rows = rows + ExperimentRowUi(id, columns.associate { it.name to "" }) + return id + } + + fun numericColumn(name: String): List<Double> = + rows.mapNotNull { it.cells[name]?.toDoubleOrNull() } + + fun mean(name: String): Double? { + val values = numericColumn(name) + return if (values.isEmpty()) null else values.sum() / values.size + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GraphTab.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GraphTab.kt index e822f5d..e06ff2a 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GraphTab.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GraphTab.kt @@ -1,3 +1,70 @@ package jp.orgflow.ui.experiment -// TODO(spec ch.07): implement GraphTab per docs/spec.md +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +data class UiChartSeries( + val name: String, + val values: List<Double>, +) + +data class UiChartSpec( + val title: String, + val series: List<UiChartSeries>, +) + +@Composable +fun GraphTab(viewModel: ExperimentTableViewModel) { + val numericColumns = viewModel.columns.filter { it.numeric } + val spec = UiChartSpec( + title = "Experiment overview", + series = numericColumns.map { col -> UiChartSeries(col.name, viewModel.numericColumn(col.name)) }, + ) + Column(modifier = Modifier.padding(top = 8.dp)) { + Text(spec.title, fontSize = 13.sp) + KoalaChartRenderer(spec) + numericColumns.forEach { col -> + Text("${col.name}: n=${viewModel.numericColumn(col.name).size} mean=${viewModel.mean(col.name)}", fontSize = 11.sp) + } + } +} + +@Composable +fun KoalaChartRenderer(spec: UiChartSpec, modifier: Modifier = Modifier) { + Canvas(modifier = modifier.fillMaxWidth().height(180.dp).padding(vertical = 8.dp)) { + val all = spec.series.flatMap { it.values } + if (all.isEmpty()) return@Canvas + val minV = all.min() + val maxV = all.max() + val span = (maxV - minV).takeIf { it > 1e-9 } ?: 1.0 + val colors = listOf(Color(0xFF3F51B5), Color(0xFF00897B), Color(0xFFE65100)) + spec.series.forEachIndexed { s, series -> + if (series.values.isEmpty()) return@forEachIndexed + val stepX = size.width / (series.values.size.coerceAtLeast(2) - 1).coerceAtLeast(1) + val path = Path() + series.values.forEachIndexed { i, v -> + val x = i * stepX + val y = size.height * (1f - ((v - minV) / span).toFloat()) + if (i == 0) path.moveTo(x, y.toFloat()) else path.lineTo(x, y.toFloat()) + } + drawPath(path, colors[s % colors.size], style = Stroke(width = 3f)) + series.values.forEachIndexed { i, v -> + val x = i * stepX + val y = size.height * (1f - ((v - minV) / span).toFloat()) + drawCircle(colors[s % colors.size], radius = 5f, center = Offset(x, y.toFloat())) + } + } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispAutocomplete.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispAutocomplete.kt index 3237320..8da197f 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispAutocomplete.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispAutocomplete.kt @@ -1,3 +1,19 @@ package jp.orgflow.ui.experiment -// TODO(spec ch.07): implement GuiLispAutocomplete per docs/spec.md +object GuiLispAutocomplete { + + val builtinSymbols: List<String> = listOf( + "+", "-", "*", "/", "=", "<", ">", "<=", ">=", + "car", "cdr", "cons", "list", "length", "map", "filter", "reduce", + "sum", "avg", "min", "max", "count", "median", "stdev", + "column", "get-cell", "bar-chart", "line-chart", "scatter-chart", + "if", "cond", "let", "defun", "and", "or", + ) + + fun suggestions(prefix: String): List<String> { + if (prefix.isBlank()) return emptyList() + val token = prefix.takeLastWhile { it.isLetterOrDigit() || it == '-' } + if (token.length < 2) return emptyList() + return builtinSymbols.filter { it.startsWith(token) && it != token }.take(8) + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispEditor.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispEditor.kt index 2655265..29a0dff 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispEditor.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispEditor.kt @@ -1,3 +1,27 @@ package jp.orgflow.ui.experiment -// TODO(spec ch.07): implement GuiLispEditor per docs/spec.md +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun GuiLispEditor( + code: String, + onCodeChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedTextField( + value = code, + onValueChange = onCodeChange, + modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), + textStyle = TextStyle(fontSize = 13.sp, fontFamily = FontFamily.Monospace), + label = { Text("GUI-Lisp expression") }, + ) +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispErrorHint.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispErrorHint.kt index 08ce93d..6bacb21 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispErrorHint.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispErrorHint.kt @@ -1,3 +1,37 @@ package jp.orgflow.ui.experiment -// TODO(spec ch.07): implement GuiLispErrorHint per docs/spec.md +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +object GuiLispSyntaxChecker { + fun check(code: String): String? { + var depth = 0 + var inString = false + for (c in code) { + when { + c == '"' -> inString = !inString + !inString && c == '(' -> depth++ + !inString && c == ')' -> depth-- + } + if (depth < 0) return "unexpected )" + } + if (inString) return "unterminated string" + if (depth > 0) return "missing $depth closing paren(s)" + return null + } +} + +@Composable +fun GuiLispErrorHint(code: String, modifier: Modifier = Modifier) { + val error = GuiLispSyntaxChecker.check(code) ?: return + Text( + text = error, + color = androidx.compose.material.MaterialTheme.colors.error, + fontSize = 11.sp, + modifier = modifier.padding(2.dp), + ) +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispLivePreview.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispLivePreview.kt index d5cb449..8d0104a 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispLivePreview.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispLivePreview.kt @@ -1,3 +1,23 @@ package jp.orgflow.ui.experiment -// TODO(spec ch.07): implement GuiLispLivePreview per docs/spec.md +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun GuiLispLivePreview(code: String, modifier: Modifier = Modifier) { + val error = GuiLispSyntaxChecker.check(code) + val preview = when { + error != null -> "— fix syntax to see preview —" + code.isBlank() -> "—" + else -> "expr ok: $code" + } + Text( + text = preview, + fontSize = 12.sp, + modifier = modifier.padding(vertical = 2.dp), + ) +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/KoalaChartRenderer.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/KoalaChartRenderer.kt index 6b7f5fd..24c563a 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/KoalaChartRenderer.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/KoalaChartRenderer.kt @@ -1,3 +1,208 @@ package jp.orgflow.ui.experiment -// TODO(spec ch.07): implement KoalaChartRenderer per docs/spec.md +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp +import jp.orgflow.ui.chart.UiChartKind +import jp.orgflow.ui.chart.UiChartSpec +import jp.orgflow.ui.component.SourceCommitBadge +import kotlin.math.max +import kotlin.math.min + +val ChartPalette: List<Color> = listOf( + Color(0xFF1F77B4), + Color(0xFFFF7F0E), + Color(0xFF2CA02C), + Color(0xFFD62728), + Color(0xFF9467BD), + Color(0xFF8C564B), +) + +object ChartScale { + fun linear(value: Double, fromMin: Double, fromMax: Double, toMin: Float, toMax: Float): Float { + if (fromMax - fromMin == 0.0) return (toMin + toMax) / 2f + val ratio = ((value - fromMin) / (fromMax - fromMin)).toFloat() + return toMin + ratio * (toMax - toMin) + } +} + +@Composable +fun KoalaChartRenderer( + spec: UiChartSpec, + modifier: Modifier = Modifier, +) { + val axisColor = MaterialTheme.colors.onSurface.copy(alpha = 0.7f) + val gridColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f) + + Column(modifier.fillMaxWidth().padding(8.dp)) { + Text(spec.title, style = MaterialTheme.typography.subtitle1) + Spacer(Modifier.height(8.dp)) + Canvas(Modifier.fillMaxWidth().height(220.dp)) { + drawChart(spec, axisColor, gridColor) + } + Spacer(Modifier.height(4.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(spec.xLabel, style = MaterialTheme.typography.caption) + Text(spec.yLabel, style = MaterialTheme.typography.caption) + } + Spacer(Modifier.height(6.dp)) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + if (spec.kind == UiChartKind.PIE) { + spec.series.firstOrNull()?.points?.forEachIndexed { index, point -> + LegendEntry(ChartPalette[index % ChartPalette.size], point.label) + } + } else { + spec.series.forEachIndexed { index, series -> + LegendEntry(ChartPalette[index % ChartPalette.size], series.name) + } + } + } + spec.sourceCommit?.let { + Spacer(Modifier.height(4.dp)) + SourceCommitBadge(it) + } + } +} + +@Composable +private fun LegendEntry(color: Color, label: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + Surface(shape = CircleShape, color = color, modifier = Modifier.size(8.dp)) {} + Spacer(Modifier.padding(2.dp)) + Text(label, style = MaterialTheme.typography.caption) + } +} + +private fun DrawScope.drawChart(spec: UiChartSpec, axisColor: Color, gridColor: Color) { + val usable = spec.series.any { series -> series.points.isNotEmpty() } + if (!usable) return + + val left = 8f + val top = 8f + val right = size.width - 8f + val bottom = size.height - 8f + + val gridStep = (bottom - top) / 4f + repeat(3) { step -> + val y = top + gridStep * (step + 1) + drawLine(gridColor, Offset(left, y), Offset(right, y), strokeWidth = 1f) + } + drawLine(axisColor, Offset(left, top), Offset(left, bottom), strokeWidth = 2f) + drawLine(axisColor, Offset(left, bottom), Offset(right, bottom), strokeWidth = 2f) + + when (spec.kind) { + UiChartKind.PIE -> drawPie(spec, left, top, right, bottom) + UiChartKind.BAR -> drawBars(spec, left, top, right, bottom) + UiChartKind.LINE -> drawLines(spec, left, top, right, bottom, axisColor) + UiChartKind.SCATTER -> drawScatter(spec, left, top, right, bottom) + } +} + +private fun DrawScope.drawBars(spec: UiChartSpec, left: Float, top: Float, right: Float, bottom: Float) { + val values = spec.series.flatMap { series -> series.points.map { it.y } } + val yMin = min(0.0, values.min()) + val yMax = max(0.0, values.max()) + val slotCount = max(1, spec.series.maxOf { series -> series.points.size }) + val slotWidth = (right - left) / slotCount + val groupWidth = slotWidth * 0.7f + val barWidth = groupWidth / max(1, spec.series.size) + + spec.series.forEachIndexed { seriesIndex, series -> + val color = ChartPalette[seriesIndex % ChartPalette.size] + series.points.forEachIndexed { pointIndex, point -> + val slotStart = left + slotWidth * pointIndex + (slotWidth - groupWidth) / 2f + val barLeft = slotStart + barWidth * seriesIndex + val yTop = ChartScale.linear(point.y, yMin, yMax, bottom, top) + drawRect( + color = color, + topLeft = Offset(barLeft, yTop), + size = Size(barWidth, bottom - yTop), + ) + } + } +} + +private fun DrawScope.drawLines(spec: UiChartSpec, left: Float, top: Float, right: Float, bottom: Float, axisColor: Color) { + val points = spec.series.flatMap { series -> series.points } + val xMin = points.minOf { it.x } + val xMax = points.maxOf { it.x } + val yMin = min(0.0, points.minOf { it.y }) + val yMax = max(0.0, points.maxOf { it.y }) + + spec.series.forEachIndexed { seriesIndex, series -> + val color = ChartPalette[seriesIndex % ChartPalette.size] + val path = Path() + series.points.sortedBy { it.x }.forEachIndexed { index, point -> + val x = ChartScale.linear(point.x, xMin, xMax, left, right) + val y = ChartScale.linear(point.y, yMin, yMax, bottom, top) + if (index == 0) path.moveTo(x, y) else path.lineTo(x, y) + } + drawPath(path, color, style = Stroke(width = 4f)) + } + if (xMax - xMin == 0.0) { + drawLine(axisColor, Offset(left, top), Offset(right, bottom), strokeWidth = 1f) + } +} + +private fun DrawScope.drawScatter(spec: UiChartSpec, left: Float, top: Float, right: Float, bottom: Float) { + val points = spec.series.flatMap { series -> series.points } + val xMin = points.minOf { it.x } + val xMax = points.maxOf { it.x } + val yMin = min(0.0, points.minOf { it.y }) + val yMax = max(0.0, points.maxOf { it.y }) + + spec.series.forEachIndexed { seriesIndex, series -> + val color = ChartPalette[seriesIndex % ChartPalette.size] + series.points.forEach { point -> + val x = ChartScale.linear(point.x, xMin, xMax, left, right) + val y = ChartScale.linear(point.y, yMin, yMax, bottom, top) + drawCircle(color, radius = 7f, center = Offset(x, y)) + } + } +} + +private fun DrawScope.drawPie(spec: UiChartSpec, left: Float, top: Float, right: Float, bottom: Float) { + val series = spec.series.firstOrNull() ?: return + if (series.points.isEmpty()) return + val values = series.points.map { max(0.0, it.y) } + val total = values.reduce { acc, v -> acc + v } + if (total <= 0.0) return + + val diameter = min(right - left, bottom - top) + val topLeft = Offset(left + (right - left - diameter) / 2f, top + (bottom - top - diameter) / 2f) + val pieSize = Size(diameter, diameter) + var startAngle = -90f + + values.forEachIndexed { index, value -> + val sweep = (value / total * 360.0).toFloat() + drawArc( + color = ChartPalette[index % ChartPalette.size], + startAngle = startAngle, + sweepAngle = sweep, + useCenter = true, + topLeft = topLeft, + size = pieSize, + ) + startAngle += sweep + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeScreen.kt index de8e05c..7fb2224 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeScreen.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeScreen.kt @@ -1,3 +1,37 @@ package jp.orgflow.ui.home -// TODO(spec ch.?): implement HomeScreen per docs/spec.md +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Card +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun HomeScreen(viewModel: HomeViewModel = remember { HomeViewModel() }) { + viewModel.refresh(activities = 3, notes = 12, openTasks = 4) + Column(modifier = Modifier.fillMaxSize().padding(12.dp)) { + Text(viewModel.quickGreeting(), fontSize = 20.sp) + Row(modifier = Modifier.padding(vertical = 8.dp)) { + StatCard("Activities", viewModel.summary.activityCount) + StatCard("Notes", viewModel.summary.noteCount) + StatCard("Open tasks", viewModel.summary.taskOpen) + } + Text("Quick actions: capture a note, review today's agenda, open the experiment table.", fontSize = 13.sp) + } +} + +@Composable +private fun StatCard(label: String, count: Int) { + Card(modifier = Modifier.padding(end = 8.dp), elevation = 2.dp) { + Column(modifier = Modifier.padding(12.dp)) { + Text(label, fontSize = 12.sp) + Text("$count", fontSize = 22.sp) + } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeViewModel.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeViewModel.kt index 91147c2..c6e115f 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeViewModel.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeViewModel.kt @@ -1,3 +1,24 @@ package jp.orgflow.ui.home -// TODO(spec ch.?): implement HomeViewModel per docs/spec.md +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import jp.orgflow.domain.identity.ActivityId + +data class HomeSummary( + val activityCount: Int, + val noteCount: Int, + val taskOpen: Int, +) + +class HomeViewModel { + var summary: HomeSummary by mutableStateOf(HomeSummary(0, 0, 0)) + private set + var lastActivityId: ActivityId? = null + + fun refresh(activities: Int, notes: Int, openTasks: Int) { + summary = HomeSummary(activities, notes, openTasks) + } + + fun quickGreeting(): String = "Welcome to OrgFlow" +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorScreen.kt index 157bf0e..5e88413 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorScreen.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorScreen.kt @@ -1,3 +1,34 @@ package jp.orgflow.ui.notes -// TODO(spec ch.04): implement NoteEditorScreen per docs/spec.md +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import jp.orgflow.ui.component.SourceCommitBadge + +@Composable +fun NoteEditorScreen(viewModel: NoteEditorViewModel = remember { NoteEditorViewModel() }) { + val note = viewModel.notes.firstOrNull { it.id == viewModel.selectedId } + Column(modifier = Modifier.fillMaxSize().padding(12.dp)) { + Row { + Text(note?.title ?: "No note selected", fontSize = 17.sp, modifier = Modifier.padding(end = 8.dp)) + SourceCommitBadge(null) + } + if (note != null) { + OrgRichTextEditor( + state = viewModel.editor, + onContentChanged = { }, + ) + TextButton(onClick = { viewModel.saveCurrent() }) { Text("Save (commit candidate)") } + } else { + Text("Select a note from the list first.", fontSize = 13.sp) + } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorViewModel.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorViewModel.kt index fd723c9..e1f0569 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorViewModel.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorViewModel.kt @@ -1,3 +1,40 @@ package jp.orgflow.ui.notes -// TODO(spec ch.04): implement NoteEditorViewModel per docs/spec.md +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import jp.orgflow.ui.component.SaveableEditorState + +data class NoteEntry( + val id: String, + val title: String, + val body: String, +) + +class NoteEditorViewModel { + var notes: List<NoteEntry> by mutableStateOf( + listOf(NoteEntry("n1", "Research log", "* observation A\n* observation B")), + ) + private set + + val editor = SaveableEditorState() + + var selectedId: String? by mutableStateOf(null) + + fun select(id: String) { + selectedId = id + notes.firstOrNull { it.id == id }?.let { editor.edit(it.body) } + } + + fun saveCurrent(): Boolean { + val id = selectedId ?: return false + notes = notes.map { if (it.id == id) it.copy(body = editor.text) else it } + return true + } + + fun addNote(title: String): String { + val id = "n${notes.size + 1}" + notes = notes + NoteEntry(id, title.ifBlank { "Untitled" }, "") + return id + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NotesScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NotesScreen.kt index bc7206b..77569c6 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NotesScreen.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NotesScreen.kt @@ -1,3 +1,43 @@ package jp.orgflow.ui.notes -// TODO(spec ch.04): implement NotesScreen per docs/spec.md +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Card +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import jp.orgflow.ui.component.EmptyContent + +@Composable +fun NotesScreen(viewModel: NoteEditorViewModel = remember { NoteEditorViewModel() }) { + Column(modifier = Modifier.padding(12.dp)) { + Text("Notes", fontSize = 17.sp) + TextButton(onClick = { viewModel.addNote("New note") }) { Text("+ New note") } + if (viewModel.notes.isEmpty()) { + EmptyContent("No notes yet — capture your first observation") + } else { + LazyColumn { + items(viewModel.notes, key = { it.id }) { note -> + Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), elevation = 1.dp) { + Column(modifier = Modifier.padding(10.dp)) { + Text(note.title, fontSize = 14.sp) + Text( + note.body.lineSequence().firstOrNull() ?: "(empty)", + fontSize = 11.sp, + maxLines = 1, + ) + TextButton(onClick = { viewModel.select(note.id) }) { Text("Open") } + } + } + } + } + } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/OrgRichTextEditor.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/OrgRichTextEditor.kt index be6511e..78d1b29 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/OrgRichTextEditor.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/OrgRichTextEditor.kt @@ -1,3 +1,58 @@ package jp.orgflow.ui.notes -// TODO(spec ch.04): implement OrgRichTextEditor per docs/spec.md +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import jp.orgflow.ui.component.SaveableEditorState + +object OrgMarkdown { + fun toMarkdown(orgText: String): String = + orgText.lineSequence().joinToString("\n") { line -> + when { + line.startsWith("* ") -> "# " + line.removePrefix("* ") + else -> line + } + } + + fun toOrgMode(markdownText: String): String = + markdownText.lineSequence().joinToString("\n") { line -> + when { + line.startsWith("# ") -> "* " + line.removePrefix("# ") + line.startsWith("- ") -> "* " + line.removePrefix("- ") + else -> line + } + } +} + +@Composable +fun OrgRichTextEditor( + state: SaveableEditorState, + modifier: Modifier = Modifier, + onContentChanged: (String) -> Unit = {}, +) { + Column(modifier = modifier) { + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + TextButton(onClick = { state.undo() }, enabled = state.canUndo()) { Text("undo") } + TextButton(onClick = { state.redo() }, enabled = state.canRedo()) { Text("redo") } + TextButton(onClick = { state.edit(OrgMarkdown.toMarkdown(state.text)) }) { Text("to-md") } + TextButton(onClick = { state.edit(OrgMarkdown.toOrgMode(state.text)) }) { Text("to-org") } + } + OutlinedTextField( + value = state.text, + onValueChange = { + state.edit(it) + onContentChanged(it) + }, + modifier = Modifier.fillMaxWidth().padding(4.dp), + ) + Text("org structure (tables / drawers / planning) is protected — edit via dedicated UI", fontSize = 11.sp) + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/FirstCaptureStep.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/FirstCaptureStep.kt index 743e2a4..5c72e09 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/FirstCaptureStep.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/FirstCaptureStep.kt @@ -1,3 +1,22 @@ package jp.orgflow.ui.onboarding -// TODO(spec ch.05): implement FirstCaptureStep per docs/spec.md +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun FirstCaptureStep(onCaptured: () -> Unit) { + Column { + Text("First capture", fontSize = 15.sp) + Text("Record your first note with the 5W1H capture form — What happened, When, and Why it matters.", fontSize = 12.sp) + TextButton( + onClick = onCaptured, + modifier = Modifier.padding(top = 8.dp), + ) { Text("I captured something") } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/OnboardingScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/OnboardingScreen.kt index 57cd8b7..c84464c 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/OnboardingScreen.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/OnboardingScreen.kt @@ -1,3 +1,42 @@ package jp.orgflow.ui.onboarding -// TODO(spec ch.?): implement OnboardingScreen per docs/spec.md +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun OnboardingScreen(viewModel: OnboardingViewModel = remember { OnboardingViewModel() }) { + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + Text("Welcome to kukuri", fontSize = 20.sp) + LinearProgressIndicator( + progress = (viewModel.step + 1).toFloat() / viewModel.stepCount, + modifier = Modifier.padding(vertical = 8.dp), + ) + when (viewModel.step) { + 0 -> WorkspaceCreateStep( + workspaceName = viewModel.workspaceName, + onNameChange = { viewModel.workspaceName = it }, + ) + 1 -> WorkspaceJoinByQrStep( + onJoined = { viewModel.joinedViaQr = true }, + ) + else -> FirstCaptureStep( + onCaptured = { viewModel.firstCaptureDone = true }, + ) + } + Row(modifier = Modifier.padding(top = 12.dp)) { + TextButton(onClick = { viewModel.back() }, enabled = viewModel.step > 0) { Text("back") } + TextButton(onClick = { viewModel.next() }, enabled = viewModel.step < viewModel.stepCount - 1) { Text("next") } + } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/OnboardingViewModel.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/OnboardingViewModel.kt index b856939..f8f4a9b 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/OnboardingViewModel.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/OnboardingViewModel.kt @@ -1,3 +1,33 @@ package jp.orgflow.ui.onboarding -// TODO(spec ch.?): implement OnboardingViewModel per docs/spec.md +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +class OnboardingViewModel { + var step: Int by mutableIntStateOf(0) + private set + var workspaceName: String by mutableStateOf("") + var joinedViaQr: Boolean by mutableStateOf(false) + var firstCaptureDone: Boolean by mutableStateOf(false) + + val stepCount: Int get() = 3 + + fun next() { + if (step < stepCount - 1) step++ + } + + fun back() { + if (step > 0) step-- + } + + fun isStepComplete(index: Int): Boolean = when (index) { + 0 -> workspaceName.isNotBlank() || joinedViaQr + 1 -> joinedViaQr || workspaceName.isNotBlank() + 2 -> firstCaptureDone + else -> false + } + + fun allComplete(): Boolean = isStepComplete(0) && isStepComplete(2) +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/WorkspaceCreateStep.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/WorkspaceCreateStep.kt index 700b15b..540530d 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/WorkspaceCreateStep.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/WorkspaceCreateStep.kt @@ -1,3 +1,28 @@ package jp.orgflow.ui.onboarding -// TODO(spec ch.?): implement WorkspaceCreateStep per docs/spec.md +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun WorkspaceCreateStep( + workspaceName: String, + onNameChange: (String) -> Unit, +) { + Column { + Text("Create a workspace", fontSize = 15.sp) + Text("Your activities, notes and experiments live in one workspace.", fontSize = 12.sp) + OutlinedTextField( + value = workspaceName, + onValueChange = onNameChange, + label = { Text("workspace name") }, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + ) + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/WorkspaceJoinByQrStep.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/WorkspaceJoinByQrStep.kt index ebb8a87..05615ed 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/WorkspaceJoinByQrStep.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/WorkspaceJoinByQrStep.kt @@ -1,3 +1,31 @@ package jp.orgflow.ui.onboarding -// TODO(spec ch.?): implement WorkspaceJoinByQrStep per docs/spec.md +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun WorkspaceJoinByQrStep(onJoined: () -> Unit) { + var scanned by remember { mutableStateOf("") } + Column { + Text("Join by QR", fontSize = 15.sp) + Text("Paste the FSMP1 invite payload you scanned.", fontSize = 12.sp) + OutlinedTextField( + value = scanned, + onValueChange = { scanned = it }, + label = { Text("FSMP1:...") }, + modifier = Modifier.padding(vertical = 8.dp), + ) + TextButton(onClick = onJoined, enabled = scanned.startsWith("FSMP1")) { Text("join") } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/presentation/HtmlPreview.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/presentation/HtmlPreview.kt index 5d1088c..e23d9f6 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/presentation/HtmlPreview.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/presentation/HtmlPreview.kt @@ -1,3 +1,27 @@ package jp.orgflow.ui.presentation -// TODO(spec ch.10): implement HtmlPreview per docs/spec.md +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun HtmlPreview(html: String, modifier: Modifier = Modifier) { + Column(modifier = modifier.fillMaxWidth().height(240.dp).verticalScroll(rememberScrollState()).padding(8.dp)) { + Text(html, fontSize = 10.sp) + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/presentation/PresentationBuilderScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/presentation/PresentationBuilderScreen.kt index a1896e6..153480a 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/presentation/PresentationBuilderScreen.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/presentation/PresentationBuilderScreen.kt @@ -1,3 +1,69 @@ package jp.orgflow.ui.presentation -// TODO(spec ch.10): implement PresentationBuilderScreen per docs/spec.md +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Card +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun PresentationBuilderScreen(viewModel: PresentationViewModel = remember { PresentationViewModel() }) { + var html by remember { mutableStateOf("") } + Row(modifier = Modifier.fillMaxSize().padding(12.dp)) { + Column(modifier = Modifier.padding(end = 12.dp)) { + Text("Slides", fontSize = 15.sp) + LazyColumn { + items(presentationSlideIds(viewModel), key = { it }) { slideId -> + val slide = viewModel.presentation.slides.first { it.id == slideId } + Card( + elevation = if (slideId == viewModel.selectedSlideId) 4.dp else 1.dp, + modifier = Modifier.padding(vertical = 2.dp), + ) { + Column(modifier = Modifier.padding(8.dp)) { + Text(slide.title, fontSize = 13.sp) + Text("items=${slide.items.size}", fontSize = 11.sp) + TextButton(onClick = { viewModel.selectedSlideId = slideId }) { Text("select", fontSize = 11.sp) } + } + } + } + } + TextButton(onClick = { viewModel.addSlide("Slide ${viewModel.presentation.slides.size + 1}") }) { Text("+ slide") } + } + Column(modifier = Modifier.fillMaxSize()) { + Text(viewModel.presentation.title, fontSize = 17.sp) + viewModel.selectedSlide()?.let { slide -> + var title by remember(slide.id) { mutableStateOf(slide.title) } + var notes by remember(slide.id) { mutableStateOf(slide.notes) } + SlideEditor( + title = title, + notes = notes, + placements = slide.items.map { it.placement }, + onTitleChange = { title = it }, + onNotesChange = { notes = it }, + onCyclePlacement = {}, + ) + TextButton(onClick = { viewModel.updateNotes(slide.id, notes) }) { Text("save notes") } + } + TextButton(onClick = { html = viewModel.exportHtml() }) { Text("export HTML") } + if (html.isNotBlank()) { + Text("preview:", fontSize = 12.sp) + HtmlPreview(html) + } + } + } +} + +private fun presentationSlideIds(viewModel: PresentationViewModel): List<String> = + viewModel.presentation.slides.map { it.id } diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/presentation/PresentationViewModel.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/presentation/PresentationViewModel.kt index dc99c69..6b69980 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/presentation/PresentationViewModel.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/presentation/PresentationViewModel.kt @@ -1,3 +1,62 @@ package jp.orgflow.ui.presentation -// TODO(spec ch.10): implement PresentationViewModel per docs/spec.md +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import jp.orgflow.domain.identity.CardId +import jp.orgflow.presentation.builder.PresentationBuilder +import jp.orgflow.presentation.export.PresentationExporter +import jp.orgflow.presentation.model.Presentation +import jp.orgflow.presentation.model.PresentationSlide +import jp.orgflow.presentation.model.SlideItem +import jp.orgflow.presentation.model.SlidePlacement + +class PresentationViewModel(private val builder: PresentationBuilder = PresentationBuilder()) { + var presentation: Presentation by mutableStateOf(demo()) + private set + var selectedSlideId: String? by mutableStateOf(presentation.slides.firstOrNull()?.id) + + fun addSlide(title: String) { + val slide = PresentationSlide( + id = "s${presentation.slides.size + 1}", + title = title.ifBlank { "New Slide" }, + items = listOf(SlideItem(CardId("c${presentation.slides.size + 1}"), SlidePlacement.FULL)), + ) + presentation = builder.addSlide(presentation, slide) + selectedSlideId = slide.id + } + + fun removeSlide(slideId: String) { + val remaining = presentation.slides.filter { it.id != slideId } + if (remaining.isEmpty()) return + presentation = presentation.copy(slides = remaining) + if (selectedSlideId == slideId) selectedSlideId = remaining.first().id + } + + fun reorder(newOrder: List<String>) { + presentation = builder.reorderSlides(presentation, newOrder) + } + + fun updateNotes(slideId: String, notes: String) { + presentation = presentation.copy( + slides = presentation.slides.map { if (it.id == slideId) it.copy(notes = notes) else it }, + ) + } + + fun selectedSlide(): PresentationSlide? = presentation.slides.firstOrNull { it.id == selectedSlideId } + + fun exportHtml(): String = PresentationExporter.exportHtml(presentation) + + private fun demo(): Presentation = builder.build( + "demo", + "Demo Presentation", + listOf( + PresentationSlide("s1", "Welcome", listOf(SlideItem(CardId("c1"), SlidePlacement.FULL))), + PresentationSlide( + "s2", + "Results", + listOf(SlideItem(CardId("c2"), SlidePlacement.LEFT_HALF), SlideItem(CardId("c3"), SlidePlacement.RIGHT_HALF)), + ), + ), + ) +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/presentation/SlideEditor.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/presentation/SlideEditor.kt index d9ed8a4..218b199 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/presentation/SlideEditor.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/presentation/SlideEditor.kt @@ -1,3 +1,46 @@ package jp.orgflow.ui.presentation -// TODO(spec ch.10): implement SlideEditor per docs/spec.md +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import jp.orgflow.presentation.model.SlidePlacement + +@Composable +fun SlideEditor( + title: String, + notes: String, + placements: List<SlidePlacement>, + onTitleChange: (String) -> Unit, + onNotesChange: (String) -> Unit, + onCyclePlacement: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth().padding(4.dp)) { + OutlinedTextField( + value = title, + onValueChange = onTitleChange, + label = { Text("slide title") }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = notes, + onValueChange = onNotesChange, + label = { Text("speaker notes") }, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) + TextButton(onClick = onCyclePlacement) { + Text("cycle placement: ${placements.joinToString { it.name }}", fontSize = 12.sp) + } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceQrInviteCard.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceQrInviteCard.kt index d9e857c..97d5c8e 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceQrInviteCard.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceQrInviteCard.kt @@ -1,3 +1,29 @@ package jp.orgflow.ui.workspace -// TODO(spec ch.?): implement WorkspaceQrInviteCard per docs/spec.md +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Card +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun WorkspaceQrInviteCard( + workspaceId: String, + inviteToken: String, + modifier: Modifier = Modifier, +) { + Card(modifier = modifier.padding(8.dp), elevation = 2.dp) { + Column(modifier = Modifier.padding(12.dp)) { + Text("Workspace invite", fontSize = 14.sp) + Text("scan to join:", fontSize = 11.sp) + Text( + "FSMP1|${workspaceId}|$inviteToken", + fontSize = 12.sp, + ) + Text("(QR rendering pending — token is copyable)", fontSize = 10.sp) + } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceScreen.kt index ff0015e..02595d0 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceScreen.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceScreen.kt @@ -1,3 +1,27 @@ package jp.orgflow.ui.workspace -// TODO(spec ch.?): implement WorkspaceScreen per docs/spec.md +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun WorkspaceScreen(viewModel: WorkspaceViewModel = remember { WorkspaceViewModel() }) { + Column(modifier = Modifier.fillMaxSize().padding(12.dp)) { + Text("Workspace", fontSize = 17.sp) + Text("id: ${viewModel.workspace.id.value}", fontSize = 12.sp) + Text("members", fontSize = 14.sp, modifier = Modifier.padding(top = 8.dp)) + viewModel.membersUi().forEach { member -> + Text("${member.displayName} — ${member.role}", fontSize = 12.sp) + } + WorkspaceQrInviteCard( + workspaceId = viewModel.workspace.id.value, + inviteToken = viewModel.inviteToken(), + ) + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceViewModel.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceViewModel.kt index dc352c1..80111c3 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceViewModel.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceViewModel.kt @@ -1,3 +1,37 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class) + package jp.orgflow.ui.workspace -// TODO(spec ch.?): implement WorkspaceViewModel per docs/spec.md +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import jp.orgflow.domain.identity.WorkspaceId +import jp.orgflow.domain.workspace.OrgFlowWorkspace +import jp.orgflow.domain.workspace.WorkspaceRole +import jp.orgflow.domain.workspace.WorkspaceSettings +import kotlin.time.Instant + +data class WorkspaceMemberUi( + val memberId: String, + val displayName: String, + val role: WorkspaceRole, +) + +class WorkspaceViewModel { + var workspace: OrgFlowWorkspace by mutableStateOf( + OrgFlowWorkspace( + id = WorkspaceId("ws-demo"), + settings = WorkspaceSettings(name = "kukuri demo"), + members = listOf( + jp.orgflow.domain.workspace.WorkspaceMember("m1", WorkspaceRole.OWNER, Instant.fromEpochMilliseconds(0)), + jp.orgflow.domain.workspace.WorkspaceMember("m2", WorkspaceRole.MEMBER, Instant.fromEpochMilliseconds(0)), + ), + ), + ) + private set + + fun membersUi(): List<WorkspaceMemberUi> = + workspace.members.map { WorkspaceMemberUi(it.memberId, it.memberId, it.role) } + + fun inviteToken(): String = "tok-" + workspace.id.value + "-invite" +} diff --git a/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/ViewModelTest.kt b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/ViewModelTest.kt index 4988059..4cf8c93 100644 --- a/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/ViewModelTest.kt +++ b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/ViewModelTest.kt @@ -1,3 +1,98 @@ package jp.orgflow.ui -// TODO(spec ch.?): implement ViewModelTest per docs/spec.md +import jp.orgflow.ui.capture.FiveW1HField +import jp.orgflow.ui.capture.CaptureFormState +import jp.orgflow.ui.component.SaveableEditorState +import jp.orgflow.ui.component.UiWaterline +import jp.orgflow.ui.component.WaterlineBadge +import jp.orgflow.ui.distribution.DistributionViewModel +import jp.orgflow.ui.experiment.GuiLispAutocomplete +import jp.orgflow.ui.experiment.GuiLispSyntaxChecker +import jp.orgflow.ui.notes.OrgMarkdown +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ViewModelTest { + + @Test + fun navigationStateTransitions() { + val nav = OrgFlowNavigation() + assertEquals(OrgFlowRoute.Home, nav.current) + nav.navigate(OrgFlowRoute.Notes) + assertEquals(OrgFlowRoute.Notes, nav.current) + assertTrue(nav.canGoBack()) + nav.back() + assertEquals(OrgFlowRoute.Home, nav.current) + assertFalse(nav.canGoBack()) + } + + @Test + fun navigationSameRouteIsNoop() { + val nav = OrgFlowNavigation(OrgFlowRoute.Home) + nav.navigate(OrgFlowRoute.Home) + assertFalse(nav.canGoBack()) + } + + @Test + fun editorUndoRedo() { + val editor = SaveableEditorState("a") + editor.edit("ab") + editor.edit("abc") + assertTrue(editor.undo()) + assertEquals("ab", editor.text) + assertTrue(editor.undo()) + assertEquals("a", editor.text) + assertFalse(editor.undo()) + assertTrue(editor.redo()) + assertEquals("ab", editor.text) + } + + @Test + fun orgMarkdownRoundtrip() { + assertEquals("* Title", OrgMarkdown.toOrgMode("# Title")) + assertEquals("# Title", OrgMarkdown.toMarkdown("* Title")) + assertEquals("* plain", OrgMarkdown.toOrgMode("- plain")) + } + + @Test + fun fiveW1HRequiredValidation() { + val empty = CaptureFormState() + assertEquals(listOf(FiveW1HField.WHEN, FiveW1HField.WHAT), empty.missingRequired()) + assertFalse(empty.isSubmittable()) + val filled = empty.copy(fields = mapOf(FiveW1HField.WHEN to "today", FiveW1HField.WHAT to "observed X")) + assertTrue(filled.isSubmittable()) + } + + @Test + fun autocompleteSuggestsBuiltins() { + val suggestions = GuiLispAutocomplete.suggestions("(av") + assertTrue("avg" in suggestions) + assertTrue(GuiLispAutocomplete.suggestions("x").isEmpty()) + } + + @Test + fun lispSyntaxCheckerDetectsProblems() { + assertEquals(null, GuiLispSyntaxChecker.check("(a b)")) + assertEquals("missing 1 closing paren(s)", GuiLispSyntaxChecker.check("(a")) + assertEquals("unexpected )", GuiLispSyntaxChecker.check(")")) + } + + @Test + fun waterlineBadgeLabels() { + assertEquals("Usable", WaterlineBadge.label(UiWaterline.USABLE)) + assertEquals("Preview", WaterlineBadge.label(UiWaterline.PREVIEW)) + } + + @Test + fun distributionLevels() { + val vm = DistributionViewModel() + assertEquals(UiWaterline.FULL, vm.levelFor(1.0)) + assertEquals(UiWaterline.USABLE, vm.levelFor(0.9)) + assertEquals(UiWaterline.BASE, vm.levelFor(0.3)) + assertEquals(UiWaterline.PREVIEW, vm.levelFor(0.1)) + vm.tick() + assertTrue(vm.overview.peers.all { it.ratio <= 1.0 }) + } +} diff --git a/modules/zero-bridge-wasm/src/wasmJsMain/kotlin/jp/orgflow/bridge/wasm/BrowserZeroBridgeStub.kt b/modules/zero-bridge-wasm/src/wasmJsMain/kotlin/jp/orgflow/bridge/wasm/BrowserZeroBridgeStub.kt index 1929a9b..8cae391 100644 --- a/modules/zero-bridge-wasm/src/wasmJsMain/kotlin/jp/orgflow/bridge/wasm/BrowserZeroBridgeStub.kt +++ b/modules/zero-bridge-wasm/src/wasmJsMain/kotlin/jp/orgflow/bridge/wasm/BrowserZeroBridgeStub.kt @@ -1,3 +1,56 @@ package jp.orgflow.bridge.wasm -// TODO(spec ch.19): implement BrowserZeroBridgeStub per docs/spec.md +import jp.orgflow.bridge.* + +class BrowserZeroBridgeStub : ZeroBridge { + private val store = InMemoryFileStore() + + override val fileSystem: HostFileSystem = StubHostFileSystem(store) + override val storageQuota: HostStorageQuota = StubStorageQuota(store) + override val transport: HostTransport? = null + override val peerState: HostPeerState? = null + override val qrScanner: HostQrScanner? = null +} + +private class InMemoryFileStore { + val files = mutableMapOf<String, ByteArray>() + + fun totalBytes(): Long = files.values.sumOf { it.size.toLong() } +} + +private class StubHostFileSystem(private val store: InMemoryFileStore) : HostFileSystem { + override fun readBytes(path: String): ByteArray = + store.files[path] ?: throw BridgeException("Wasm FS: not found: $path") + + override fun writeBytes(path: String, data: ByteArray) { + store.files[path] = data + } + + override fun delete(path: String) { + store.files.remove(path) + } + + override fun exists(path: String): Boolean = path in store.files + + override fun list(directory: String): List<String> = + store.files.keys.filter { it.startsWith("$directory/") }.map { it.removePrefix("$directory/") } +} + +private class StubStorageQuota(private val store: InMemoryFileStore) : HostStorageQuota { + override fun usedBytes(): Long = store.totalBytes() + + override fun totalBytes(): Long = TOTAL_BYTES + + override fun availableBytes(): Long = totalBytes() - usedBytes() + + override fun estimate(): StorageEstimate { + val used = usedBytes() + val total = totalBytes() + val pct = if (total > 0) (used.toDouble() / total) * 100.0 else 0.0 + return StorageEstimate(used, total, pct) + } + + companion object { + private const val TOTAL_BYTES = 64L * 1024 * 1024 + } +} |
