intermediatearchitecture-patternsconvergenceterminationmetricsloop-engineering

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

PatternSignalConfidenceCostBest For
Exact (tests pass)All tests greenVery HighLowTasks with test suites
Exact (goal met)Specific goal achievedHighLowWell-defined tasks
ApproximatePass rate above thresholdMediumMediumLarge codebases
Stagnation (no progress)No change in N iterationsHighHigh (wasted)Any loop as safety net
Stagnation (oscillation)Repeating code patternsHighHigh (wasted)Complex bug fixes
Timeout (iterations)Hard limit reachedLow (forced)KnownAny loop as safety net
Timeout (time)Clock limit reachedLow (forced)KnownProduction 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