Loop Convergence Criteria
How to define and detect loop convergence in autonomous AI systems — termination conditions, success metrics, stagnation detection, and convergence patterns.
Loop Convergence Criteria
The convergence problem is the most fundamental question in loop engineering: when does a loop stop? Unlike traditional programs that terminate when their logic completes, an autonomous coding loop must decide whether it has achieved its goal, should keep trying, or should give up. Getting this wrong means either wasting resources on unnecessary iterations or stopping before the task is actually complete.
This guide covers the full spectrum of convergence in agentic loop systems — from exact goal satisfaction to forced termination, and everything in between.
The Convergence Problem
Every autonomous loop faces a trilemma: it must balance correctness (is the task done?), efficiency (how many resources have we spent?), and certainty (how sure are we that we cannot do better?).
┌───────────────────────────────────────────────────┐
│ THE CONVERGENCE TRILEMMA │
│ │
│ Correctness │
│ /\ │
│ / \ │
│ / \ │
│ / \ │
│ / OPTIMAL \ │
│ / ZONE \ │
│ / \ │
│ /──────────────\ │
│ / \ │
│ / Under-done \ Over-done │
│ / (stopped too \ (wasted │
│ / early) \ resources) │
│ /─────────────────────────────\ │
│ Efficiency │
│ │
│ Stop too early → incorrect result │
│ Stop too late → wasted tokens and time │
│ Never stop → runaway loop (infinite cost) │
└───────────────────────────────────────────────────┘
Types of Convergence
Exact Convergence
Exact convergence occurs when the loop's goal is fully and verifiably met. This is the ideal outcome — the task is complete, tests pass, and there is nothing left to fix.
interface ExactConvergenceResult {
type: 'exact';
reason: string;
evidence: string[]; // Test results, lint output, etc.
}
function checkExactConvergence(state: LoopState): ExactConvergenceResult | null {
// All tests pass
if (state.testResults.every(t => t.passed)) {
return {
type: 'exact',
reason: 'All tests pass',
evidence: state.testResults.map(t => `${t.name}: PASS`)
};
}
// Task-specific goal achieved
if (state.goal.target === 'function_added' && state.newFunctionExists) {
return {
type: 'exact',
reason: 'Target function was created',
evidence: [`Function ${state.goal.functionName} exists`]
};
}
return null;
}
Exact convergence is achievable when you have clear, measurable success criteria — typically a passing test suite. It is the strongest form of convergence and should be the target for most loop engineering tasks.
Approximate Convergence
Approximate convergence occurs when the loop has gotten "close enough" — the primary objectives are met but some secondary issues remain. This is the reality for many complex autonomous coding tasks where perfection may be unattainable or prohibitively expensive.
interface ApproximateConvergenceResult {
type: 'approximate';
reason: string;
score: number; // 0.0 to 1.0
threshold: number; // Minimum acceptable score
remainingIssues: string[];
}
function checkApproximateConvergence(
state: LoopState,
threshold: number = 0.85
): ApproximateConvergenceResult | null {
const totalTests = state.testResults.length;
const passedTests = state.testResults.filter(t => t.passed).length;
const score = totalTests > 0 ? passedTests / totalTests : 0;
// Most tests pass (but not all)
if (score >= threshold && score < 1.0) {
const failing = state.testResults.filter(t => !t.passed);
return {
type: 'approximate',
reason: `${(score * 100).toFixed(0)}% of tests pass (threshold: ${threshold * 100}%)`,
score,
threshold,
remainingIssues: failing.map(t => t.name)
};
}
return null;
}
When to use approximate convergence:
- Large codebases where fixing all tests in one loop is impractical
- Tasks where the LLM has limited context about edge cases
- Cost-sensitive environments where 95% correct at low cost is better than 100% at high cost
- Tasks with subjective quality criteria (code style, naming, documentation)
Stagnation Convergence
Stagnation convergence is a forced termination — the loop has stopped making progress and continuing would waste resources. This is a defensive mechanism, not a success indicator.
interface StagnationConvergenceResult {
type: 'stagnation';
reason: string;
stagnationType: 'no_progress' | 'oscillation' | 'divergence';
lastProgressIteration: number;
}
function checkStagnation(state: LoopState): StagnationConvergenceResult | null {
// No progress in last N iterations
const noProgressWindow = 8;
const recentIterations = state.history.slice(-noProgressWindow);
const hasProgress = recentIterations.some(i => i.madeProgress);
if (!hasProgress && state.iterationCount >= noProgressWindow) {
return {
type: 'stagnation',
reason: `No progress in last ${noProgressWindow} iterations`,
stagnationType: 'no_progress',
lastProgressIteration: findLastProgressIteration(state)
};
}
// Oscillation detection
if (detectOscillation(state.history)) {
return {
type: 'stagnation',
reason: 'Loop is oscillating between solutions',
stagnationType: 'oscillation',
lastProgressIteration: findLastProgressIteration(state)
};
}
return null;
}
Timeout Convergence
Timeout convergence is an absolute safety net — the loop is forcibly terminated when a hard time or iteration limit is reached.
interface TimeoutConvergenceResult {
type: 'timeout';
reason: string;
limitType: 'max_iterations' | 'max_time' | 'max_tokens' | 'max_budget';
limitReached: number;
limitSet: number;
}
function checkTimeout(state: LoopState, limits: LoopLimits): TimeoutConvergenceResult | null {
if (state.iterationCount >= limits.maxIterations) {
return {
type: 'timeout',
reason: `Reached max iterations: ${limits.maxIterations}`,
limitType: 'max_iterations',
limitReached: state.iterationCount,
limitSet: limits.maxIterations
};
}
if (Date.now() - state.startTime > limits.maxTimeMs) {
return {
type: 'timeout',
reason: `Exceeded time limit: ${limits.maxTimeMs}ms`,
limitType: 'max_time',
limitReached: Date.now() - state.startTime,
limitSet: limits.maxTimeMs
};
}
if (state.tokensUsed > limits.maxTokens) {
return {
type: 'timeout',
reason: `Exceeded token budget: ${limits.maxTokens}`,
limitType: 'max_tokens',
limitReached: state.tokensUsed,
limitSet: limits.maxTokens
};
}
return null;
}
Defining Convergence Criteria
Test Pass Rate Thresholds
The most common convergence criterion for code-modification loops.
interface TestConvergenceCriteria {
// Exact convergence: all tests must pass
exactThreshold: number; // 1.0 (100%)
// Approximate convergence: acceptable minimum
approximateThreshold: number; // 0.80 (80%)
// Allowlist: tests that may fail
allowedFailures: string[]; // Known flaky tests, etc.
// Blocklist: tests that MUST pass
requiredTests: string[]; // Critical path tests
}
function evaluateTestConvergence(
results: TestResult[],
criteria: TestConvergenceCriteria
): ConvergenceResult {
const allowedFailures = results.filter(
r => !r.passed && criteria.allowedFailures.includes(r.name)
);
const unexpectedFailures = results.filter(
r => !r.passed && !criteria.allowedFailures.includes(r.name)
);
const requiredPassed = criteria.requiredTests.every(
name => results.find(r => r.name === name)?.passed
);
// Exact: everything passes (minus allowed failures)
if (unexpectedFailures.length === 0 && requiredPassed) {
return { type: 'exact', score: 1.0 };
}
// Approximate: critical tests pass, some non-critical fail
if (requiredPassed) {
const total = results.length - allowedFailures.length;
const passed = total - unexpectedFailures.length;
return {
type: 'approximate',
score: total > 0 ? passed / total : 0
};
}
return { type: 'incomplete', score: 0 };
}
Error Count Reduction
For tasks without a full test suite, track error reduction across iterations.
function trackErrorReduction(history: IterationHistory[]): ConvergenceAnalysis {
const errorCounts = history.map(h => h.errorCount);
// Calculate trend
const trend = calculateTrend(errorCounts);
// Check for convergence: errors reach zero
const lastCount = errorCounts[errorCounts.length - 1];
if (lastCount === 0) {
return {
converged: true,
type: 'exact',
iterations: history.length,
trend
};
}
// Check for approximate: significant reduction
const reduction = 1 - (lastCount / (errorCounts[0] || 1));
if (reduction >= 0.8) {
return {
converged: true,
type: 'approximate',
score: reduction,
iterations: history.length,
trend
};
}
// Check for stagnation: no reduction in recent iterations
const recent = errorCounts.slice(-5);
const isFlat = recent.every(c => c === recent[0]);
if (isFlat && lastCount > 0) {
return {
converged: true,
type: 'stagnation',
iterations: history.length,
trend
};
}
return { converged: false, trend };
}
Semantic Similarity Checks
When you cannot run tests, use semantic similarity to detect whether the loop is actually making changes.
async function detectSemanticStagnation(
currentCode: string,
previousCode: string,
threshold: number = 0.95
): Promise<boolean> {
// Use embedding similarity to detect if changes are meaningful
const currentEmbedding = await embed(currentCode);
const previousEmbedding = await embed(previousCode);
const similarity = cosineSimilarity(currentEmbedding, previousEmbedding);
// If code is >95% similar, the loop is not making meaningful changes
return similarity > threshold;
}
Maximum Iteration Limits
Hard iteration limits are the simplest and most reliable convergence criterion.
interface IterationLimits {
softLimit: number; // Suggest stopping
hardLimit: number; // Force stop
warnAt: number; // Warn about potential non-convergence
}
function checkIterationLimit(
current: number,
limits: IterationLimits
): 'ok' | 'warn' | 'suggest_stop' | 'force_stop' {
if (current >= limits.hardLimit) return 'force_stop';
if (current >= limits.softLimit) return 'suggest_stop';
if (current >= limits.warnAt) return 'warn';
return 'ok';
}
// Typical limits for different task types
const defaultLimits: Record<string, IterationLimits> = {
simple_fix: { warnAt: 3, softLimit: 8, hardLimit: 15 },
feature_add: { warnAt: 5, softLimit: 15, hardLimit: 30 },
refactoring: { warnAt: 8, softLimit: 20, hardLimit: 50 },
multi_file_fix: { warnAt: 10, softLimit: 25, hardLimit: 50 }
};
Stagnation Detection
Stagnation is the most dangerous convergence failure mode — the loop keeps running but stops making progress.
No Progress Detection
function detectNoProgress(
history: IterationHistory[],
windowSize: number = 5
): boolean {
if (history.length < windowSize) return false;
const recent = history.slice(-windowSize);
// No new errors fixed
const errorsAtStart = new Set(recent[0].errors);
const errorsAtEnd = new Set(recent[recent.length - 1].errors);
const errorsFixed = errorsAtStart.size - [...errorsAtEnd]
.filter(e => errorsAtStart.has(e)).length;
// No new code changes
const fileChanges = recent.flatMap(i => i.filesChanged);
const uniqueChanges = new Set(fileChanges);
// No test improvement
const testScores = recent.map(i => i.testPassRate);
const testImproved = testScores[testScores.length - 1] > testScores[0];
return errorsFixed === 0 && uniqueChanges.size <= 1 && !testImproved;
}
Oscillation Detection
Oscillation occurs when the loop flip-flops between two or more solutions without settling.
function detectOscillation(
history: IterationHistory[],
minCycles: number = 2
): boolean {
if (history.length < 4) return false;
// Compare code snapshots for repeating patterns
const recentSnapshots = history
.slice(-6)
.map(h => hashContent(h.codeSnapshot));
// Check for ABAB pattern (alternating between two solutions)
for (let cycleLength = 2; cycleLength <= 4; cycleLength++) {
let cycles = 0;
for (let i = cycleLength; i < recentSnapshots.length; i++) {
if (recentSnapshots[i] === recentSnapshots[i - cycleLength]) {
cycles++;
}
}
if (cycles >= minCycles) {
return true;
}
}
// Check for error pattern oscillation
const recentErrors = history.slice(-6).map(h => h.errorSignature);
for (let cycleLength = 2; cycleLength <= 3; cycleLength++) {
let cycles = 0;
for (let i = cycleLength; i < recentErrors.length; i++) {
if (recentErrors[i] === recentErrors[i - cycleLength]) {
cycles++;
}
}
if (cycles >= minCycles) {
return true;
}
}
return false;
}
// Simple content hash for comparison
function hashContent(content: string): string {
// Use a rolling hash or truncated SHA for speed
let hash = 0;
for (let i = 0; i < content.length; i++) {
const char = content.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return hash.toString(36);
}
Context Overflow Detection
function checkContextOverflow(state: LoopState): boolean {
const estimatedTokens = estimateTokens(state.accumulatedContext);
const maxTokens = state.modelContextWindow;
// Warn at 80%, error at 95%
if (estimatedTokens > maxTokens * 0.95) {
return true; // Context overflow imminent
}
return false;
}
function estimateTokens(text: string): number {
// Rough estimate: 1 token ≈ 4 characters for English/code
return Math.ceil(text.length / 4);
}
Convergence Acceleration Strategies
When a loop is converging slowly, these strategies can speed things up.
Strategy 1: Focused Context
Feed only the relevant error context to the LLM, not the entire conversation history.
function buildFocusedContext(state: LoopState): string {
return [
state.task,
`Current errors: ${state.currentErrors.join('; ')}`,
state.lastCodeChange,
`Previous fix attempt: ${state.lastFixSummary}`
].filter(Boolean).join('\n\n');
}
Strategy 2: Incremental Goals
Break a large task into smaller convergence goals that the loop can achieve sequentially.
async function incrementalConvergence(
task: ComplexTask,
subGoals: string[]
): Promise<LoopResult> {
let context = task.description;
for (const goal of subGoals) {
const result = await loop.run(
`${context}\n\nCurrent sub-goal: ${goal}`,
{ maxIterations: 10 }
);
if (result.success) {
context += `\n\nCompleted: ${goal}`;
} else {
return { ...result, failedAt: goal };
}
}
return { success: true };
}
Strategy 3: Early Verification
Run partial verifications before the full test suite to catch obvious issues faster.
async function fastVerify(code: string): Promise<VerifyResult> {
// Stage 1: Syntax check (< 100ms)
const syntaxResult = await checkSyntax(code);
if (!syntaxResult.valid) {
return { passed: false, errors: syntaxResult.errors };
}
// Stage 2: Type check (< 2s)
const typeResult = await checkTypes(code);
if (!typeResult.valid) {
return { passed: false, errors: typeResult.errors };
}
// Stage 3: Full test suite (> 10s)
const testResult = await runTests();
return testResult;
}
Convergence Flow Diagram
┌───────────────────────────────────────────────────────────┐
│ CONVERGENCE CHECK │
│ │
│ Loop iteration complete │
│ │ │
│ ▼ │
│ ┌────────────────────┐ │
│ │ EXACT CONVERGENCE? │──── YES ───▶ LOOP COMPLETE │
│ │ (all tests pass, │ (success) │
│ │ goal fully met) │ │
│ └────────┬───────────┘ │
│ │ NO │
│ ▼ │
│ ┌────────────────────┐ │
│ │ APPROXIMATE? │──── YES ───▶ LOOP COMPLETE │
│ │ (85%+ tests pass, │ (acceptable) │
│ │ critical pass) │ │
│ └────────┬───────────┘ │
│ │ NO │
│ ▼ │
│ ┌────────────────────┐ │
│ │ STAGNATION? │──── YES ───▶ LOOP COMPLETE │
│ │ (no progress, │ (stagnated) │
│ │ oscillation) │ │
│ └────────┬───────────┘ │
│ │ NO │
│ ▼ │
│ ┌────────────────────┐ │
│ │ TIMEOUT? │──── YES ───▶ LOOP COMPLETE │
│ │ (max iter, time, │ (timed out) │
│ │ token budget) │ │
│ └────────┬───────────┘ │
│ │ NO │
│ ▼ │
│ CONTINUE LOOP │
└───────────────────────────────────────────────────────────┘
Convergence Patterns Comparison
| Pattern | Signal | Confidence | Cost | Best For |
|---|---|---|---|---|
| Exact (tests pass) | All tests green | Very High | Low | Tasks with test suites |
| Exact (goal met) | Specific goal achieved | High | Low | Well-defined tasks |
| Approximate | Pass rate above threshold | Medium | Medium | Large codebases |
| Stagnation (no progress) | No change in N iterations | High | High (wasted) | Any loop as safety net |
| Stagnation (oscillation) | Repeating code patterns | High | High (wasted) | Complex bug fixes |
| Timeout (iterations) | Hard limit reached | Low (forced) | Known | Any loop as safety net |
| Timeout (time) | Clock limit reached | Low (forced) | Known | Production systems |
Practical Examples
Example 1: Simple Bug Fix
Task: Fix the off-by-one error in pagination.ts
Iteration 1: Generated fix → 3 tests fail (was 5 failing)
Iteration 2: Refined fix → 1 test fails
Iteration 3: Final fix → All 10 tests pass
→ EXACT CONVERGENCE (iteration 3)
Example 2: Feature Addition
Task: Add search functionality to the user list component
Iteration 1: Created search component → 5 errors (imports, types)
Iteration 2: Fixed imports → 2 errors (missing props)
Iteration 3: Fixed props → 12/15 tests pass (3 edge case failures)
Iteration 4: Fixed 2 edge cases → 14/15 tests pass
Iteration 5: Attempted last fix → still 14/15 (flaky test)
Iteration 6-8: Oscillation on flaky test
→ STAGNATION CONVERGENCE (oscillation at iteration 8)
→ APPROXIMATE CONVERGENCE with score 0.93
Example 3: Multi-File Refactoring
Task: Migrate from REST to GraphQL
Iteration 1-5: Migrated 3/8 files, tests: 60% passing
Iteration 6-10: Migrated 6/8 files, tests: 75% passing
Iteration 11-15: Migrated 8/8 files, tests: 88% passing
Iteration 16-20: No further improvement (complex edge cases)
→ APPROXIMATE CONVERGENCE (iteration 20, score 0.88)
Code Example: Complete Convergence Detector
class ConvergenceDetector {
private errorHistory: number[] = [];
private codeHashHistory: string[] = [];
private testScoreHistory: number[] = [];
constructor(private options: {
exactThreshold?: number;
approxThreshold?: number;
stagnationWindow?: number;
maxIterations?: number;
} = {}) {}
check(state: LoopState): ConvergenceResult {
// Update tracking
this.errorHistory.push(state.errorCount);
this.codeHashHistory.push(hashContent(state.code));
this.testScoreHistory.push(state.testPassRate ?? 0);
// Check in priority order
return (
this.checkExact(state) ||
this.checkApproximate() ||
this.checkStagnation() ||
this.checkTimeout(state) ||
{ converged: false }
);
}
private checkExact(state: LoopState): ConvergenceResult | null {
if (state.allTestsPass) {
return { converged: true, type: 'exact' };
}
return null;
}
private checkApproximate(): ConvergenceResult | null {
const score = this.testScoreHistory[this.testScoreHistory.length - 1];
const threshold = this.options.approxThreshold ?? 0.85;
if (score >= threshold && score < 1.0) {
return { converged: true, type: 'approximate', score };
}
return null;
}
private checkStagnation(): ConvergenceResult | null {
const window = this.options.stagnationWindow ?? 5;
if (this.errorHistory.length < window) return null;
if (detectNoProgress(this.errorHistory, window)) {
return { converged: true, type: 'stagnation' };
}
if (detectOscillation(this.codeHashHistory)) {
return { converged: true, type: 'stagnation', subtype: 'oscillation' };
}
return null;
}
private checkTimeout(state: LoopState): ConvergenceResult | null {
const max = this.options.maxIterations ?? 50;
if (state.iterationCount >= max) {
return { converged: true, type: 'timeout' };
}
return null;
}
}
Key Takeaways
- Convergence is the core challenge of loop engineering — define clear success criteria before the loop starts running
- Four convergence types cover the full spectrum: exact (goal fully met), approximate (good enough), stagnation (no progress), and timeout (forced stop)
- Test pass rates are the most reliable convergence signal — aim for exact convergence when a test suite is available
- Stagnation detection requires multiple strategies: no-progress windows, oscillation detection via code hashing, and context overflow monitoring
- Oscillation is insidious — the loop appears active (code is changing) but makes no net progress; detect it by comparing code snapshots across iterations
- Always set hard limits for iterations, time, and token budget — these are your ultimate safety net against runaway loops
- Approximate convergence is pragmatic — in large codebases, 85% test pass rate with all critical tests passing is often the best achievable outcome
- Convergence acceleration strategies like focused context, incremental goals, and early verification (syntax, then types, then tests) can significantly reduce the number of iterations needed