summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorketsuban <ketsuban@kukuri.dev>2026-08-27 17:14:17 +0000
committerketsuban <ketsuban@kukuri.dev>2026-08-27 17:14:17 +0000
commit9610425ea46ae92f8caebf76b975ca17d23e115f (patch)
tree00252b60df2e557d0b05682f66eba3ad1fb08bbf
parent54e1397fbbfc7e82074fceaabaf9b64a01f06324 (diff)
downloadkukuri-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
-rw-r--r--modules/fsmp-core/build.gradle.kts31
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/FsmpConfiguration.kt42
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/FsmpEngine.kt52
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/aco/AcoRouteOptimizer.kt35
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/aco/BilliardReflector.kt40
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/aco/PheromoneTable.kt34
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/anchor/SeparatorAnchorResolver.kt62
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/bottompool/BottomPool.kt36
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/cache/ChunkCacheStore.kt37
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/chunkmind/ChunkMind.kt67
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/consistency/CommitConsistencyPolicy.kt16
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/consistency/CommitConsistencyResult.kt17
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/consistency/StaleCommitPolicy.kt9
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/error/FsmpError.kt8
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/error/FsmpErrorCode.kt11
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/error/FsmpProtocolException.kt2
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/explain/DecisionFactor.kt7
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/explain/DecisionTrace.kt8
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/fluid/FluidInjectionPlanner.kt37
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFragmenter.kt24
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFrame.kt10
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFrameDecoder.kt39
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFrameEncoder.kt45
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFrameHeader.kt17
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpFrameType.kt13
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpReassembler.kt39
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/frame/FsmpReplayGuard.kt21
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/ContentMessages.kt65
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/FsmpMessage.kt12
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/GitPoaMessages.kt30
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/PeerMessages.kt40
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/RepairMessages.kt39
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/RouteMessages.kt19
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pagepriority/PagePriorityBooster.kt19
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/peer/FsmpPeer.kt10
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/peer/FsmpPeerSession.kt13
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/peer/PeerCapabilities.kt10
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/peer/PeerReliability.kt32
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/peer/PeerScope.kt2
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/CandidateScorer.kt4
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/CandidateSource.kt4
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/DecisionContext.kt26
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/ScoredCandidate.kt18
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/SelectionPolicy.kt5
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/TransferCandidate.kt22
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/pipeline/TransferPlan.kt13
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/repair/RepairPriorityPlanner.kt18
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpDecisionLogger.kt17
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpEvent.kt11
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpEventBus.kt15
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpScheduler.kt31
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpStateMachine.kt34
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/runtime/FsmpTransferCoordinator.kt34
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/saturation/SmartSaturatingSelector.kt21
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/scoring/ObScaVScorer.kt33
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/scoring/ObScaVWeights.kt14
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/GroupBottleneckDetector.kt17
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/LocalityAwareRouting.kt23
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/PartialMeshManager.kt48
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/PartialMeshPolicy.kt7
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/PeerConnectionCandidate.kt10
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/PeerConnectionPruner.kt18
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/topology/PeerConnectionSelector.kt20
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/waterline/Waterline.kt6
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/waterline/WaterlineDefinition.kt11
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/waterline/WaterlineEvaluator.kt17
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/waterline/WaterlineState.kt16
-rw-r--r--modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/AcoRouteOptimizerTest.kt81
-rw-r--r--modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/BottomPoolPolicyTest.kt64
-rw-r--r--modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/ChunkMindTest.kt64
-rw-r--r--modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/CommitConsistencyPolicyTest.kt44
-rw-r--r--modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/FluidInjectionPlannerTest.kt57
-rw-r--r--modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/FsmpFrameCodecTest.kt82
-rw-r--r--modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/ObScaVScorerTest.kt67
-rw-r--r--modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/PartialMeshManagerTest.kt77
-rw-r--r--modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/SmartSaturatingSelectorTest.kt51
-rw-r--r--modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/WaterlineEvaluatorTest.kt48
-rw-r--r--modules/fsmp-relay/build.gradle.kts34
-rw-r--r--modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/AckBitmapAggregator.kt35
-rw-r--r--modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/FsmpRelay.kt84
-rw-r--r--modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/FsmpRelayConfiguration.kt8
-rw-r--r--modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelayCache.kt36
-rw-r--r--modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelayCacheIndex.kt23
-rw-r--r--modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelayManifestVerifier.kt24
-rw-r--r--modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelayPeerRegistry.kt24
-rw-r--r--modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelayRepairCoordinator.kt24
-rw-r--r--modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelaySelectionPolicy.kt25
-rw-r--r--modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/RelaySession.kt22
-rw-r--r--modules/fsmp-relay/src/commonMain/kotlin/jp/orgflow/relay/SelectiveChunkForwarder.kt23
-rw-r--r--modules/fsmp-relay/src/jvmMain/kotlin/jp/orgflow/relay/server/FsmpRelayServer.kt55
-rw-r--r--modules/fsmp-relay/src/jvmMain/kotlin/jp/orgflow/relay/server/RelayServerMain.kt19
-rw-r--r--modules/fsmp-relay/src/jvmMain/kotlin/jp/orgflow/relay/server/RelayServerModule.kt29
-rw-r--r--modules/fsmp-transport/build.gradle.kts31
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/api/FsmpTransport.kt15
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/api/FsmpTransportEvent.kt9
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/api/FsmpTransportException.kt2
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/api/FsmpTransportFrame.kt20
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/api/FsmpTransportState.kt4
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/qr/QrBootstrapEncoder.kt61
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/qr/QrBootstrapExpiryPolicy.kt16
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/qr/QrBootstrapPayload.kt12
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/qr/QrBootstrapValidator.kt21
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/AnswerSignal.kt9
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/IceCandidateSignal.kt10
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/KtorSignalingClient.kt73
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/OfferSignal.kt11
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/PeerJoinSignal.kt9
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/PeerLeaveSignal.kt8
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/SignalingClient.kt14
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/SignalingEnvelope.kt14
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/CandidateObservationMapper.kt35
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/DataChannelBackpressureController.kt42
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/FsmpDataChannelSession.kt29
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/FsmpPeerConnectionController.kt16
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/KtorWebRtcFsmpTransport.kt73
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/webrtc/RouteFeedbackEmitter.kt24
-rw-r--r--modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/DataChannelBackpressureTest.kt52
-rw-r--r--modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/QrBootstrapValidatorTest.kt63
-rw-r--r--modules/orgflow-capture/build.gradle.kts26
-rw-r--r--modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureCommitCoordinator.kt42
-rw-r--r--modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureField.kt57
-rw-r--r--modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureRequest.kt16
-rw-r--r--modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureResult.kt29
-rw-r--r--modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureService.kt133
-rw-r--r--modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureTemplate.kt28
-rw-r--r--modules/orgflow-capture/src/commonMain/kotlin/jp/orgflow/capture/CaptureTemplateRegistry.kt132
-rw-r--r--modules/orgflow-capture/src/commonTest/kotlin/jp/orgflow/capture/CaptureCommitCoordinatorTest.kt87
-rw-r--r--modules/orgflow-capture/src/commonTest/kotlin/jp/orgflow/capture/CaptureServiceTest.kt174
-rw-r--r--modules/orgflow-capture/src/commonTest/kotlin/jp/orgflow/capture/CaptureTemplateRegistryTest.kt52
-rw-r--r--modules/orgflow-experiment/build.gradle.kts23
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/calculation/AggregateFunctions.kt41
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/calculation/FilterFunctions.kt32
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/calculation/GroupFunctions.kt42
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/calculation/NumericCoercion.kt25
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/ChartAnchorFactory.kt40
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/ChartAxis.kt19
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/ChartSeries.kt20
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/ChartSpec.kt20
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/GraphCardFactory.kt55
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/chart/GuiLispChartCompiler.kt84
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/BuiltinFunction.kt22
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/BuiltinFunctionRegistry.kt194
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/GuiLispError.kt10
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/GuiLispEvaluationContext.kt84
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/GuiLispEvaluator.kt190
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/GuiLispGrammar.kt167
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispBoolean.kt4
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispList.kt14
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispNumber.kt11
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispString.kt7
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispSymbol.kt4
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/LispValue.kt10
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/lisp/TableEnvironment.kt51
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentCell.kt20
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentColumn.kt15
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentEvidence.kt8
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentRow.kt9
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentTable.kt21
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentTableRepository.kt26
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentTableService.kt171
-rw-r--r--modules/orgflow-experiment/src/commonMain/kotlin/jp/orgflow/experiment/table/ExperimentValue.kt35
-rw-r--r--modules/orgflow-experiment/src/commonTest/kotlin/jp/orgflow/experiment/ChartCompilerTest.kt158
-rw-r--r--modules/orgflow-experiment/src/commonTest/kotlin/jp/orgflow/experiment/ExperimentTableServiceTest.kt164
-rw-r--r--modules/orgflow-experiment/src/commonTest/kotlin/jp/orgflow/experiment/GuiLispEvaluatorTest.kt201
-rw-r--r--modules/orgflow-experiment/src/commonTest/kotlin/jp/orgflow/experiment/GuiLispGrammarTest.kt113
-rw-r--r--modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/CommitInfo.kt8
-rw-r--r--modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/DiffInfo.kt7
-rw-r--r--modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/GitPoaService.kt31
-rw-r--r--modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/TagInfo.kt5
-rw-r--r--modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/TreeEntryInfo.kt6
-rw-r--r--modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/api/TreeInfo.kt5
-rw-r--r--modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/gossip/CommitHashGossip.kt8
-rw-r--r--modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/gossip/CommitObservation.kt6
-rw-r--r--modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/policy/PoaState.kt7
-rw-r--r--modules/orgflow-git-poa/orgflow-git-poa/src/commonMain/kotlin/jp/orgflow/gitpoa/policy/PoaStateMachine.kt9
-rw-r--r--modules/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/ActivityTreeTest.kt67
-rw-r--r--modules/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/AnchorTreeTest.kt58
-rw-r--r--modules/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/GitTreeSnapshotTest.kt48
-rw-r--r--modules/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/TreeMappingVerifierTest.kt69
-rw-r--r--modules/orgflow-tree/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/ActivityTreeTest.kt67
-rw-r--r--modules/orgflow-tree/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/AnchorTreeTest.kt58
-rw-r--r--modules/orgflow-tree/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/GitTreeSnapshotTest.kt48
-rw-r--r--modules/orgflow-tree/orgflow-tree/commonTest/kotlin/jp/orgflow/tree/TreeMappingVerifierTest.kt69
-rw-r--r--modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/ActivityNode.kt37
-rw-r--r--modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/CardNode.kt8
-rw-r--r--modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/PresentationNode.kt6
-rw-r--r--modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/ProjectNode.kt9
-rw-r--r--modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/TaskNode.kt9
-rw-r--r--modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/activity/WorkspaceNode.kt9
-rw-r--r--modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/anchor/AnchorId.kt4
-rw-r--r--modules/orgflow-tree/orgflow-tree/src/commonMain/kotlin/jp/orgflow/tree/common/TreeAlgorithms.kt2
-rw-r--r--modules/orgflow-ui/build.gradle.kts31
-rw-r--r--modules/orgflow-ui/src/commonMain/composeResources/drawable/orgflow_logo.xml11
-rw-r--r--modules/orgflow-ui/src/commonMain/composeResources/values/strings.xml14
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowApp.kt53
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowNavigation.kt28
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowRoute.kt16
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowTheme.kt28
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/agenda/AgendaScreen.kt41
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/agenda/AgendaViewModel.kt27
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureScreen.kt47
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureViewModel.kt47
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/FiveW1HFormSection.kt60
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/QuickCaptureShortcuts.kt20
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/chart/UiChartSpec.kt30
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/ActivityCardView.kt24
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/ConfirmationDialog.kt23
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/EmptyContent.kt16
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/EntityDetailScreen.kt26
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/EntityListScreen.kt41
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/ErrorBanner.kt22
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/ErrorContent.kt22
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/LoadableState.kt27
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/LoadingContent.kt15
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/OfflineStatusBanner.kt22
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/PeerBadge.kt35
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/SaveableEditorState.kt41
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/SourceCommitBadge.kt23
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/component/WaterlineBadge.kt32
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/distribution/DistributionScreen.kt27
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/distribution/DistributionViewModel.kt50
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/distribution/PeerSyncMap.kt29
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/distribution/WaterlineProgressBoard.kt35
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/ExperimentTableScreen.kt63
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/ExperimentTableViewModel.kt47
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GraphTab.kt69
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispAutocomplete.kt18
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispEditor.kt26
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispErrorHint.kt36
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispLivePreview.kt22
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/KoalaChartRenderer.kt207
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeScreen.kt36
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeViewModel.kt23
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorScreen.kt33
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorViewModel.kt39
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NotesScreen.kt42
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/OrgRichTextEditor.kt57
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/FirstCaptureStep.kt21
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/OnboardingScreen.kt41
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/OnboardingViewModel.kt32
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/WorkspaceCreateStep.kt27
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/WorkspaceJoinByQrStep.kt30
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/presentation/HtmlPreview.kt26
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/presentation/PresentationBuilderScreen.kt68
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/presentation/PresentationViewModel.kt61
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/presentation/SlideEditor.kt45
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceQrInviteCard.kt28
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceScreen.kt26
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceViewModel.kt36
-rw-r--r--modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/ViewModelTest.kt97
-rw-r--r--modules/zero-bridge-wasm/src/wasmJsMain/kotlin/jp/orgflow/bridge/wasm/BrowserZeroBridgeStub.kt55
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
+ }
+}