github-issue-tower-defence-management 1.140.2 → 1.141.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +7 -0
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +43 -2
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.js +32 -0
- package/bin/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.js.map +1 -0
- package/bin/adapter/repositories/FileSystemSessionAssistantTurnsRepository.js +135 -0
- package/bin/adapter/repositories/FileSystemSessionAssistantTurnsRepository.js.map +1 -0
- package/bin/adapter/repositories/FileSystemSessionDegenerationCooldownStateRepository.js +120 -0
- package/bin/adapter/repositories/FileSystemSessionDegenerationCooldownStateRepository.js.map +1 -0
- package/bin/domain/entities/OutputDegeneration.js +3 -0
- package/bin/domain/entities/OutputDegeneration.js.map +1 -0
- package/bin/domain/usecases/OutputDegenerationDetector.js +150 -0
- package/bin/domain/usecases/OutputDegenerationDetector.js.map +1 -0
- package/bin/domain/usecases/SessionOutputDegenerationRecoveryUseCase.js +84 -0
- package/bin/domain/usecases/SessionOutputDegenerationRecoveryUseCase.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/SessionAssistantTurnsRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/SessionAssistantTurnsRepository.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/SessionDegenerationCooldownStateRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/SessionDegenerationCooldownStateRepository.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +44 -0
- package/src/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.test.ts +69 -0
- package/src/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.ts +71 -0
- package/src/adapter/repositories/FileSystemSessionAssistantTurnsRepository.test.ts +138 -0
- package/src/adapter/repositories/FileSystemSessionAssistantTurnsRepository.ts +113 -0
- package/src/adapter/repositories/FileSystemSessionDegenerationCooldownStateRepository.test.ts +85 -0
- package/src/adapter/repositories/FileSystemSessionDegenerationCooldownStateRepository.ts +105 -0
- package/src/domain/entities/OutputDegeneration.ts +10 -0
- package/src/domain/usecases/OutputDegenerationDetector.test.ts +167 -0
- package/src/domain/usecases/OutputDegenerationDetector.ts +169 -0
- package/src/domain/usecases/SessionOutputDegenerationRecoveryUseCase.test.ts +224 -0
- package/src/domain/usecases/SessionOutputDegenerationRecoveryUseCase.ts +142 -0
- package/src/domain/usecases/adapter-interfaces/SessionAssistantTurnsRepository.ts +6 -0
- package/src/domain/usecases/adapter-interfaces/SessionDegenerationCooldownStateRepository.ts +4 -0
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.d.ts +19 -0
- package/types/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.d.ts.map +1 -0
- package/types/adapter/repositories/FileSystemSessionAssistantTurnsRepository.d.ts +10 -0
- package/types/adapter/repositories/FileSystemSessionAssistantTurnsRepository.d.ts.map +1 -0
- package/types/adapter/repositories/FileSystemSessionDegenerationCooldownStateRepository.d.ts +15 -0
- package/types/adapter/repositories/FileSystemSessionDegenerationCooldownStateRepository.d.ts.map +1 -0
- package/types/domain/entities/OutputDegeneration.d.ts +10 -0
- package/types/domain/entities/OutputDegeneration.d.ts.map +1 -0
- package/types/domain/usecases/OutputDegenerationDetector.d.ts +32 -0
- package/types/domain/usecases/OutputDegenerationDetector.d.ts.map +1 -0
- package/types/domain/usecases/SessionOutputDegenerationRecoveryUseCase.d.ts +31 -0
- package/types/domain/usecases/SessionOutputDegenerationRecoveryUseCase.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/SessionAssistantTurnsRepository.d.ts +4 -0
- package/types/domain/usecases/adapter-interfaces/SessionAssistantTurnsRepository.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/SessionDegenerationCooldownStateRepository.d.ts +8 -0
- package/types/domain/usecases/adapter-interfaces/SessionDegenerationCooldownStateRepository.d.ts.map +1 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import { SessionAssistantTurnsRepository } from '../../domain/usecases/adapter-interfaces/SessionAssistantTurnsRepository';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_TRANSCRIPT_TAIL_BYTES = 3_000_000;
|
|
5
|
+
|
|
6
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
7
|
+
typeof value === 'object' && value !== null;
|
|
8
|
+
|
|
9
|
+
const assistantText = (message: Record<string, unknown>): string => {
|
|
10
|
+
const content = message.content;
|
|
11
|
+
if (typeof content === 'string') {
|
|
12
|
+
return content;
|
|
13
|
+
}
|
|
14
|
+
if (Array.isArray(content)) {
|
|
15
|
+
const parts: string[] = [];
|
|
16
|
+
for (const block of content) {
|
|
17
|
+
if (
|
|
18
|
+
isRecord(block) &&
|
|
19
|
+
block.type === 'text' &&
|
|
20
|
+
typeof block.text === 'string'
|
|
21
|
+
) {
|
|
22
|
+
parts.push(block.text);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return parts.join(' ');
|
|
26
|
+
}
|
|
27
|
+
return '';
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export class FileSystemSessionAssistantTurnsRepository implements SessionAssistantTurnsRepository {
|
|
31
|
+
constructor(
|
|
32
|
+
private readonly tailBytes: number = DEFAULT_TRANSCRIPT_TAIL_BYTES,
|
|
33
|
+
) {}
|
|
34
|
+
|
|
35
|
+
listRecentAssistantTurnsBySessionName = async (
|
|
36
|
+
transcriptPathBySessionName: Map<string, string>,
|
|
37
|
+
maxTurnsPerSession: number,
|
|
38
|
+
): Promise<Map<string, string[]>> => {
|
|
39
|
+
const turnsBySessionName = new Map<string, string[]>();
|
|
40
|
+
for (const [sessionName, transcriptPath] of transcriptPathBySessionName) {
|
|
41
|
+
const turns = this.readRecentAssistantTurns(
|
|
42
|
+
transcriptPath,
|
|
43
|
+
maxTurnsPerSession,
|
|
44
|
+
);
|
|
45
|
+
if (turns.length > 0) {
|
|
46
|
+
turnsBySessionName.set(sessionName, turns);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return turnsBySessionName;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
private readRecentAssistantTurns = (
|
|
53
|
+
transcriptPath: string,
|
|
54
|
+
maxTurns: number,
|
|
55
|
+
): string[] => {
|
|
56
|
+
const lines = this.readTailLines(transcriptPath);
|
|
57
|
+
const turns: string[] = [];
|
|
58
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
59
|
+
const trimmed = lines[index].trim();
|
|
60
|
+
if (trimmed.length === 0) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
let parsed: unknown;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(trimmed);
|
|
66
|
+
} catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (!isRecord(parsed) || parsed.type !== 'assistant') {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const message = parsed.message;
|
|
73
|
+
if (!isRecord(message)) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const text = assistantText(message);
|
|
77
|
+
if (text.trim().length === 0) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
turns.push(text);
|
|
81
|
+
if (turns.length >= maxTurns) {
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return turns;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
private readTailLines = (transcriptPath: string): string[] => {
|
|
89
|
+
let handle: number;
|
|
90
|
+
try {
|
|
91
|
+
handle = fs.openSync(transcriptPath, 'r');
|
|
92
|
+
} catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const size = fs.fstatSync(handle).size;
|
|
97
|
+
const start = size > this.tailBytes ? size - this.tailBytes : 0;
|
|
98
|
+
const length = size - start;
|
|
99
|
+
const buffer = new Uint8Array(length);
|
|
100
|
+
fs.readSync(handle, buffer, 0, length, start);
|
|
101
|
+
let text = Buffer.from(buffer).toString('utf8');
|
|
102
|
+
if (start > 0) {
|
|
103
|
+
const newlineIndex = text.indexOf('\n');
|
|
104
|
+
text = newlineIndex === -1 ? '' : text.slice(newlineIndex + 1);
|
|
105
|
+
}
|
|
106
|
+
return text.split('\n');
|
|
107
|
+
} catch {
|
|
108
|
+
return [];
|
|
109
|
+
} finally {
|
|
110
|
+
fs.closeSync(handle);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { FileSystemSessionDegenerationCooldownStateRepository } from './FileSystemSessionDegenerationCooldownStateRepository';
|
|
5
|
+
|
|
6
|
+
describe('FileSystemSessionDegenerationCooldownStateRepository', () => {
|
|
7
|
+
let temporaryDirectory: string;
|
|
8
|
+
let stateFilePath: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
temporaryDirectory = fs.mkdtempSync(
|
|
12
|
+
path.join(os.tmpdir(), 'degeneration-cooldown-'),
|
|
13
|
+
);
|
|
14
|
+
stateFilePath = path.join(temporaryDirectory, 'cooldown.json');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns an empty map when no state file exists', async () => {
|
|
22
|
+
const repository = new FileSystemSessionDegenerationCooldownStateRepository(
|
|
23
|
+
stateFilePath,
|
|
24
|
+
);
|
|
25
|
+
expect(await repository.loadLastResetEpochSecondsBySessionName()).toEqual(
|
|
26
|
+
new Map(),
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('persists and reloads a per-session reset time', async () => {
|
|
31
|
+
const repository = new FileSystemSessionDegenerationCooldownStateRepository(
|
|
32
|
+
stateFilePath,
|
|
33
|
+
);
|
|
34
|
+
const now = new Date('2026-07-26T00:00:00Z');
|
|
35
|
+
await repository.recordReset({ sessionName: 'session-a', now });
|
|
36
|
+
|
|
37
|
+
const reloaded = await repository.loadLastResetEpochSecondsBySessionName();
|
|
38
|
+
expect(reloaded.get('session-a')).toBe(Math.floor(now.getTime() / 1000));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('keeps distinct sessions separate and overwrites the same session', async () => {
|
|
42
|
+
const repository = new FileSystemSessionDegenerationCooldownStateRepository(
|
|
43
|
+
stateFilePath,
|
|
44
|
+
);
|
|
45
|
+
await repository.recordReset({
|
|
46
|
+
sessionName: 'session-a',
|
|
47
|
+
now: new Date('2026-07-26T00:00:00Z'),
|
|
48
|
+
});
|
|
49
|
+
await repository.recordReset({
|
|
50
|
+
sessionName: 'session-b',
|
|
51
|
+
now: new Date('2026-07-26T00:01:00Z'),
|
|
52
|
+
});
|
|
53
|
+
await repository.recordReset({
|
|
54
|
+
sessionName: 'session-a',
|
|
55
|
+
now: new Date('2026-07-26T00:02:00Z'),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const reloaded = await repository.loadLastResetEpochSecondsBySessionName();
|
|
59
|
+
expect(reloaded.get('session-a')).toBe(
|
|
60
|
+
Math.floor(new Date('2026-07-26T00:02:00Z').getTime() / 1000),
|
|
61
|
+
);
|
|
62
|
+
expect(reloaded.get('session-b')).toBe(
|
|
63
|
+
Math.floor(new Date('2026-07-26T00:01:00Z').getTime() / 1000),
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('drops entries older than the retention window on the next write', async () => {
|
|
68
|
+
const repository = new FileSystemSessionDegenerationCooldownStateRepository(
|
|
69
|
+
stateFilePath,
|
|
70
|
+
60,
|
|
71
|
+
);
|
|
72
|
+
await repository.recordReset({
|
|
73
|
+
sessionName: 'stale-session',
|
|
74
|
+
now: new Date('2026-07-26T00:00:00Z'),
|
|
75
|
+
});
|
|
76
|
+
await repository.recordReset({
|
|
77
|
+
sessionName: 'fresh-session',
|
|
78
|
+
now: new Date('2026-07-26T00:05:00Z'),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const reloaded = await repository.loadLastResetEpochSecondsBySessionName();
|
|
82
|
+
expect(reloaded.has('stale-session')).toBe(false);
|
|
83
|
+
expect(reloaded.has('fresh-session')).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { SessionDegenerationCooldownStateRepository } from '../../domain/usecases/adapter-interfaces/SessionDegenerationCooldownStateRepository';
|
|
5
|
+
|
|
6
|
+
type StoredResetEntry = {
|
|
7
|
+
sessionName: string;
|
|
8
|
+
resetEpochSeconds: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
12
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_RESET_RETENTION_WINDOW_SECONDS = 60 * 60;
|
|
15
|
+
|
|
16
|
+
const defaultStateFilePath = (): string => {
|
|
17
|
+
const base = process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), '.cache');
|
|
18
|
+
return path.join(base, 'tdpm', 'output-degeneration-cooldown.json');
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export class FileSystemSessionDegenerationCooldownStateRepository implements SessionDegenerationCooldownStateRepository {
|
|
22
|
+
constructor(
|
|
23
|
+
private readonly stateFilePath: string = defaultStateFilePath(),
|
|
24
|
+
private readonly retentionWindowSeconds: number = DEFAULT_RESET_RETENTION_WINDOW_SECONDS,
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
loadLastResetEpochSecondsBySessionName = async (): Promise<
|
|
28
|
+
Map<string, number>
|
|
29
|
+
> => {
|
|
30
|
+
const lastResetBySessionName = new Map<string, number>();
|
|
31
|
+
for (const entry of this.readResetEntries()) {
|
|
32
|
+
lastResetBySessionName.set(entry.sessionName, entry.resetEpochSeconds);
|
|
33
|
+
}
|
|
34
|
+
return lastResetBySessionName;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
recordReset = async (params: {
|
|
38
|
+
sessionName: string;
|
|
39
|
+
now: Date;
|
|
40
|
+
}): Promise<void> => {
|
|
41
|
+
const resetEpochSeconds = Math.floor(params.now.getTime() / 1000);
|
|
42
|
+
const oldestRetainedEpochSeconds =
|
|
43
|
+
resetEpochSeconds - this.retentionWindowSeconds;
|
|
44
|
+
const mergedBySessionName = new Map<string, StoredResetEntry>();
|
|
45
|
+
for (const entry of this.readResetEntries()) {
|
|
46
|
+
if (
|
|
47
|
+
entry.resetEpochSeconds >= oldestRetainedEpochSeconds &&
|
|
48
|
+
entry.sessionName !== params.sessionName
|
|
49
|
+
) {
|
|
50
|
+
mergedBySessionName.set(entry.sessionName, entry);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
mergedBySessionName.set(params.sessionName, {
|
|
54
|
+
sessionName: params.sessionName,
|
|
55
|
+
resetEpochSeconds,
|
|
56
|
+
});
|
|
57
|
+
this.writeState(Array.from(mergedBySessionName.values()));
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
private readResetEntries = (): StoredResetEntry[] => {
|
|
61
|
+
let raw: string;
|
|
62
|
+
try {
|
|
63
|
+
raw = fs.readFileSync(this.stateFilePath, 'utf8');
|
|
64
|
+
} catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
let parsed: unknown;
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(raw);
|
|
70
|
+
} catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
if (!isRecord(parsed)) {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
const storedEntries = parsed.resets;
|
|
77
|
+
if (!Array.isArray(storedEntries)) {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
const entries: StoredResetEntry[] = [];
|
|
81
|
+
for (const storedEntry of storedEntries) {
|
|
82
|
+
if (!isRecord(storedEntry)) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const sessionName = storedEntry.sessionName;
|
|
86
|
+
const resetEpochSeconds = storedEntry.resetEpochSeconds;
|
|
87
|
+
if (
|
|
88
|
+
typeof sessionName === 'string' &&
|
|
89
|
+
typeof resetEpochSeconds === 'number' &&
|
|
90
|
+
Number.isFinite(resetEpochSeconds)
|
|
91
|
+
) {
|
|
92
|
+
entries.push({ sessionName, resetEpochSeconds });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return entries;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
private writeState = (resets: StoredResetEntry[]): void => {
|
|
99
|
+
const directory = path.dirname(this.stateFilePath);
|
|
100
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
101
|
+
const temporaryPath = `${this.stateFilePath}.${process.pid}.tmp`;
|
|
102
|
+
fs.writeFileSync(temporaryPath, JSON.stringify({ resets }));
|
|
103
|
+
fs.renameSync(temporaryPath, this.stateFilePath);
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import {
|
|
2
|
+
OutputDegenerationDetector,
|
|
3
|
+
OUTPUT_DEGENERATION_REPEAT_THRESHOLD,
|
|
4
|
+
OUTPUT_DEGENERATION_DOMINATION_FRACTION,
|
|
5
|
+
OUTPUT_DEGENERATION_MAX_TOKEN_LENGTH,
|
|
6
|
+
OUTPUT_DEGENERATION_ABSOLUTE_RUN_THRESHOLD,
|
|
7
|
+
OUTPUT_DEGENERATION_REP4_THRESHOLD,
|
|
8
|
+
OUTPUT_DEGENERATION_REP4_MIN_TOKENS,
|
|
9
|
+
OUTPUT_DEGENERATION_CROSS_TURN_WINDOW,
|
|
10
|
+
OUTPUT_DEGENERATION_CROSS_TURN_MIN_TURNS,
|
|
11
|
+
} from './OutputDegenerationDetector';
|
|
12
|
+
|
|
13
|
+
const repeat = (token: string, count: number): string =>
|
|
14
|
+
Array.from({ length: count }, () => token).join(' ');
|
|
15
|
+
|
|
16
|
+
describe('OutputDegenerationDetector', () => {
|
|
17
|
+
const detector = new OutputDegenerationDetector();
|
|
18
|
+
|
|
19
|
+
describe('ported constants match the validated Python values', () => {
|
|
20
|
+
it('exposes the exact thresholds', () => {
|
|
21
|
+
expect(OUTPUT_DEGENERATION_REPEAT_THRESHOLD).toBe(5);
|
|
22
|
+
expect(OUTPUT_DEGENERATION_DOMINATION_FRACTION).toBe(0.8);
|
|
23
|
+
expect(OUTPUT_DEGENERATION_MAX_TOKEN_LENGTH).toBe(32);
|
|
24
|
+
expect(OUTPUT_DEGENERATION_ABSOLUTE_RUN_THRESHOLD).toBe(15);
|
|
25
|
+
expect(OUTPUT_DEGENERATION_REP4_THRESHOLD).toBe(0.3);
|
|
26
|
+
expect(OUTPUT_DEGENERATION_REP4_MIN_TOKENS).toBe(40);
|
|
27
|
+
expect(OUTPUT_DEGENERATION_CROSS_TURN_WINDOW).toBe(10);
|
|
28
|
+
expect(OUTPUT_DEGENERATION_CROSS_TURN_MIN_TURNS).toBe(4);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('isIntraTurnDegeneration', () => {
|
|
33
|
+
it('fires on a turn dominated by a single short repeated token (positive)', () => {
|
|
34
|
+
expect(detector.isIntraTurnDegeneration(repeat('court', 40))).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('does not fire on a short run just below the repeat threshold (negative)', () => {
|
|
38
|
+
expect(detector.isIntraTurnDegeneration(repeat('court', 4))).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('does not fire on healthy prose with no repetition', () => {
|
|
42
|
+
const healthy =
|
|
43
|
+
'I finished the migration and verified the results against the seed data. The build passed and the report is uploaded.';
|
|
44
|
+
expect(detector.isIntraTurnDegeneration(healthy)).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('fires exactly at the domination-fraction boundary (run 5 of 6 tokens)', () => {
|
|
48
|
+
expect(
|
|
49
|
+
detector.isIntraTurnDegeneration('count count count count count alpha'),
|
|
50
|
+
).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('does not fire just past the domination-fraction boundary (run 5 of 7 tokens)', () => {
|
|
54
|
+
expect(
|
|
55
|
+
detector.isIntraTurnDegeneration(
|
|
56
|
+
'count count count count count alpha beta',
|
|
57
|
+
),
|
|
58
|
+
).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('fires on a large absolute run even when domination fails', () => {
|
|
62
|
+
const buried = `${repeat('court', 15)} ${repeat('distinct', 1)} one two three four five`;
|
|
63
|
+
expect(detector.isIntraTurnDegeneration(buried)).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('fires on a repeated phrase loop via duplicated 4-gram density', () => {
|
|
67
|
+
const phraseLoop = repeat('the quick brown fox', 15);
|
|
68
|
+
expect(detector.isIntraTurnDegeneration(phraseLoop)).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('does not fire on a non-dominating repeat run confined to a fenced code block', () => {
|
|
72
|
+
const variedProse = Array.from(
|
|
73
|
+
{ length: 100 },
|
|
74
|
+
(_unused, index) => `word${index}`,
|
|
75
|
+
).join(' ');
|
|
76
|
+
const withCodeBlock = `${variedProse}\n\`\`\`\n${repeat('court', 20)}\n\`\`\``;
|
|
77
|
+
expect(detector.isIntraTurnDegeneration(withCodeBlock)).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('ignores repeated long tokens above the max token length', () => {
|
|
81
|
+
const longToken = 'a'.repeat(OUTPUT_DEGENERATION_MAX_TOKEN_LENGTH + 1);
|
|
82
|
+
expect(detector.isIntraTurnDegeneration(repeat(longToken, 40))).toBe(
|
|
83
|
+
false,
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns false for an empty turn', () => {
|
|
88
|
+
expect(detector.isIntraTurnDegeneration('')).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('maxConsecutiveShortTokenRun', () => {
|
|
93
|
+
it('reports the dominating token and run length for logging', () => {
|
|
94
|
+
const result = detector.maxConsecutiveShortTokenRun(repeat('court', 12));
|
|
95
|
+
expect(result.token).toBe('court');
|
|
96
|
+
expect(result.run).toBe(12);
|
|
97
|
+
expect(result.total).toBe(12);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('detectCrossTurnDegeneration', () => {
|
|
102
|
+
const cleanEnding = (index: number): string =>
|
|
103
|
+
`Turn ${index}: I completed the step and reported the result.`;
|
|
104
|
+
const trailingTokenTurn = (index: number, token: string): string =>
|
|
105
|
+
`Turn ${index}: real message here.\n\n${token}`;
|
|
106
|
+
|
|
107
|
+
it('fires when the same trailing token recurs in 4 of the last 10 turns', () => {
|
|
108
|
+
const turns = [
|
|
109
|
+
trailingTokenTurn(1, 'court'),
|
|
110
|
+
cleanEnding(2),
|
|
111
|
+
trailingTokenTurn(3, 'court'),
|
|
112
|
+
cleanEnding(4),
|
|
113
|
+
trailingTokenTurn(5, 'court'),
|
|
114
|
+
cleanEnding(6),
|
|
115
|
+
trailingTokenTurn(7, 'court'),
|
|
116
|
+
cleanEnding(8),
|
|
117
|
+
cleanEnding(9),
|
|
118
|
+
cleanEnding(10),
|
|
119
|
+
];
|
|
120
|
+
const result = detector.detectCrossTurnDegeneration(turns);
|
|
121
|
+
expect(result).not.toBeNull();
|
|
122
|
+
expect(result?.token).toBe('court');
|
|
123
|
+
expect(result?.turnCount).toBe(4);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('does not fire when the trailing token recurs in only 3 of 10 turns', () => {
|
|
127
|
+
const turns = [
|
|
128
|
+
trailingTokenTurn(1, 'court'),
|
|
129
|
+
cleanEnding(2),
|
|
130
|
+
trailingTokenTurn(3, 'court'),
|
|
131
|
+
cleanEnding(4),
|
|
132
|
+
trailingTokenTurn(5, 'court'),
|
|
133
|
+
cleanEnding(6),
|
|
134
|
+
cleanEnding(7),
|
|
135
|
+
cleanEnding(8),
|
|
136
|
+
cleanEnding(9),
|
|
137
|
+
cleanEnding(10),
|
|
138
|
+
];
|
|
139
|
+
expect(detector.detectCrossTurnDegeneration(turns)).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('does not treat a bare URL or multi-word ending as a trailing token', () => {
|
|
143
|
+
const turns = [
|
|
144
|
+
'Done. See https://example.com/report',
|
|
145
|
+
'The work is complete',
|
|
146
|
+
'Result: 42',
|
|
147
|
+
'Done. See https://example.com/report',
|
|
148
|
+
];
|
|
149
|
+
expect(detector.detectCrossTurnDegeneration(turns)).toBeNull();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('only counts turns inside the cross-turn window', () => {
|
|
153
|
+
const withinWindow = Array.from({ length: 10 }, (_unused, index) =>
|
|
154
|
+
index < 3 ? trailingTokenTurn(index, 'court') : cleanEnding(index),
|
|
155
|
+
);
|
|
156
|
+
const beyondWindow = Array.from({ length: 5 }, (_unused, index) =>
|
|
157
|
+
trailingTokenTurn(100 + index, 'court'),
|
|
158
|
+
);
|
|
159
|
+
expect(
|
|
160
|
+
detector.detectCrossTurnDegeneration([
|
|
161
|
+
...withinWindow,
|
|
162
|
+
...beyondWindow,
|
|
163
|
+
]),
|
|
164
|
+
).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { CrossTurnDegeneration } from '../entities/OutputDegeneration';
|
|
2
|
+
|
|
3
|
+
export const OUTPUT_DEGENERATION_REPEAT_THRESHOLD = 5;
|
|
4
|
+
export const OUTPUT_DEGENERATION_DOMINATION_FRACTION = 0.8;
|
|
5
|
+
export const OUTPUT_DEGENERATION_MAX_TOKEN_LENGTH = 32;
|
|
6
|
+
export const OUTPUT_DEGENERATION_ABSOLUTE_RUN_THRESHOLD = 15;
|
|
7
|
+
export const OUTPUT_DEGENERATION_REP4_THRESHOLD = 0.3;
|
|
8
|
+
export const OUTPUT_DEGENERATION_REP4_MIN_TOKENS = 40;
|
|
9
|
+
export const OUTPUT_DEGENERATION_CROSS_TURN_WINDOW = 10;
|
|
10
|
+
export const OUTPUT_DEGENERATION_CROSS_TURN_MIN_TURNS = 4;
|
|
11
|
+
|
|
12
|
+
export type ShortTokenRun = {
|
|
13
|
+
token: string | null;
|
|
14
|
+
run: number;
|
|
15
|
+
total: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const LIST_ITEM_LINE_PATTERN = /^\s*(?:[-*+]\s|\d+[.)]\s)/;
|
|
19
|
+
const TRAILING_ALPHA_TOKEN_PATTERN = /^[A-Za-z]+$/;
|
|
20
|
+
|
|
21
|
+
const splitTokens = (text: string): string[] =>
|
|
22
|
+
text.split(/\s+/).filter((token) => token.length > 0);
|
|
23
|
+
|
|
24
|
+
const splitLines = (text: string): string[] => text.split(/\r\n|\r|\n/);
|
|
25
|
+
|
|
26
|
+
export class OutputDegenerationDetector {
|
|
27
|
+
constructor(
|
|
28
|
+
private readonly repeatThreshold: number = OUTPUT_DEGENERATION_REPEAT_THRESHOLD,
|
|
29
|
+
private readonly dominationFraction: number = OUTPUT_DEGENERATION_DOMINATION_FRACTION,
|
|
30
|
+
private readonly maxTokenLength: number = OUTPUT_DEGENERATION_MAX_TOKEN_LENGTH,
|
|
31
|
+
private readonly absoluteRunThreshold: number = OUTPUT_DEGENERATION_ABSOLUTE_RUN_THRESHOLD,
|
|
32
|
+
private readonly rep4Threshold: number = OUTPUT_DEGENERATION_REP4_THRESHOLD,
|
|
33
|
+
private readonly rep4MinTokens: number = OUTPUT_DEGENERATION_REP4_MIN_TOKENS,
|
|
34
|
+
private readonly crossTurnWindow: number = OUTPUT_DEGENERATION_CROSS_TURN_WINDOW,
|
|
35
|
+
private readonly crossTurnMinTurns: number = OUTPUT_DEGENERATION_CROSS_TURN_MIN_TURNS,
|
|
36
|
+
) {}
|
|
37
|
+
|
|
38
|
+
maxConsecutiveShortTokenRun = (text: string): ShortTokenRun => {
|
|
39
|
+
let bestToken: string | null = null;
|
|
40
|
+
let bestRun = 0;
|
|
41
|
+
let totalTokens = 0;
|
|
42
|
+
let currentToken: string | null = null;
|
|
43
|
+
let currentRun = 0;
|
|
44
|
+
for (const token of splitTokens(text)) {
|
|
45
|
+
totalTokens += 1;
|
|
46
|
+
if (token.length > this.maxTokenLength) {
|
|
47
|
+
currentToken = null;
|
|
48
|
+
currentRun = 0;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (token === currentToken) {
|
|
52
|
+
currentRun += 1;
|
|
53
|
+
} else {
|
|
54
|
+
currentToken = token;
|
|
55
|
+
currentRun = 1;
|
|
56
|
+
}
|
|
57
|
+
if (currentRun > bestRun) {
|
|
58
|
+
bestRun = currentRun;
|
|
59
|
+
bestToken = currentToken;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { token: bestToken, run: bestRun, total: totalTokens };
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
isIntraTurnDegeneration = (text: string): boolean => {
|
|
66
|
+
const { run, total } = this.maxConsecutiveShortTokenRun(text);
|
|
67
|
+
if (total === 0) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
if (run >= this.repeatThreshold && run >= this.dominationFraction * total) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
const filtered = this.stripRepetitionExemptSpans(text);
|
|
74
|
+
const filteredRun = this.maxConsecutiveShortTokenRun(filtered);
|
|
75
|
+
if (filteredRun.run >= this.absoluteRunThreshold) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
if (
|
|
79
|
+
filteredRun.total >= this.rep4MinTokens &&
|
|
80
|
+
this.duplicateNgramFraction(filtered, 4) >= this.rep4Threshold
|
|
81
|
+
) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
detectCrossTurnDegeneration = (
|
|
88
|
+
texts: string[],
|
|
89
|
+
): CrossTurnDegeneration | null => {
|
|
90
|
+
const counts = new Map<string, number>();
|
|
91
|
+
for (const text of texts.slice(0, this.crossTurnWindow)) {
|
|
92
|
+
const token = this.trailingSpuriousToken(text);
|
|
93
|
+
if (token === null) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
counts.set(token, (counts.get(token) ?? 0) + 1);
|
|
97
|
+
}
|
|
98
|
+
let bestToken: string | null = null;
|
|
99
|
+
let bestCount = 0;
|
|
100
|
+
for (const [token, count] of counts) {
|
|
101
|
+
if (count > bestCount) {
|
|
102
|
+
bestCount = count;
|
|
103
|
+
bestToken = token;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (bestToken !== null && bestCount >= this.crossTurnMinTurns) {
|
|
107
|
+
return { token: bestToken, turnCount: bestCount };
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
private stripRepetitionExemptSpans = (text: string): string => {
|
|
113
|
+
const keptLines: string[] = [];
|
|
114
|
+
let inFence = false;
|
|
115
|
+
for (const line of splitLines(text)) {
|
|
116
|
+
const stripped = line.trim();
|
|
117
|
+
if (stripped.startsWith('```') || stripped.startsWith('~~~')) {
|
|
118
|
+
inFence = !inFence;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (inFence) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (line.includes('|')) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (LIST_ITEM_LINE_PATTERN.test(line)) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
keptLines.push(line);
|
|
131
|
+
}
|
|
132
|
+
return keptLines.join('\n');
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
private duplicateNgramFraction = (text: string, n: number): number => {
|
|
136
|
+
const tokens = splitTokens(text).filter(
|
|
137
|
+
(token) => token.length <= this.maxTokenLength,
|
|
138
|
+
);
|
|
139
|
+
if (tokens.length < n + 1) {
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
const ngrams: string[] = [];
|
|
143
|
+
for (let index = 0; index <= tokens.length - n; index += 1) {
|
|
144
|
+
ngrams.push(tokens.slice(index, index + n).join('\u0000'));
|
|
145
|
+
}
|
|
146
|
+
const total = ngrams.length;
|
|
147
|
+
if (total < 2) {
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
const unique = new Set(ngrams).size;
|
|
151
|
+
return 1 - unique / total;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
private trailingSpuriousToken = (text: string): string | null => {
|
|
155
|
+
const stripped = text.replace(/\s+$/, '');
|
|
156
|
+
if (stripped.length === 0) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const lines = splitLines(stripped);
|
|
160
|
+
const lastLine = lines[lines.length - 1].trim();
|
|
161
|
+
if (lastLine.length === 0 || lastLine.length > this.maxTokenLength) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
if (!TRAILING_ALPHA_TOKEN_PATTERN.test(lastLine)) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
return lastLine;
|
|
168
|
+
};
|
|
169
|
+
}
|