# Interface Contracts: LLM Module

**Consolidated:** 2026-03-14 (Phase 12)

**Purpose:** Single-source reference for LLM module behavior. Tests verify these specifications, not implementation details.

---

## Table of Contents

1. [LLM Core](#1-llm-core) — Exceptions, retry, context
2. [LLM Structured Output](#2-llm-structured-output) — JSON parsing, TruncationResult
3. [LLM Providers](#3-llm-providers) — Factory, retry delays, token budgets
4. [LLM Briefing](#4-llm-briefing) — Briefing generation, schemas
5. [LLM Missions](#5-llm-missions) — Mission pipeline, validation
6. [Key Invariants Summary](#key-invariants-summary)

---

## 1. LLM Core

**Modules:** `llm/exceptions.py`, `llm/retry.py`, `llm/context.py`

### Exception Hierarchy

#### LLMError

```python
class LLMError(Exception):
    """Base exception for all LLM-related errors."""
```

**Purpose:** Base class for all LLM errors — catch this to handle any LLM failure.

**Invariants:**
- All LLM-specific exceptions inherit from this class
- Message is always human-readable

**Valid usage:**
- `except LLMError as e:` catches all LLM errors
- `raise LLMError("message")` for generic LLM failures

---

#### ProviderError(LLMError)

```python
class ProviderError(LLMError):
    """Raised when a provider cannot be initialized or is unknown."""
```

**Purpose:** Indicates configuration or initialization problems with an LLM provider.

**Invariants:**
- Only raised during `create_chat_model()` setup
- Never raised during LLM invocation (that's AuthenticationError)

**Error conditions:**
- Unknown provider → `ProviderError: Unknown provider: 'xyz'. Supported providers: anthropic, glm`
- Missing API key → `ProviderError: No API key found for provider 'anthropic'. Set the appropriate environment variable...`
- Missing package → `ProviderError: Failed to initialize glm provider. Ensure the required package is installed: ...`

---

#### AuthenticationError(LLMError)

```python
class AuthenticationError(LLMError):
    """Raised when the LLM provider returns a 401 or 403 error."""
```

**Purpose:** Indicates invalid or expired API key — user must fix configuration.

**Invariants:**
- Never retried (immediate failure)
- Always indicates user action required (fix API key)

**Error conditions:**
- HTTP 401 response → `AuthenticationError: LLM API authentication failed. Check your API key configuration.`
- HTTP 403 response → Same message
- "unauthorized" in error message → Same message
- "invalid api key" in error message → Same message

---

#### BriefingGenerationError(LLMError)

```python
class BriefingGenerationError(LLMError):
    def __init__(self, message: str, attempts: int = 1) -> None:
        self.attempts = attempts
        super().__init__(f"{message} (after {attempts} attempt(s))")
```

**Purpose:** Briefing generation failed after all retries.

**Invariants:**
- `attempts` attribute always set
- Message includes attempt count

**Error conditions:**
- Max retries exhausted → `BriefingGenerationError: Failed to generate briefing: <original error> (after 5 attempt(s))`
- Empty LLM response → `BriefingGenerationError: LLM returned empty response (after 3 attempt(s))`

---

#### MissionGenerationError(LLMError)

```python
class MissionGenerationError(LLMError):
    def __init__(
        self, message: str, category: str = "", attempts: int = 1
    ) -> None:
        self.category = category
        self.attempts = attempts
```

**Purpose:** Mission generation failed for a specific category.

**Invariants:**
- `category` attribute contains category name (may be empty string)
- `attempts` attribute always set
- Message format includes category if provided

**Error conditions:**
- Max retries exhausted → `MissionGenerationError: Failed to generate missions: <error> (category 'architecture', after 5 attempt(s))`
- Empty missions returned → `MissionGenerationError: LLM returned no missions for this category (category 'testing', after 1 attempt(s))`

---

#### TokenBudgetExceededError(LLMError)

```python
class TokenBudgetExceededError(LLMError):
    """Raised when the context exceeds the configured token budget."""
```

**Purpose:** Context too large for provider (future-proofing, currently unused).

**Invariants:**
- Currently never raised (context builder truncates gracefully instead)
- Reserved for strict budget enforcement mode

---

### Retry Utilities

#### with_llm_retry

```python
def with_llm_retry(
    fn: Callable[..., T],
    *args: object,
    max_attempts: int = 5,
    base_delay: float | None = None,
    provider_name: str | None = None,
    **kwargs: object,
) -> T
```

**Purpose:** Execute a function with exponential backoff retry on transient LLM errors.

**Invariants:**
- Always returns the result of `fn(*args, **kwargs)` on success
- AuthenticationError raised immediately (never retried)
- Transient errors always retry up to `max_attempts`
- Delay formula: `base_delay * 2^(attempt-1) + random(0, 1)`

**Valid inputs:**
- `fn`: Any callable (typically a lambda that invokes an LLM chain)
- `max_attempts`: 1-10 (1 = no retries, default 5)
- `base_delay`: Positive float (overrides provider_name if both given)
- `provider_name`: "anthropic" → 1.0s, "glm" → 30.0s, None/unknown → 1.0s

**Error conditions:**
- HTTP 401/403 → `AuthenticationError` (immediate, no retry)
- HTTP 429/5xx → Retried, then original exception after max_attempts
- Connection errors → Retried, then original exception
- "rate limit" in message → Retried
- Schema validation error → Retried (LLM may succeed on retry)
- Non-transient, non-auth error → Immediate re-raise

**Edge cases:**
- `max_attempts=1` → Executes once, no retry on failure
- Both `base_delay` and `provider_name` given → Uses `base_delay`
- `fn` raises on first call, succeeds on retry → Returns successful result
- All attempts fail → Raises the last exception

**Delay sequence for base_delay=1.0, max_attempts=5:**
| Failure | Wait |
|---------|------|
| Attempt 1 | ~1s |
| Attempt 2 | ~2s |
| Attempt 3 | ~4s |
| Attempt 4 | ~8s |
| Attempt 5 | raise |

**Delay sequence for base_delay=30.0 (GLM):**
| Failure | Wait |
|---------|------|
| Attempt 1 | ~30s |
| Attempt 2 | ~60s |
| Attempt 3 | ~120s |
| Attempt 4 | ~240s |
| Attempt 5 | raise |

---

### Context Builder

#### build_briefing_context

```python
def build_briefing_context(
    analysis: AnalysisResult,
    token_budget: int = DEFAULT_TOKEN_BUDGET,
) -> str
```

**Purpose:** Transform static analysis results into a structured text context for LLM consumption.

**Invariants:**
- Return string length ≤ `token_budget * 4` characters (CHARS_PER_TOKEN)
- Always includes repository overview section
- Global truncation applied if total exceeds budget
- Files > 10,000 chars are truncated individually

**Valid inputs:**
- `analysis`: Complete AnalysisResult from `analyze_repository()`
- `token_budget`: Positive integer (default 50,000)

**Return structure:**
1. `# Repository Overview` — always present
2. `# Entry Points` — if entry_points non-empty
3. `# Module Structure` — if modules non-empty
4. `# Import Graph Summary` — always present
5. `# Test Coverage Summary` — always present
6. `# Security Findings` — if secrets or vulnerabilities exist
7. `# Key File Contents` — budget-aware, highest priority files first
8. `# Other Files (Signatures)` — remaining files

**Edge cases:**
- Empty analysis (no files) → Returns minimal overview
- No entry points → Section omitted
- No modules → Section omitted
- No security findings → Section omitted
- Very large codebase → Truncated with `... [context truncated to fit token budget]`
- Single file > MAX_FILE_CHARS → File truncated with `... [file truncated]`

---

### Internal Constants

| Constant | Value | Purpose |
|----------|-------|---------|
| `CHARS_PER_TOKEN` | `4` | Approximate characters per token |
| `MAX_FILE_CHARS` | `10_000` | Max chars per file (~2,500 tokens) |

---

## 2. LLM Structured Output

**Module:** `llm/structured.py`

### TruncationResult (Dataclass)

```python
@dataclass
class TruncationResult:
    recovered_data: BaseModel | None
    is_truncated: bool
    last_complete_index: int | None
    resume_hint: str | None
```

**Purpose:** Encapsulate results of parsing LLM response with truncation detection.

**Fields:**

| Field | Type | Description |
|-------|------|-------------|
| `recovered_data` | `BaseModel \| None` | Parsed Pydantic model if any complete data recovered |
| `is_truncated` | `bool` | True if response was incomplete/truncated |
| `last_complete_index` | `int \| None` | 0-based index of last complete item in array |
| `resume_hint` | `str \| None` | Human-readable description for continuation prompt |

**Invariants:**
- `is_truncated=False` implies `recovered_data` is valid (not None)
- `is_truncated=True` and `recovered_data` not None → partial recovery succeeded
- `last_complete_index` only set when array field exists in schema

**Valid states:**
| State | recovered_data | is_truncated | last_complete_index | resume_hint |
|-------|----------------|--------------|---------------------|-------------|
| Complete response | valid model | `False` | `None` | `None` |
| Recovered partial | valid model | `True` | N (≥0) | hint string |
| Nothing recovered | `None` | `True` | `None` | error hint |
| Empty input | `None` | `False` | `None` | `None` |

---

### detect_truncation

```python
def detect_truncation(
    text: str,
    schema: type[T],
) -> TruncationResult
```

**Purpose:** Parse LLM response and detect if JSON was truncated, recovering partial data if possible.

**Invariants:**
- **Never raises an exception** — always returns TruncationResult
- If complete JSON parses successfully → `is_truncated=False`
- Recovery attempts only for mission-style schemas (has "missions" field)

**Valid inputs:**
- `text`: Any string (including empty, None-like)
- `schema`: Pydantic model class with optional array field

**Edge cases:**
| Input | Result |
|-------|--------|
| Empty text | `TruncationResult(None, False, None, None)` |
| Whitespace only | `TruncationResult(None, False, None, None)` |
| Complete JSON | `TruncationResult(parsed, False, None, None)` |
| JSON in markdown fence | Extracted and parsed |
| Truncated mid-mission | `TruncationResult(partial, True, last_idx, hint)` |
| Truncated mid-field | Attempts recovery, may return `(None, True, ...)` |
| No JSON at all | `TruncationResult(None, True, None, "No JSON object found")` |
| Invalid JSON structure | `TruncationResult(None, True, None, "Could not recover...")` |

**Recovery behavior:**
1. Find `"missions"` array start
2. Track brace depth to find complete objects
3. Truncate to last complete mission object
4. Close JSON structure: `], "chains": []}`
5. Validate against schema

**Resume hint format:**
- With title: `"Continue generating missions after 'Mission Title'. Generate additional missions for this category."`
- Without title: `"Continue generating missions. N missions were already generated."`

---

### create_structured_chain

```python
def create_structured_chain(
    model: BaseChatModel,
    prompt: ChatPromptTemplate,
    schema: type[T],
) -> Runnable
```

**Purpose:** Build a LangChain chain that returns validated Pydantic model instances.

**Invariants:**
- Return value's `.invoke(input_data)` always returns instance of `schema`
- Provider-agnostic: works with any BaseChatModel

**Provider behavior:**

| Provider | Method |
|----------|--------|
| Anthropic | `model.with_structured_output(schema)` |
| OpenAI | `model.with_structured_output(schema)` |
| GLM (ChatZhipuAI) | Prompt-based JSON + manual parsing |

**Valid inputs:**
- `model`: Any LangChain BaseChatModel instance
- `prompt`: ChatPromptTemplate with system and human messages
- `schema`: Pydantic model class (BaseModel subclass)

**Error conditions:**
- JSON parse failure → `ValueError: Could not extract JSON from LLM response. First 500 chars: ...`
- Schema validation failure → `ValueError: LLM response did not match expected schema SchemaName: <validation errors>`

---

### Internal Functions

#### _extract_json

```python
def _extract_json(text: str) -> str
```

**Purpose:** Extract a JSON object from LLM text output.

**Invariants:**
- Returns only the JSON portion (no surrounding text)
- Handles markdown code fences
- Attempts recovery for truncated JSON

**Error conditions:**
- No JSON found → `ValueError: Could not extract JSON from LLM response. First 500 chars: ...`

**Edge cases:**
| Input | Output |
|-------|--------|
| Pure JSON object | Unchanged |
| JSON in \`\`\`json fence | Content extracted |
| JSON in \`\`\` fence (no lang) | Content extracted |
| Unclosed fence (truncated) | Content extracted, recovery attempted |
| JSON embedded in text | Object extracted by brace matching |
| Truncated JSON | Recovery attempted |

#### _recover_truncated_json

```python
def _recover_truncated_json(text: str, start_pos: int) -> str | None
```

**Purpose:** Attempt to recover usable JSON from truncated response.

**Invariants:**
- Only attempts recovery for mission-style schemas (`"missions"` in text)
- Returns valid JSON string or None

---

### Provider Detection

| Constant | Value | Purpose |
|----------|-------|---------|
| `_NO_TOOL_CALLING_MODELS` | `{"ChatZhipuAI"}` | Models without reliable `with_structured_output()` |

```python
def _supports_tool_calling(model: BaseChatModel) -> bool
```

**Returns:**
- `False` for ChatZhipuAI
- `True` for Anthropic, OpenAI, and most other models
- `True` for unknown model classes (assume tool calling works)

---

## 3. LLM Providers

**Module:** `llm/providers/factory.py`

### ProviderConfig (Dataclass)

```python
@dataclass(frozen=True)
class ProviderConfig:
    langchain_id: str
    default_model: str
    max_token_budget: int | None = None
    retry_base_delay: float = 1.0
```

**Purpose:** Configuration for a single LLM provider.

**Fields:**

| Field | Type | Description |
|-------|------|-------------|
| `langchain_id` | `str` | Provider identifier LangChain uses internally |
| `default_model` | `str` | Default model name if none specified |
| `max_token_budget` | `int \| None` | Maximum context tokens (None = unlimited) |
| `retry_base_delay` | `float` | Base delay seconds for exponential backoff |

**Predefined configurations:**

| Provider | langchain_id | default_model | max_token_budget | retry_base_delay |
|----------|--------------|---------------|------------------|------------------|
| anthropic | "anthropic" | "claude-sonnet-4-5-2025-929" | None | 1.0 |
| glm | "zhipuai" | "glm-4" | 8,000 | 30.0 |

**Invariants:**
- `frozen=True` — immutable after creation
- `retry_base_delay` always positive
- Free-tier providers (GLM) have stricter `max_token_budget`

---

### create_chat_model

```python
def create_chat_model(
    config: UnfamiliarConfig,
    max_tokens: int | None = None,
) -> BaseChatModel
```

**Purpose:** Create a configured LangChain chat model from user configuration.

**Invariants:**
- Returns fully initialized BaseChatModel ready for `.invoke()`
- Temperature always set to 0.2
- max_retries always set to 2

**Valid inputs:**
- `config`: UnfamiliarConfig with `provider` and resolved API key
- `max_tokens`: Override max output tokens (default from env or 4095)

**Error conditions:**
- Unknown provider → `ProviderError: Unknown provider: 'xyz'. Supported providers: anthropic, glm`
- Missing API key → `ProviderError: No API key found for provider 'anthropic'. Set the appropriate environment variable...`
- Missing package → `ProviderError: Failed to initialize glm provider. Ensure the required package is installed: ...`
- Invalid config → `ProviderError: Failed to initialize anthropic provider: <error>`

**Provider-specific behavior:**

| Provider | Model Class | API Key Param |
|----------|-------------|---------------|
| anthropic | LangChain init_chat_model | `api_key` |
| glm | ChatZhipuAI | `zhipuai_api_key` |

**Edge cases:**
- `UNFAMILIAR_MODEL` env var set → Overrides default model
- `UNFAMILIAR_MAX_TOKENS` env var set → Overrides default max_tokens
- `max_tokens` param provided → Overrides env var

---

### get_retry_delay

```python
def get_retry_delay(provider_name: str) -> float
```

**Purpose:** Return the appropriate base delay for retry backoff based on provider.

**Invariants:**
- Always returns positive float
- Unknown providers return default (1.0)

**Valid inputs:**
- `provider_name`: Any non-empty string (case-insensitive)

**Expected returns:**
| Input | Return |
|-------|--------|
| `"anthropic"` | `1.0` |
| `"ANTHROPIC"` | `1.0` |
| `"glm"` | `30.0` |
| `"GLM"` | `30.0` |
| `"unknown"` | `1.0` |
| `"any_other_string"` | `1.0` |

**Edge cases:**
- Empty string → `1.0`
- Whitespace string → `1.0` (stripped, unknown)
- Provider with whitespace → `" glm "` → `30.0` (stripped)

---

### get_effective_token_budget

```python
def get_effective_token_budget(config: UnfamiliarConfig) -> int
```

**Purpose:** Return token budget capped by provider limits.

**Invariants:**
- Return value ≤ `config.token_budget`
- Return value ≤ provider's `max_token_budget` (if set)
- If provider has no limit, returns `config.token_budget` unchanged

**Valid inputs:**
- `config`: UnfamiliarConfig with `provider` and `token_budget` fields

**Expected returns:**
| Scenario | Return |
|----------|--------|
| Anthropic with budget 100k | `100000` (no cap) |
| GLM with budget 100k | `8000` (capped) |
| GLM with budget 5000 | `5000` (under cap) |
| GLM with budget 8000 | `8000` (exactly at cap) |
| Unknown provider | `config.token_budget` unchanged |

---

### Internal Constants

| Constant | Value | Purpose |
|----------|-------|---------|
| `_PROVIDERS` | `dict[str, ProviderConfig]` | Registry: `"anthropic"`, `"glm"` |
| `_DEFAULT_TEMPERATURE` | `0.2` | Low for deterministic output |
| `_DEFAULT_MAX_RETRIES` | `2` | LangChain-level retries |

---

### Environment Variables

| Variable | Purpose | Default |
|----------|---------|---------|
| `UNFAMILIAR_MODEL` | Override default model | Provider's default |
| `UNFAMILIAR_MAX_TOKENS` | Override max output tokens | `4095` |

### API Key Resolution

**Order of precedence:**
1. Provider-specific env var (e.g., `ANTHROPIC_API_KEY`, `ZHIPUAI_API_KEY`)
2. Generic `UNFAMILIAR_API_KEY` env var
3. Config file (removed in Phase 8.5, now env vars only)

**GLM-specific note:**
- GLM uses `zhipuai_api_key` parameter (not `api_key`)
- Ensure `ZHIPUAI_API_KEY` env var is set

---

## 4. LLM Briefing

**Module:** `llm/briefing/`

### Pydantic Models

#### ModuleDescription

```python
class ModuleDescription(BaseModel):
    name: str
    description: str
    security_relevant: bool
```

**Purpose:** Description of a detected module cluster.

**Fields:**

| Field | Type | Description |
|-------|------|-------------|
| `name` | `str` | Concise, descriptive name (2-4 words) |
| `description` | `str` | 1-2 sentence description of module's role |
| `security_relevant` | `bool` | True if handles auth, encryption, secrets, validation |

**Invariants:**
- `name` is never empty
- `description` is never empty
- `security_relevant` has explicit default in Pydantic schema

**Field descriptions for LLM:**
- `name`: "A concise, descriptive name for this module cluster..."
- `description`: "A 1-2 sentence description of what this module does..."
- `security_relevant`: "True if this module handles security-sensitive operations..."

---

#### BriefingResponse

```python
class BriefingResponse(BaseModel):
    summary: str
    tech_stack: list[str]
    security_posture: str
    modules: list[ModuleDescription]
```

**Purpose:** Structured response from the LLM briefing generation.

**Fields:**

| Field | Type | Description |
|-------|------|-------------|
| `summary` | `str` | 3-5 paragraph codebase overview |
| `tech_stack` | `list[str]` | Technologies, frameworks, libraries used |
| `security_posture` | `str` | Security assessment |
| `modules` | `list[ModuleDescription]` | Description for each detected module |

**Invariants:**
- `summary` contains at least 3 paragraphs (enforced by prompt)
- `tech_stack` may be empty (LLM warning logged)
- `modules` length should match input module count

---

### Dataclasses

#### BriefingResult

```python
@dataclass
class BriefingResult:
    briefing: BriefingResponse
    modules: list[ModuleDescription]
    context_tokens_estimate: int
```

**Purpose:** Complete output of briefing generation.

**Fields:**

| Field | Type | Description |
|-------|------|-------------|
| `briefing` | `BriefingResponse` | Structured briefing content |
| `modules` | `list[ModuleDescription]` | Named descriptions for each module |
| `context_tokens_estimate` | `int` | Approximate tokens in context sent to LLM |

**Invariants:**
- `modules` length ≥ `briefing.modules` length (padding adds more)
- `context_tokens_estimate` = `len(context) // CHARS_PER_TOKEN`
- `context_tokens_estimate` ≥ 0

---

### Functions

#### generate_briefing

```python
def generate_briefing(
    analysis: AnalysisResult,
    model: BaseChatModel,
    token_budget: int = DEFAULT_TOKEN_BUDGET,
    provider_name: str | None = None,
) -> BriefingResult
```

**Purpose:** Generate a structured codebase briefing via single LLM call.

**Invariants:**
- Makes exactly 1 successful LLM call (plus retries on transient failure)
- Returns BriefingResult with complete briefing
- Module count padded to match analysis.modules length

**Valid inputs:**
- `analysis`: Complete AnalysisResult from `analyze_repository()`
- `model`: Configured BaseChatModel instance
- `token_budget`: Positive integer (default 50,000)
- `provider_name`: "anthropic" | "glm" | None

**Error conditions:**
- HTTP 401/403 → `AuthenticationError` (immediate)
- Max retries exhausted → `BriefingGenerationError(attempts=N)`
- Empty LLM response → `BriefingGenerationError(attempts=N)`
- Schema validation failure → Retried (treated as transient)

**Edge cases:**
- LLM returns fewer modules than expected → Padded with path-based defaults
- LLM returns more modules than expected → Truncated to expected count
- Empty tech_stack → Warning logged, briefing still returned
- Empty analysis → Briefing generated (minimal context)

**Module padding behavior:**
```python
# If LLM returns 5 modules but analysis has 15:
# Remaining 10 are padded with:
ModuleDescription(
    name=module.primary_path or f"Module {idx}",
    description="Description not provided by LLM.",
    security_relevant=False,
)
```

---

### Internal Constants

| Constant | Value | Purpose |
|----------|-------|---------|
| `_MAX_LLM_MODULES` | `15` | Cap on modules requested from LLM |

---

### Prompt Templates

#### BRIEFING_PROMPT

**Type:** `ChatPromptTemplate`

**Variables:**
- `{context}` — Built context from `build_briefing_context()`
- `{module_count}` — Number of modules to describe

---

## 5. LLM Missions

**Module:** `llm/missions/`

### Pydantic Models

#### MissionFileRefResponse

```python
class MissionFileRefResponse(BaseModel):
    file_path: str
    line_start: int | None = None
    line_end: int | None = None
    description: str
```

**Purpose:** A file reference within a mission.

**Invariants:**
- `file_path` is relative path from repo root
- Lines are 1-indexed (not 0-indexed)
- `line_start` ≤ `line_end` if both set

**Valid inputs:**
- `file_path`: Relative path string (e.g., `"src/handlers/auth.py"`)
- `line_start`: Positive integer or None
- `line_end`: Positive integer ≥ line_start, or None
- `description`: 1-2 sentence explanation

---

#### MissionItemResponse

```python
class MissionItemResponse(BaseModel):
    title: str                    # Film Noir themed
    title_plain: str              # Plain English
    description: str
    difficulty: str               # "quick" | "medium" | "deep_dive"
    completion_condition: str
    security_tag: bool
    standard_tag: str | None = None
    narrative_intro: str          # Film Noir themed
    narrative_outro: str          # Film Noir themed
    file_refs: list[MissionFileRefResponse]
    module_index: int
    prerequisite_titles: list[str] = []
```

**Purpose:** A single investigation mission.

**Invariants:**
- `title` uses Film Noir theming
- `title_plain` is plain English (used for matching)
- `difficulty` is one of: `"quick"`, `"medium"`, `"deep_dive"`
- `module_index` is 0-indexed into briefing's module list
- `prerequisite_titles` length ≤ 2 (enforced by prompt, not schema)

**Difficulty mappings:**
| Value | Duration | Scope |
|-------|----------|-------|
| `"quick"` | 10-15 min | Single file |
| `"medium"` | 20-30 min | 2-3 files |
| `"deep_dive"` | 30-45 min | Multiple modules |

**Valid inputs:**
- All fields non-empty except `standard_tag`
- `file_refs` length 1-5
- `prerequisite_titles` references other missions' `title_plain` values

---

#### ChainResponse

```python
class ChainResponse(BaseModel):
    name: str                     # Film Noir themed
    name_plain: str               # Plain English
    description: str
    mission_titles: list[str]
```

**Purpose:** A chain of related missions to complete in sequence.

**Invariants:**
- `mission_titles` length 2-5 (enforced by prompt)
- Each title matches a `title_plain` from some mission
- Same mission appears in at most one chain (enforced by validation)

---

#### CategoryMissionsResponse

```python
class CategoryMissionsResponse(BaseModel):
    missions: list[MissionItemResponse]
    chains: list[ChainResponse] = []
```

**Purpose:** Complete mission generation response for one category.

**Invariants:**
- `missions` length 3-8 (enforced by prompt)
- At least 1-2 root missions (empty `prerequisite_titles`)
- All non-root missions have valid prerequisites (enforced by validation)

---

### Dataclasses

#### ValidationResult

```python
@dataclass
class ValidationResult:
    missions: list[MissionItemResponse]
    chains: list[ChainResponse]
    warnings: list[str] = field(default_factory=list)
    fixes_applied: list[str] = field(default_factory=list)
    valid: bool = True
```

**Purpose:** Result of validating a category's generated missions.

**Invariants:**
- `valid=True` implies at least 1 mission in list
- `valid=False` implies critical issues (empty missions, unrecoverable)
- Auto-fixes are reflected in modified `missions` list

---

#### PipelineResult

```python
@dataclass
class PipelineResult:
    category_missions: dict[str, CategoryMissionsResponse] = field(default_factory=dict)
    category_validations: dict[str, ValidationResult] = field(default_factory=dict)
    completed_categories: list[str] = field(default_factory=list)
    errors: list[str] = field(default_factory=list)
```

**Purpose:** Complete output of the 7-category mission pipeline.

**Invariants:**
- `completed_categories` ⊆ `category_missions.keys()`
- `category_validations` has same keys as `category_missions`
- Partial results returned even on error

---

### Functions

#### build_mission_context

```python
def build_mission_context(
    analysis: AnalysisResult,
    briefing_result: BriefingResult,
    category: InvestigationCategory,
    previous_missions: list[str],
    token_budget: int = DEFAULT_TOKEN_BUDGET,
) -> str
```

**Purpose:** Build category-focused context for mission generation.

**Invariants:**
- Budget divided by 7 categories: `per_category_budget = token_budget // 7`
- Always includes category-specific files section
- Previous missions section only if `previous_missions` non-empty

**Return structure:**
1. `## Files Relevant to {Category}`
2. `## Relevant Modules`
3. Category-specific context (testing, dependencies, etc.)
4. `## Previously Generated Missions` (if any)

**Category-specific contexts:**
| Category | Section Generated |
|----------|-------------------|
| TESTING | Test files, coverage, untested files |
| DEPENDENCIES | External imports, vulnerabilities |
| TECHNICAL_DEBT | High complexity, large files, many definitions |
| ARCHITECTURE | Entry points, hub files, languages |
| DATA_FLOW | Import relationships, data processing files |

---

#### build_module_descriptions

```python
def build_module_descriptions(briefing_result: BriefingResult) -> str
```

**Purpose:** Build module descriptions list with indices for mission linking.

**Invariants:**
- Index matches position in `briefing_result.modules`
- Format: `[N] **Name**\n    Description`

---

#### generate_category_missions

```python
def generate_category_missions(
    analysis: AnalysisResult,
    briefing_result: BriefingResult,
    category: InvestigationCategory,
    model: BaseChatModel,
    previous_missions: list[str] | None = None,
    token_budget: int = DEFAULT_TOKEN_BUDGET,
    provider_name: str | None = None,
) -> CategoryMissionsResponse
```

**Purpose:** Generate missions for a single category via one LLM call.

**Invariants:**
- Makes exactly 1 successful LLM call (plus retries)
- Returns at least 1 mission (or raises MissionGenerationError)
- `previous_missions` for cross-category awareness only

**Valid inputs:**
- `analysis`: Complete AnalysisResult
- `briefing_result`: Complete BriefingResult
- `category`: One of 7 InvestigationCategory values
- `model`: Configured BaseChatModel
- `previous_missions`: `title_plain` values from prior categories
- `token_budget`: Positive integer
- `provider_name`: "anthropic" | "glm" | None

**Error conditions:**
- HTTP 401/403 → `AuthenticationError` (immediate)
- Max retries exhausted → `MissionGenerationError(category=..., attempts=N)`
- Zero missions returned → `MissionGenerationError("LLM returned no missions...", ...)`

**Edge cases:**
- Empty `previous_missions` → "None (this is the first category)" in prompt
- Category with no relevant files → Falls back to top scored files
- LLM returns invalid JSON → Retried with schema validation retry

---

#### generate_category_missions_multi

```python
def generate_category_missions_multi(
    analysis: AnalysisResult,
    briefing_result: BriefingResult,
    category: InvestigationCategory,
    model: BaseChatModel,
    previous_missions: list[str] | None = None,
    token_budget: int = DEFAULT_TOKEN_BUDGET,
    provider_name: str | None = None,
    missions_per_call: int = 3,
    max_calls: int = 3,
) -> CategoryMissionsResponse
```

**Purpose:** Generate missions using multiple LLM calls with truncation resume.

**Invariants:**
- Makes 1-3 LLM calls depending on truncation
- Returns at least 1 mission (or raises MissionGenerationError)
- No duplicate mission titles in result
- Total missions ≤ `missions_per_call * max_calls` (9 default)

**Multi-call behavior:**
1. Call 1: Request 3 missions
2. If truncated → Extract complete missions, call 2 with "continue from X" hint
3. If still truncated → Call 3
4. Merge all missions, deduplicate by `title_plain`

**Edge cases:**
- All calls fail but have partial missions → Return partial results with warning
- All calls fail, no missions → `MissionGenerationError`
- Duplicate titles across calls → Keep first occurrence only
- Truncation detected with resume_hint → Hint passed to next call

---

#### validate_category_missions

```python
def validate_category_missions(
    missions_response: MissionItemResponse | list[MissionItemResponse],
    repo_path: Path,
    category: InvestigationCategory,
    chains: list[ChainResponse] | None = None,
) -> ValidationResult
```

**Purpose:** Orchestrate all validation passes on generated missions.

**Validation steps (in order):**
1. **File refs:** Remove invalid paths, clamp line ranges
2. **Prerequisites:** Remove nonexistent prereqs
3. **Cycles:** Break circular dependencies
4. **Depth:** Enforce MAX_PREREQUISITE_DEPTH
5. **Roots:** Ensure MIN_ROOT_MISSIONS_PER_CATEGORY
6. **Reachability:** Make unreachable missions into roots
7. **Chains:** Remove invalid chain references

**Invariants:**
- Always returns ValidationResult (never raises)
- `valid=True` implies at least 1 mission exists
- Auto-fixes applied in-place to missions list

**Edge cases:**
- Empty mission list → `ValidationResult([], [], ["No missions generated"], [], valid=False)`
- Single mission → All validation passes (no prereqs possible)
- All file refs invalid → Mission kept with empty file_refs

---

#### validate_file_refs

```python
def validate_file_refs(
    file_refs: list[MissionFileRefResponse],
    repo_path: Path,
) -> tuple[list[MissionFileRefResponse], list[str]]
```

**Purpose:** Validate file references against filesystem.

**Auto-fixes applied:**
| Issue | Fix |
|-------|-----|
| File not found | Removed, warning added |
| Path traversal (`../etc/passwd`) | Removed, warning added |
| Binary/unreadable file | Removed, warning added |
| `line_start=0` | Fixed to `1` |
| `line_end=0` | Fixed to `1` |
| `line_start > line_end` | Swapped |
| `line_start > file_line_count` | Clamped to last line |
| `line_end > file_line_count` | Clamped to file length |

**Invariants:**
- Never raises (all errors become warnings)
- All returned refs have valid paths
- All line ranges within file bounds

**Edge cases:**
- Empty file_refs list → Returns `([], [])`
- All refs invalid → Returns `([], [warning1, warning2, ...])`
- File with 100 lines, ref has `line_end=200` → Clamped to `100`

---

#### validate_prerequisites

```python
def validate_prerequisites(
    missions: list[MissionItemResponse],
    category: InvestigationCategory,
) -> tuple[list[MissionItemResponse], list[str], list[str]]
```

**Purpose:** Validate and fix prerequisite relationships.

**Auto-fixes applied:**
1. Remove prereqs referencing nonexistent missions
2. Break cycles by removing one edge
3. Trim prereqs exceeding depth limit
4. Create root missions by removing prereqs
5. Make unreachable missions into roots

**Invariants:**
- After validation, all prereq titles exist in mission set
- No cycles in prereq graph
- At least MIN_ROOT_MISSIONS_PER_CATEGORY roots exist
- All missions reachable from a root

**Edge cases:**
- All missions form a cycle → One edge removed per cycle
- All missions have prereqs → Lowest-difficulty converted to root
- Single mission → No prereqs possible, passes validation

---

#### run_mission_pipeline

```python
def run_mission_pipeline(
    analysis: AnalysisResult,
    briefing_result: BriefingResult,
    model: BaseChatModel,
    campaign_dir: Path,
    token_budget: int = DEFAULT_TOKEN_BUDGET,
    provider_name: str | None = None,
    category_cooldown: float = 0.0,
) -> PipelineResult
```

**Purpose:** Execute the full 7-category mission generation pipeline.

**Category order (CATEGORY_ORDER):**
1. ARCHITECTURE
2. DATA_FLOW
3. DOMAIN_BUSINESS_LOGIC
4. DEPENDENCIES
5. CONVENTIONS
6. TESTING
7. TECHNICAL_DEBT

**Invariants:**
- Categories processed in CATEGORY_ORDER
- Partial results returned on error
- AuthenticationError stops pipeline immediately
- Other errors logged, pipeline continues

**Valid inputs:**
- `analysis`: Complete AnalysisResult
- `briefing_result`: Complete BriefingResult
- `model`: Configured BaseChatModel
- `campaign_dir`: Path for checkpoint storage
- `token_budget`: Positive integer
- `provider_name`: "anthropic" | "glm" | None
- `category_cooldown`: Non-negative float (seconds between categories)

**Error handling:**
| Error | Action |
|-------|--------|
| Auth error (401/403) | Stop immediately, return partial results |
| MissionGenerationError | Log error, continue to next category |
| Validation failure | Store validation result, continue |
| Checkpoint failure | Fall back to memory, continue |

**Cooldown behavior:**
- `category_cooldown=0.0` → No delays (default)
- `category_cooldown=60.0` → 60s sleep between each category
- Not applied before first category

**Return values:**
- All 7 succeed → `PipelineResult` with all categories, empty errors
- Some fail → `PipelineResult` with completed categories, errors list
- Auth error → `PipelineResult` with completed up to failure point

**Checkpoint behavior:**
- SQLite checkpoint at `campaign_dir/mission_checkpoint.db`
- Falls back to in-memory if SQLite unavailable
- State persisted after each successful category

---

### Internal Constants

| Constant | Value | Purpose |
|----------|-------|---------|
| `_CATEGORIES_COUNT` | `7` | Number of categories for budget division |
| `_MISSIONS_PER_CALL` | `3` | Missions to request per LLM call |
| `_MAX_CALLS_PER_CATEGORY` | `3` | Max LLM calls per category |

---

### Prompt Templates

#### MISSION_PROMPT

**Type:** `ChatPromptTemplate`

**Variables:**
- `{category}` — Category name (e.g., "Architecture")
- `{briefing_summary}` — Summary from briefing
- `{category_context}` — Category-specific context
- `{module_descriptions}` — Module index for linking
- `{previous_missions}` — Prior mission titles (awareness only)
- `{continue_from}` — Optional hint for truncation resume

**System prompt guidelines:**
- Film Noir theming required
- 3-8 missions per category
- Difficulty distribution (quick/medium/deep_dive)
- Maximum 2 prerequisites per mission
- At least 1-2 root missions
- Specific file paths required

---

## Key Invariants Summary

### Retry Behavior

| Provider | Base Delay | Delay Sequence (5 attempts) |
|----------|------------|----------------------------|
| anthropic | 1.0s | ~1s → ~2s → ~4s → ~8s → ~16s |
| glm | 30.0s | ~30s → ~60s → ~120s → ~240s → ~480s |
| unknown | 1.0s | Same as anthropic |

**Retry rules:**
| Error Type | Retried? |
|------------|----------|
| HTTP 429/5xx | Always |
| Connection errors | Always |
| Schema validation errors | Always |
| HTTP 401/403 | Never (AuthenticationError) |
| Non-transient | Never |

---

### Token Budgets

| Provider | Max Budget | Notes |
|----------|------------|-------|
| anthropic | Unlimited | Uses config.token_budget |
| glm | 8,000 | Free tier TPM limits |

**Budget allocation:**
- Briefing: `config.token_budget` (capped by provider)
- Missions: `token_budget // 7` per category

---

### Validation Auto-Fixes

| Issue | Fix |
|-------|-----|
| Invalid file path | Remove from file_refs |
| Path traversal | Remove from file_refs |
| `line_start=0` | Fix to `1` |
| `line_start > line_end` | Swap values |
| `line_end > file_length` | Clamp to file_length |
| Missing prerequisite | Remove from prereqs |
| Prerequisite cycle | Break by removing edge |
| Depth > MAX | Trim deepest prereq |
| No root missions | Convert low-difficulty to root |
| Unreachable mission | Make it a root |

---

## Test Independence Rules

When writing tests from these contracts:

1. **Read contracts first** — Before looking at any implementation
2. **Write tests from contracts** — Test behavior, not implementation
3. **Reference edge cases** — Each contract lists expected return values
4. **Use mock helpers** — Follow my-style/testing.md for LangChain mocking

**Tests verify contract compliance:**
- Tests should pass if implementation matches contract
- Tests should fail if implementation violates contract
- Never mirror implementation logic in tests
