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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ import SwiftUtils

/// かな漢字変換の管理を受け持つクラス
public final class KanaKanjiConverter {
/// 1つのConverterを複数の入力セッションで共有するための識別子。
public struct ConversionSessionID: Hashable, Sendable {
fileprivate let rawValue: String
}

public enum ConversionSessionError: Error, Equatable, Sendable {
case unknownSession(ConversionSessionID)
}

private let converter: Kana2Kanji
private struct ConversionSessionState {
var previousInputData: ComposingText?
Expand All @@ -23,9 +32,9 @@ public final class KanaKanjiConverter {
var ngramCache: NGramCache = .init()
var predictiveInputCache: PredictiveInputCacheEntry?
var stablePredictionCandidateCache: StablePredictionCandidateCacheEntry?
var lastData: DicdataElement?
}
private typealias SessionID = String
private static let defaultSessionID: SessionID = "default"
private static let defaultSessionID = ConversionSessionID(rawValue: "default")

public init(dicdataStore: DicdataStore) {
self.converter = .init(dicdataStore: dicdataStore)
Expand All @@ -51,9 +60,10 @@ public final class KanaKanjiConverter {
private var checkerInitialized: [KeyboardLanguage: Bool] = [.none: true, .ja_JP: true]

// 前回の変換や確定の情報を取っておく部分。
private var sessions: [SessionID: ConversionSessionState] = ["default": .init()]
private var activeSessionID: SessionID = "default"
private var lastData: DicdataElement?
private var sessions: [ConversionSessionID: ConversionSessionState] = [
KanaKanjiConverter.defaultSessionID: .init()
]
private var activeSessionID = KanaKanjiConverter.defaultSessionID
/// Zenzaiのためのzenzモデル
private var zenz: Zenz?
/// 完全な入力・文脈・制約をキーにする純粋なメモ化結果。
Expand All @@ -73,8 +83,50 @@ public final class KanaKanjiConverter {
self.sessions[self.activeSessionID] = state
}

/// 独立した変換状態を持つセッションを作成します。
///
/// 辞書、モデル、学習データ、Converter単位のメモ化キャッシュは同じ
/// `KanaKanjiConverter`インスタンス内で共有されます。
public func createSession() -> ConversionSessionID {
let sessionID = ConversionSessionID(rawValue: UUID().uuidString)
self.sessions[sessionID] = .init()
return sessionID
}

/// 作成済みの変換セッションを破棄します。
public func removeSession(_ sessionID: ConversionSessionID) {
guard sessionID != Self.defaultSessionID else {
self.sessions[sessionID] = .init()
return
}
self.sessions[sessionID] = nil
if self.activeSessionID == sessionID {
self.activeSessionID = Self.defaultSessionID
}
}

/// 指定したセッションを有効にして同期処理を実行します。
///
/// `KanaKanjiConverter`はスレッドセーフではありません。複数の実行コンテキストから
/// 利用する場合、呼び出し側でこのメソッドを含むConverterアクセスを直列化してください。
/// `operation`終了時には、ネストした呼び出しを含めて元のセッションへ戻ります。
public func withSession<Result>(
_ sessionID: ConversionSessionID,
operation: () throws -> Result
) throws -> Result {
guard self.sessions[sessionID] != nil else {
throw ConversionSessionError.unknownSession(sessionID)
}
let previousSessionID = self.activeSessionID
self.activeSessionID = sessionID
defer {
self.activeSessionID = previousSessionID
}
return try operation()
}

private func withScratchSession<T>(_ body: () -> T) -> T {
let scratchID: SessionID = "scratch-\(UUID().uuidString)"
let scratchID = ConversionSessionID(rawValue: "scratch-\(UUID().uuidString)")
self.sessions[scratchID] = self.currentSessionState
let previousSessionID = self.activeSessionID
let savedPersonalization = self.zenzaiPersonalization
Expand All @@ -91,9 +143,7 @@ public final class KanaKanjiConverter {
public func stopComposition() {
self.zenz?.endSession()
self.zenzaiPersonalization = nil
self.sessions = [Self.defaultSessionID: .init()]
self.activeSessionID = Self.defaultSessionID
self.lastData = nil
self.sessions[self.activeSessionID] = .init()
}

/// Zenzaiの純粋なメモ化結果を明示的に破棄します。
Expand Down Expand Up @@ -394,8 +444,10 @@ public final class KanaKanjiConverter {
/// - Warning:
/// `commitUpdateLearningData`を呼び出すまで永続化されません。
public func updateLearningData(_ candidate: Candidate) {
self.dicdataStoreState.updateLearningData(candidate, with: self.lastData)
self.lastData = candidate.data.last
self.dicdataStoreState.updateLearningData(candidate, with: self.currentSessionState.lastData)
self.updateCurrentSessionState {
$0.lastData = candidate.data.last
}
}

/// 確定操作後、学習メモリをアップデートする関数。
Expand All @@ -405,7 +457,9 @@ public final class KanaKanjiConverter {
/// `commitUpdateLearningData`を呼び出すまで永続化されません。
public func updateLearningData(_ candidate: Candidate, with predictionCandidate: PostCompositionPredictionCandidate) {
self.dicdataStoreState.updateLearningData(candidate, with: predictionCandidate)
self.lastData = predictionCandidate.lastData
self.updateCurrentSessionState {
$0.lastData = predictionCandidate.lastData
}
}

/// 確定操作後の学習メモリの更新を確定させます。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,9 @@ package final class DicdataStoreState {
}

func saveMemory() {
self.learningMemoryManager.save()
self.resetMemoryLOUDSCache()
if self.learningMemoryManager.save() {
self.resetMemoryLOUDSCache()
}
}

func resetMemory() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,10 @@ struct TemporalLearningMemoryTrie {
fileprivate var dicdata: [DicdataElement] = []
fileprivate var metadata: [MetadataElement] = []

var isEmpty: Bool {
self.dicdata.isEmpty
}

/// 同じノードにあることがわかっているデータを一括で追加する場面で利用する関数
/// 主にマージ時の利用を想定
fileprivate mutating func append(dicdata: [DicdataElement], chars: [UInt8], metadata: [MetadataElement]) {
Expand Down Expand Up @@ -848,11 +852,16 @@ final class LearningManager {
self.memoryCollapsed = LongTermLearningMemory.memoryCollapsed(directoryURL: memoryURL)
}

func save() {
@discardableResult
func save() -> Bool {
guard self.config.learningType.needUpdateMemory,
let memoryURL = config.memoryURL else {
debug(#function, "config.learningType=\(self.config.learningType as _?)", "skip memory update")
return
return false
}
guard !self.temporaryMemory.isEmpty else {
debug(#function, "skip because there is no pending memory")
return false
}
do {
try LongTermLearningMemory.merge(tempTrie: self.temporaryMemory, directoryURL: memoryURL, maxMemoryCount: self.config.maxMemoryCount, char2UInt8: char2UInt8)
Expand All @@ -864,6 +873,7 @@ final class LearningManager {
}
// 状態を更新する
self.memoryCollapsed = LongTermLearningMemory.memoryCollapsed(directoryURL: memoryURL)
return true
}

func resetMemory() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@ final class LearningMemoryTests: XCTestCase {
.init(learningType: .inputAndOutput, maxMemoryCount: 32, memoryURL: memoryURL)
}

func testSaveSkipsWhenNoPendingMemory() throws {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("LearningMemoryTest-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }

let manager = LearningManager(dictionaryURL: Self.resourceURL)
_ = manager.updateConfig(self.getConfigForMemoryTest(memoryURL: dir))

XCTAssertFalse(manager.save())

let element = DicdataElement(word: "テスト", ruby: "テスト", cid: CIDData.一般名詞.cid, mid: MIDData.一般.mid, value: -10)
manager.update(data: [element])
XCTAssertTrue(manager.save())
XCTAssertFalse(manager.save())
}

func testPauseFileIsClearedOnInit() throws {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("LearningMemoryTest-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import Foundation
@testable import KanaKanjiConverterModuleWithDefaultDictionary
import XCTest

final class ConverterSessionTests: XCTestCase {
func testStopCompositionOnlyResetsActiveSession() throws {
let converter = KanaKanjiConverter.withDefaultDictionary()
let firstSession = converter.createSession()
let secondSession = converter.createSession()

try converter.withSession(firstSession) {
converter.stopComposition()
}

XCTAssertNoThrow(
try converter.withSession(secondSession) {
var composingText = ComposingText()
composingText.insertAtCursorPosition("あずーきー", inputStyle: .direct)
_ = converter.requestCandidates(composingText, options: requestOptions())
}
)
}

func testRemovedSessionCannotBeSelected() throws {
let converter = KanaKanjiConverter.withDefaultDictionary()
let removedSession = converter.createSession()
let remainingSession = converter.createSession()

converter.removeSession(removedSession)

XCTAssertThrowsError(try converter.withSession(removedSession) {}) { error in
XCTAssertEqual(
error as? KanaKanjiConverter.ConversionSessionError,
.unknownSession(removedSession)
)
}
XCTAssertNoThrow(try converter.withSession(remainingSession) {})
}

func testNestedSessionSelectionRestoresOuterSession() throws {
let converter = KanaKanjiConverter.withDefaultDictionary()
let outerSession = converter.createSession()
let innerSession = converter.createSession()

try converter.withSession(outerSession) {
try converter.withSession(innerSession) {
converter.stopComposition()
}
converter.stopComposition()
}

XCTAssertNoThrow(try converter.withSession(outerSession) {})
XCTAssertNoThrow(try converter.withSession(innerSession) {})
}

private func requestOptions() -> ConvertRequestOptions {
ConvertRequestOptions(
N_best: 10,
requireJapanesePrediction: .disabled,
requireEnglishPrediction: .disabled,
keyboardLanguage: .ja_JP,
englishCandidateInRoman2KanaInput: true,
fullWidthRomanCandidate: false,
halfWidthKanaCandidate: false,
learningType: .nothing,
maxMemoryCount: 0,
shouldResetMemory: false,
memoryDirectoryURL: URL(fileURLWithPath: ""),
sharedContainerURL: URL(fileURLWithPath: ""),
textReplacer: .empty,
specialCandidateProviders: [],
typoCorrectionMode: .disabled,
metadata: nil
)
}
}
Loading