mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-28 16:49:50 -07:00
fix(sync): adopted lexicon frequency no longer double-counts active-session lines
The remote tracker increments imm_words/imm_kanji.frequency live, so a snapshot holding a stale ACTIVE session (skipped by the merge) has that session's partial occurrences baked into frequency. A word new to the local DB adopted that full total, then received the same occurrences again when the session finalized and merged on a later sync. Subtract active-session occurrence counts when adopting a new row; they are re-added exactly once when the session completes. Existing rows were already safe (isNew guard in addWordOccurrences/addKanjiOccurrences). Verified red/green: the new two-sync regression test fails without the subtraction (frequency 9 instead of 5).
This commit is contained in:
@@ -285,9 +285,11 @@ export function mergeExcludedWords(
|
||||
/**
|
||||
* Lazily maps remote imm_words / imm_kanji ids onto local rows by natural key
|
||||
* ((headword, word, reading) / kanji). New rows are copied with the remote's
|
||||
* accumulated frequency; rows that already exist locally get their frequency
|
||||
* incremented later with only the occurrence counts this merge adds (the
|
||||
* remote total would double-count lines merged in earlier syncs).
|
||||
* accumulated frequency minus any counts owed to skipped ACTIVE sessions
|
||||
* (those merge later and re-add their counts); rows that already exist locally
|
||||
* get their frequency incremented later with only the occurrence counts this
|
||||
* merge adds (the remote total would double-count lines merged in earlier
|
||||
* syncs).
|
||||
*/
|
||||
export class LexiconResolver {
|
||||
private readonly wordMap = new Map<number, { localId: number; isNew: boolean }>();
|
||||
@@ -301,6 +303,35 @@ export class LexiconResolver {
|
||||
private readonly summary: SyncMergeSummary,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Occurrence counts the remote's live tracker already baked into `frequency`
|
||||
* but that belong to skipped ACTIVE sessions. Those lines are not copied this
|
||||
* merge; they are re-added via addWordOccurrences/addKanjiOccurrences when
|
||||
* the session finalizes and syncs, so a newly adopted row must not carry them
|
||||
* or that slice would be counted twice.
|
||||
*/
|
||||
private pendingActiveSessionOccurrences(
|
||||
occurrenceTable: 'imm_word_line_occurrences' | 'imm_kanji_line_occurrences',
|
||||
idColumn: 'word_id' | 'kanji_id',
|
||||
remoteId: number,
|
||||
): number {
|
||||
const row = selectOne(
|
||||
this.remote,
|
||||
`SELECT COALESCE(SUM(o.occurrence_count), 0) AS pending
|
||||
FROM ${occurrenceTable} o
|
||||
JOIN imm_subtitle_lines l ON l.line_id = o.line_id
|
||||
JOIN imm_sessions s ON s.session_id = l.session_id
|
||||
WHERE o.${idColumn} = ? AND s.ended_at_ms IS NULL`,
|
||||
[remoteId],
|
||||
);
|
||||
return Number(row?.pending ?? 0);
|
||||
}
|
||||
|
||||
private adoptedFrequency(frequency: unknown, pending: number): unknown {
|
||||
if (pending <= 0 || typeof frequency !== 'number') return frequency;
|
||||
return Math.max(0, frequency - pending);
|
||||
}
|
||||
|
||||
resolveWord(remoteWordId: number): number {
|
||||
const cached = this.wordMap.get(remoteWordId);
|
||||
if (cached) return cached.localId;
|
||||
@@ -329,11 +360,18 @@ export class LexiconResolver {
|
||||
)
|
||||
.run(row.first_seen, row.first_seen, row.last_seen, row.last_seen, entry.localId);
|
||||
} else {
|
||||
const pending = this.pendingActiveSessionOccurrences(
|
||||
'imm_word_line_occurrences',
|
||||
'word_id',
|
||||
remoteWordId,
|
||||
);
|
||||
const localId = insertRow(
|
||||
this.local,
|
||||
'imm_words',
|
||||
WORD_COPY_COLUMNS,
|
||||
WORD_COPY_COLUMNS.map((column) => row[column]),
|
||||
WORD_COPY_COLUMNS.map((column) =>
|
||||
column === 'frequency' ? this.adoptedFrequency(row[column], pending) : row[column],
|
||||
),
|
||||
);
|
||||
entry = { localId, isNew: true };
|
||||
this.summary.wordsAdded += 1;
|
||||
@@ -368,11 +406,16 @@ export class LexiconResolver {
|
||||
)
|
||||
.run(row.first_seen, row.first_seen, row.last_seen, row.last_seen, entry.localId);
|
||||
} else {
|
||||
const pending = this.pendingActiveSessionOccurrences(
|
||||
'imm_kanji_line_occurrences',
|
||||
'kanji_id',
|
||||
remoteKanjiId,
|
||||
);
|
||||
const localId = insertRow(
|
||||
this.local,
|
||||
'imm_kanji',
|
||||
['kanji', 'first_seen', 'last_seen', 'frequency'],
|
||||
[row.kanji, row.first_seen, row.last_seen, row.frequency],
|
||||
[row.kanji, row.first_seen, row.last_seen, this.adoptedFrequency(row.frequency, pending)],
|
||||
);
|
||||
entry = { localId, isNew: true };
|
||||
this.summary.kanjiAdded += 1;
|
||||
|
||||
Reference in New Issue
Block a user