132 lines
3.9 KiB
TypeScript
132 lines
3.9 KiB
TypeScript
/**
|
|
* Cycle detection for loop monitors.
|
|
*
|
|
* Tracks movement execution history and detects when a specific cycle
|
|
* of movements has been repeated a configured number of times (threshold).
|
|
*
|
|
* Example:
|
|
* cycle: [ai_review, ai_fix], threshold: 3
|
|
* History: ai_review → ai_fix → ai_review → ai_fix → ai_review → ai_fix
|
|
* ↑
|
|
* 3 cycles → trigger
|
|
*/
|
|
|
|
import type { LoopMonitorConfig } from '../../models/types.js';
|
|
|
|
/** Result of checking a single loop monitor */
|
|
export interface CycleCheckResult {
|
|
/** Whether the threshold has been reached */
|
|
triggered: boolean;
|
|
/** Current number of completed cycles */
|
|
cycleCount: number;
|
|
/** The loop monitor config that was triggered (if triggered) */
|
|
monitor?: LoopMonitorConfig;
|
|
}
|
|
|
|
/**
|
|
* Tracks movement execution history and detects cyclic patterns
|
|
* as defined by loop_monitors configuration.
|
|
*/
|
|
export class CycleDetector {
|
|
/** Movement execution history (names in order) */
|
|
private history: string[] = [];
|
|
private monitors: LoopMonitorConfig[];
|
|
|
|
constructor(monitors: LoopMonitorConfig[] = []) {
|
|
this.monitors = monitors;
|
|
}
|
|
|
|
/**
|
|
* Record a movement completion and check if any cycle threshold is reached.
|
|
*
|
|
* The detection logic works as follows:
|
|
* 1. The movement name is appended to the history
|
|
* 2. For each monitor, we check if the cycle pattern has been completed
|
|
* by looking at the tail of the history
|
|
* 3. A cycle is "completed" when the last N entries in history match
|
|
* the cycle pattern repeated `threshold` times
|
|
*
|
|
* @param movementName The name of the movement that just completed
|
|
* @returns CycleCheckResult indicating if any monitor was triggered
|
|
*/
|
|
recordAndCheck(movementName: string): CycleCheckResult {
|
|
this.history.push(movementName);
|
|
|
|
for (const monitor of this.monitors) {
|
|
const result = this.checkMonitor(monitor);
|
|
if (result.triggered) {
|
|
return result;
|
|
}
|
|
}
|
|
|
|
return { triggered: false, cycleCount: 0 };
|
|
}
|
|
|
|
/**
|
|
* Check a single monitor against the current history.
|
|
*
|
|
* A cycle is detected when the last element of the history matches the
|
|
* last element of the cycle, and looking backwards we can find exactly
|
|
* `threshold` complete cycles.
|
|
*/
|
|
private checkMonitor(monitor: LoopMonitorConfig): CycleCheckResult {
|
|
const { cycle, threshold } = monitor;
|
|
const cycleLen = cycle.length;
|
|
|
|
// The cycle's last step must match the most recent movement
|
|
const lastStep = cycle[cycleLen - 1];
|
|
if (this.history[this.history.length - 1] !== lastStep) {
|
|
return { triggered: false, cycleCount: 0 };
|
|
}
|
|
|
|
// Need at least threshold * cycleLen entries to check
|
|
const requiredLen = threshold * cycleLen;
|
|
if (this.history.length < requiredLen) {
|
|
return { triggered: false, cycleCount: 0 };
|
|
}
|
|
|
|
// Count complete cycles from the end of history backwards
|
|
let cycleCount = 0;
|
|
let pos = this.history.length;
|
|
|
|
while (pos >= cycleLen) {
|
|
// Check if the last cycleLen entries match the cycle pattern
|
|
let matches = true;
|
|
for (let i = 0; i < cycleLen; i++) {
|
|
if (this.history[pos - cycleLen + i] !== cycle[i]) {
|
|
matches = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (matches) {
|
|
cycleCount++;
|
|
pos -= cycleLen;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (cycleCount >= threshold) {
|
|
return { triggered: true, cycleCount, monitor };
|
|
}
|
|
|
|
return { triggered: false, cycleCount };
|
|
}
|
|
|
|
/**
|
|
* Reset the history after a judge intervention.
|
|
* This prevents the same cycle from immediately triggering again.
|
|
*/
|
|
reset(): void {
|
|
this.history = [];
|
|
}
|
|
|
|
/**
|
|
* Get the current movement history (for debugging/testing).
|
|
*/
|
|
getHistory(): readonly string[] {
|
|
return this.history;
|
|
}
|
|
}
|