# How Karukan's Learning Cache Works: Recency-Weighted Scoring and TSV Persistence

> Discover how Karukan's learning cache uses recency-weighted scoring and TSV persistence to efficiently store and retrieve user conversion history.

- Repository: [Hitoshi Togasaki/karukan](https://github.com/togatoga/karukan)
- Tags: internals
- Published: 2026-07-03

---

**Karukan's learning cache is a disk-backed HashMap that stores user conversion history, applies a recency-weighted scoring algorithm to boost recent selections, and persists data to a TSV file for session continuity.**

The `togatoga/karukan` IME engine personalizes conversion candidates by remembering which surface forms users select for specific hiragana readings. At the heart of this personalization system lies the learning cache, a lightweight persistence layer implemented in Rust that balances in-memory performance with durable TSV storage.

## In-Memory Architecture and Data Structures

The cache maintains a private `HashMap<String, Vec<LearningEntry>>` where each key represents a reading (hiragana input) and the vector stores all historically selected surfaces for that reading.

### The LearningEntry Struct

As defined in [`karukan-engine/src/learning.rs`](https://github.com/togatoga/karukan/blob/main/karukan-engine/src/learning.rs) at lines 12-21, each entry tracks three critical fields:

- **`surface`** – The kanji or hiragana string the user selected
- **`frequency`** – An integer counting how many times this surface was chosen for the reading
- **`last_access`** – A Unix timestamp recording the most recent selection

This structure enables the scoring algorithm to weigh both historical preference and temporal recency.

## Recording User Choices

When a user commits a conversion, the engine invokes `LearningCache::record` to update the history. According to the implementation in [`karukan-engine/src/learning.rs`](https://github.com/togatoga/karukan/blob/main/karukan-engine/src/learning.rs) (lines 47-62), the method executes the following steps:

1. Captures the current Unix time using `now_unix`
2. Retrieves the existing entry vector for the reading or creates a new one if absent
3. If the surface exists, increments its `frequency` and updates `last_access`; otherwise appends a new `LearningEntry`
4. Sets an internal `dirty` flag to signal that the cache requires persistence to disk

This incremental update strategy ensures that the cache reflects user behavior in real-time without expensive write operations on every keystroke.

## Recency-Weighted Scoring Algorithm

The ranking logic combines temporal decay with logarithmic frequency scaling. The helper function `score(entry, now)` in [`karukan-engine/src/learning.rs`](https://github.com/togatoga/karukan/blob/main/karukan-engine/src/learning.rs) (lines 27-41) computes values using:

**Recency calculation:**

```

age_days = (now - last_access) / 86400
recency = 1 / (1 + age_days)

```

Recent selections approach a recency value of 1.0, while older entries asymptotically approach zero.

**Frequency calculation:**

```

freq = ln(1 + frequency)

```

This logarithmic transformation provides diminishing returns for repeated use, preventing high-frequency entries from permanently dominating the rankings.

**Final composite score:**

```

score = recency * 10.0 + freq

```

The multiplication by 10 scales the recency term to dominate the ranking, similar to the `UserHistoryPredictor` implementation in Mozc, ensuring that recent selections receive significant priority even if their absolute frequency is lower than historical alternatives.

## Lookup and Retrieval APIs

The cache exposes two primary query methods that recompute scores on the fly using the current time, ensuring automatic decay of stale entries.

### Exact Match Lookup

The `lookup(reading)` method (lines 65-77 in [`learning.rs`](https://github.com/togatoga/karukan/blob/main/learning.rs)) returns a `Vec<(String, f64)>` containing tuples of surface and score, sorted by descending score. This powers the primary candidate ranking when the user types a complete reading.

### Prefix Matching

For predictive input, `prefix_lookup(prefix)` (lines 79-92) returns triplets of `(reading, surface, score)` for every reading that starts with the given prefix, also sorted by descending score. This enables the IME to suggest learned completions before the user finishes typing.

## Capacity Management and Entry Eviction

To prevent unbounded memory growth, the cache enforces a global limit of 10,000 entries by default (configurable via `max_entries`). When `save()` is called, the private `evict()` method executes:

1. Computes the total entry count across all reading buckets
2. If the count exceeds the limit, collects all entries with their computed scores
3. Sorts ascending by score (lowest first) and removes the excess entries
4. Cleans up empty reading vectors to free HashMap capacity

This least-valuable eviction strategy ensures that high-scoring (recent and frequent) entries survive while low-scoring entries are purged first.

## TSV Persistence Format

The cache serializes to a human-readable, tab-separated format that survives application restarts.

### Loading from Disk

`LearningCache::load` (lines 95-124 in [`learning.rs`](https://github.com/togatoga/karukan/blob/main/learning.rs)) parses the TSV file with the following format:

```

reading<TAB>surface<TAB>frequency<TAB>last_access

```

The loader skips comment lines (prefixed with `#`) and blank rows, building the in-memory HashMap incrementally. Line-level parsing errors do not abort the entire load operation, ensuring robustness against corrupted entries.

### Saving to Disk

The `save` method (lines 41-71) implements atomic persistence:

1. Triggers `evict()` to enforce capacity limits
2. Creates parent directories if necessary using `std::fs::create_dir_all`
3. Writes a header comment identifying the file format
4. Outputs one line per entry in deterministic order (sorted by reading)
5. Clears the `dirty` flag only after successful write completion

This deterministic ordering ensures that diff tools produce clean output when comparing cache versions.

## Integration Example

The `LearningCache` is re-exported in [`karukan-engine/src/lib.rs`](https://github.com/togatoga/karukan/blob/main/karukan-engine/src/lib.rs) (line 14) for consumption by higher-level crates such as `karukan-im`. The following example demonstrates complete lifecycle management:

```rust
use std::path::Path;
use karukan_engine::LearningCache;

// Initialize with default 10,000-entry limit
let mut cache = LearningCache::new(LearningCache::DEFAULT_MAX_ENTRIES);

// Record user selections
cache.record("きょう", "今日");  // First selection
cache.record("きょう", "今日");  // Frequency increments to 2
cache.record("きょう", "京");    // Alternative candidate

// Retrieve ranked candidates
let candidates = cache.lookup("きょう");
for (surface, score) in candidates {
    println!("{} → {:.3}", surface, score);
}

// Persist to TSV
let path = Path::new("/home/user/.local/share/karukan-im/learning.tsv");
cache.save(path).expect("Failed to persist learning cache");

// Restore in a new session
let loaded = LearningCache::load(path, LearningCache::DEFAULT_MAX_ENTRIES)
    .expect("Cannot read learning cache");

```

## Summary

- **Data Structure:** The cache uses a `HashMap<String, Vec<LearningEntry>>` where entries track surface, frequency, and last access timestamp.
- **Scoring:** The recency-weighted algorithm calculates `score = (1 / (1 + age_days)) * 10.0 + ln(1 + frequency)` to rank candidates.
- **Eviction:** When the 10,000-entry limit is exceeded, the cache removes lowest-scoring entries before saving.
- **Persistence:** Data serializes to a TSV format with columns `reading`, `surface`, `frequency`, and `last_access`, loaded at startup and saved on dirty changes.
- **API:** Public methods include `record`, `lookup`, `prefix_lookup`, `save`, and `load` in [`karukan-engine/src/learning.rs`](https://github.com/togatoga/karukan/blob/main/karukan-engine/src/learning.rs).

## Frequently Asked Questions

### How does the recency-weighted scoring algorithm prioritize recent entries?

The algorithm computes recency as `1 / (1 + age_in_days)` and multiplies it by 10, while frequency contributes only `ln(1 + frequency)`. This 10x scaling ensures that a selection made today (recency ≈ 1.0) scores higher than a selection made 9 days ago (recency = 0.1), even if the older entry has significantly higher frequency counts.

### What happens when the learning cache reaches its entry limit?

When `save()` is called, the cache evaluates the total entry count against `max_entries` (default 10,000). If exceeded, it computes scores for all entries, sorts them ascending, and removes the lowest-scoring entries first. This preserves high-value (recent and frequent) selections while pruning outdated history.

### How is the TSV file structured for persistence?

Each line follows the tab-separated format: `reading<TAB>surface<TAB>frequency<TAB>last_access`. The file supports comment lines beginning with `#` and handles missing directories by creating them automatically during the save operation. The implementation ensures deterministic output by sorting readings before serialization.

### Can the same surface exist for multiple different readings?

Yes. The HashMap keys are distinct readings, so the surface "今日" can exist under the reading "きょう" while also existing under a different reading like "こんにち" if the user has historically selected it for both inputs. Each reading maintains its own independent vector of learning entries.