# Phase 3 Hypothetical Promotion: `ProviderHistory` Migration Analysis **Date:** 2026-06-21 **Author:** Tier 1 Orchestrator **Status:** Hypothetical — this is the analysis the deferred Phase 3 work would look like, NOT a track spec **Input:** `docs/handoffs/HANDOFF_CODE_PATH_AUDIT_FROM_any_type_componentization.md` (Tier 2's runtime cost framing) + `src/provider_state.py` (the dataclass already on the tier2 branch) --- ## 1. Purpose Phase 3 (`provider_state.ProviderHistory` call-site migration in `src/ai_client.py`) was deferred from `any_type_componentization_20260621` because: 1. It's the highest-risk phase (112 call sites across 6 senders) 2. The cost depends on whether each site is in a hot path, cold path, or init path 3. `code_path_audit_20260607` is the right tool to quantify that cost before refactoring This document presents **what the migration would look like** — the approximate dataclasses, the call-site catalog, and a **qualitative cost estimation** of each codepath. The actual numbers will come from the audit. This document is the **what**; the audit produces the **cost**. ## 2. The Dataclass (already exists on `tier2/any_type_componentization_20260621` branch) ```python # src/provider_state.py:25-44 (verbatim from branch) @dataclass class ProviderHistory: messages: list[HistoryMessage] = field(default_factory=list) lock: threading.Lock = field(default_factory=threading.Lock) def append(self, message: HistoryMessage) -> None: with self.lock: self.messages.append(message) def get_all(self) -> list[HistoryMessage]: with self.lock: return list(self.messages) def replace_all(self, messages: list[HistoryMessage]) -> None: with self.lock: self.messages = list(messages) def clear(self) -> None: with self.lock: self.messages = [] ``` ```python # src/provider_state.py:47-69 (verbatim from branch) _PROVIDER_HISTORIES: dict[str, ProviderHistory] = { "anthropic": ProviderHistory(), "deepseek": ProviderHistory(), "minimax": ProviderHistory(), "qwen": ProviderHistory(), "grok": ProviderHistory(), "llama": ProviderHistory(), } def get_history(provider: str) -> ProviderHistory: if provider not in _PROVIDER_HISTORIES: raise KeyError(f"Unknown provider: {provider!r}") return _PROVIDER_HISTORIES[provider] def clear_all() -> None: for h in _PROVIDER_HISTORIES.values(): h.clear() def providers() -> tuple[str, ...]: return tuple(_PROVIDER_HISTORIES.keys()) ``` **Properties that hold:** - `@dataclass` (NOT `frozen=True`) — the message list and lock are mutable; this is correct. - `default_factory=list` for `messages` — each `ProviderHistory` gets its own list. - `default_factory=threading.Lock` for `lock` — each `ProviderHistory` gets its own lock instance. - The 4-method interface encapsulates the lock; consumers never see it. **This is already on the tier2 branch.** What Phase 3 does is migrate the consumers. ## 3. The Hypothetical Migration The migration replaces direct module-global access (`_anthropic_history`, `_anthropic_history_lock`) with the typed accessor (`get_history("anthropic")`). ### 3.1 Mechanical Translation Rules | Current | Hypothetical (typed) | Lock needed? | |---|---|---| | `_anthropic_history` (read) | `get_history("anthropic").get_all()` | Yes (returns copy under lock) | | `_anthropic_history` (write ref) | `get_history("anthropic").messages` | Only inside `with h.lock:` | | `_anthropic_history.append(m)` | `get_history("anthropic").append(m)` | Encapsulated | | `len(_anthropic_history)` | `len(get_history("anthropic").messages)` | No (length is atomic in CPython) | | `for m in _anthropic_history:` | `for m in get_history("anthropic").get_all():` | Yes | | `with _anthropic_history_lock:` | `with get_history("anthropic").lock:` | Same | | `_anthropic_history = []` | `get_history("anthropic").clear()` | Encapsulated | ### 3.2 Pattern Categories (per `HANDOFF_CODE_PATH_AUDIT_FROM_any_type_componentization.md` §1) | Category | Sites | Path role | |---|---:|---| | `__history.append(message)` | 6 | Hot — called per LLM turn | | `len(__history)` / `__history[-1]` / iteration | ~40 | Hot — called per LLM turn for trimming, tool-history cache breakpoint, strip_cache_controls | | `with __history_lock:` | ~30 | Mixed — per-turn append is Hot; `reset_session` is Cold | | `global __history` declarations | 4 | N/A — module-level, no runtime cost | | `_strip_cache_controls(__history)` + `_repair__history()` + `_add_history_cache_breakpoint()` + `_trim__history()` | ~30 | Hot for Anthropic (cache controls); Mixed for others | ### 3.3 Per-Provider Site Count (measured from current `src/ai_client.py`) | Provider | history refs | lock refs | global decls | Total sites | |---|---:|---:|---:|---:| | anthropic | 22 | 2 | 1 | 25 | | deepseek | 13 | 6 | 1 | 20 | | minimax | 15 | 5 | 1 | 21 | | qwen | 7 | 4 | 1 | 12 | | grok | 7 | 6 | 0 | 13 | | llama | 12 | 9 | 0 | 21 | | **Total** | **76** | **32** | **4** | **112** | (Note: this 112 count is **higher** than the HANDOFF's "41" estimate, because the grep counts every reference including duplicates in helper functions. The migration work is the same either way — every reference gets touched — but the codepath catalog is richer.) ## 4. The Codepath Catalog (with Qualitative Cost Estimation) This is the **what the audit will quantify**. Each codepath is tagged with `path_role`, `call_frequency`, and **estimated qualitative cost delta** (positive = slower, negative = faster, zero = no change). ### 4.1 `_send_anthropic` (L1407) — **HOT per-LLM-turn** **Codepaths inside `_send_anthropic` (per the grep):** | Codepath | Path role | Per-call freq | Qualitative cost delta | |---|---|---|---| | `_strip_cache_controls(_anthropic_history)` | Hot (called once per send) | 1× per LLM turn | **+0.5-1μs** (one extra dict lookup `get_history("anthropic")` per call) | | `_repair_anthropic_history(_anthropic_history)` | Hot | 1× per LLM turn | **+0.5μs** (same) | | `_anthropic_history.append(...)` (user message) | Hot | 1× per LLM turn | **+0.5μs** (method call vs. bare `.append()`) | | `_add_history_cache_breakpoint(_anthropic_history)` | Hot | 1× per LLM turn | **+0.5μs** (same) | | `_trim_anthropic_history(system_blocks, _anthropic_history)` | Hot | 1× per LLM turn | **+0.5μs** (one extra dict lookup) | | `len(_anthropic_history)` | Hot | 2-3× per LLM turn (used in token estimation) | **+0.3μs** per call (`.messages` attribute access vs. global var lookup) | | `_estimate_prompt_tokens(system_blocks, _anthropic_history)` | Hot | 1× per LLM turn | **+1μs** (the function takes a list; we pass `h.messages` under lock or `h.get_all()`; if the latter, that's a list copy — ~5μs for a 50-message history) | | `for m in _anthropic_history:` (inside `_strip_cache_controls`) | Hot | 1× per LLM turn (iteration over ~10-50 messages) | **+5-10μs** (list copy via `get_all()`; the bare global just iterates directly) | **Per-turn overhead estimate:** +8-15μs per `_send_anthropic` call. At ~50 turns per session, that's **+400-750μs per session**. Negligible vs LLM latency (typically 1-30 seconds). **Recommendation (subject to audit):** Migrate, but use `with h.lock:` blocks for the hot paths inside `_strip_cache_controls` and `_estimate_prompt_tokens` to avoid the list-copy overhead of `get_all()`. ### 4.2 `_send_deepseek` (L2167) — **HOT per-LLM-turn** **Similar pattern to `_send_anthropic` but simpler** (no cache controls). Estimated per-turn overhead: **+3-7μs**. At 50 turns/session, **+150-350μs/session**. ### 4.3 `_send_minimax` (L2616) — **HOT per-LLM-turn** **Has `_trim_minimax_history` helper (L2484).** Estimated per-turn overhead: **+3-7μs**. **+150-350μs/session**. ### 4.4 `_send_grok` (L2532) — **HOT per-LLM-turn** **No `_trim` or `_repair` helpers; simpler.** Estimated per-turn overhead: **+2-5μs**. **+100-250μs/session**. ### 4.5 `_send_qwen` (L2771) — **HOT per-LLM-turn** **No helpers.** Estimated per-turn overhead: **+2-5μs**. **+100-250μs/session**. ### 4.6 `_send_llama` (L2856) — **HOT per-LLM-turn** **Highest lock count (9 lock refs).** Estimated per-turn overhead: **+4-8μs**. **+200-400μs/session**. ### 4.7 `cleanup()` (L454) — **COLD per project-switch** **Iterates over all 6 providers, calls `clear()` on each.** Current code does `with __history_lock: __history = []` 6 times. Hypothetical: `clear_all()` (already defined on branch) iterates and calls `clear()` once per provider. **Per-call cost:** **-2 to -5μs** (negative — slight speedup because `clear_all()` is one function call vs. 6 inline blocks). Called once per project switch; **negligible** in absolute terms. ### 4.8 `reset_session()` (L461) — **COLD per project-switch** **Calls `cleanup()` (the cold path above).** Total per-call cost: **-2 to -5μs**. ### 4.9 Init Path — **`_PROVIDER_HISTORIES` dict construction at module load** **One-time cost at module import.** 6 `ProviderHistory()` instances each with `default_factory=list` + `default_factory=threading.Lock`. Total: ~10-15μs. **Negligible.** ## 5. Total Qualitative Cost Summary | Codepath | Path role | Est. overhead per call | Frequency | Total per session | |---|---|---|---|---| | `_send_anthropic` | Hot per turn | +8-15μs | ~50 turns | +400-750μs | | `_send_deepseek` | Hot per turn | +3-7μs | ~50 turns | +150-350μs | | `_send_minimax` | Hot per turn | +3-7μs | ~50 turns | +150-350μs | | `_send_grok` | Hot per turn | +2-5μs | ~50 turns | +100-250μs | | `_send_qwen` | Hot per turn | +2-5μs | ~50 turns | +100-250μs | | `_send_llama` | Hot per turn | +4-8μs | ~50 turns | +200-400μs | | `cleanup()` / `reset_session()` | Cold per project switch | -2-5μs | ~1× | -2-5μs | | Init (module load) | Once | +10-15μs | 1× | +10-15μs | | **Total per session** | | | | **~+1.1-2.4ms** | **Interpretation:** Even at the upper bound (+2.4ms per session), this is **3+ orders of magnitude smaller** than the LLM latency it lives alongside. The migration is **type-safety for free** in absolute runtime terms. **The actual audit will quantify these estimates.** If the audit finds a >50μs delta per turn (e.g., from lock contention or `get_all()` list copies), the migration strategy changes (use `with h.lock:` blocks instead of `get_all()` to avoid copies). ## 6. The Risks (per `HANDOFF_CODE_PATH_AUDIT_FROM_any_type_componentization.md` §1) | Risk | Likelihood | Impact | Mitigation | |---|---|---|---| | `get_history("anthropic").get_all()` copies the list per access; `_estimate_prompt_tokens` is called per turn and iterates the copy | Medium | **+5-15μs per turn** | Use `with h.lock: msg_list = h.messages` pattern in hot iteration sites | | Lock contention: multiple `_send_` calls in parallel (rare but possible during batch sends) | Low | **+1-10μs per turn under contention** | The lock is per-provider; no cross-provider contention; benchmark will reveal | | `getattr` lookup overhead for `get_history(...)` vs. global var | Low | **+0.5μs per access** | Could inline as a module-level constant if needed; unlikely worth the readability cost | | The `_send_anthropic` cache-control helpers iterate the list; a copy doubles memory bandwidth | Medium | **+10-30μs per turn** if hot | Refactor to operate on `h.messages` under lock without copying | | Forgotten call site (one of the 76 history refs missed) | Medium | **Runtime AttributeError or NameError** | Run tier-1-unit-core + tier-2-mock-app-core FULLY per the regression protocol | ## 7. The Codepath Audit Additions (per `PROMPT_FOR_TIER_1.md` Decision 4) Per Tier 1's sequencing decision, the `code_path_audit_20260607` will instrument: | Action | Codepath | Measures | |---|---|---| | `provider_history_append` | `get_history(p).append(msg)` (or current `_anthropic_history.append(msg)`) | Per-turn append latency + lock acquire time | | `websocket_broadcast` | `broadcast(WebSocketMessage(...))` (post-Phase 6a) | Per-broadcast overhead | | `ai_message_lifecycle` (existing) | `_send_` end-to-end | Total per-turn latency delta pre/post Phase 3 | | `discussion_save_load` (existing) | `reset_session()` + project switch | Cold-path cost | | `gui_startup` (existing) | `_PROVIDER_HISTORIES` init | One-time cost | ## 8. Recommendation (subject to audit results) **If the audit confirms the qualitative estimates** (+1-2ms per session; <50μs per turn): - Proceed with Phase 3 migration as planned (~10-15 commits). - Use `with h.lock:` blocks for hot iteration sites (`_strip_cache_controls`, `_estimate_prompt_tokens`) to avoid `get_all()` copies. - Run the 11-tier regression protocol per the follow-up track. **If the audit reveals a >50μs per-turn delta** (e.g., lock contention >10μs): - Reconsider: do we even need to migrate the history aspect? It's `list[Metadata]` already typed. - Alternative: keep the module globals but rename them with a `_HISTORY` suffix and document the pattern; defer full ProviderHistory migration. **The audit decides.** This analysis is the input to the audit, not the conclusion. ## 9. Open Questions 1. **Should the `ProviderHistory.messages` be `list[HistoryMessage]` or `list[dict[str, Any]]`?** Currently it's `list[HistoryMessage]` (= `list[Metadata]`). The legacy code uses `list[Metadata]` everywhere. The dataclass stays consistent with the type alias. 2. **Should we add a `__len__` method to `ProviderHistory` to avoid `len(h.messages)`?** - Pros: cleaner consumer code - Cons: minor; only saves attribute access 3. **Should `_PROVIDER_HISTORIES` be a `MappingProxyType` (read-only) for external code?** Currently it's a regular dict; external code could mutate `_PROVIDER_HISTORIES["anthropic"] = ProviderHistory()`. Probably not worth the indirection. 4. **Should `get_history(p)` validate `p` (raise on unknown)?** Currently it raises `KeyError`. Could be `Literal["anthropic", "deepseek", ...]` for static type checking. ## 10. See Also - `docs/handoffs/HANDOFF_CODE_PATH_AUDIT_FROM_any_type_componentization.md` — the original runtime cost framing - `docs/handoffs/PROMPT_FOR_TIER_1.md` — Tier 1's decision points - `src/provider_state.py` — the actual dataclass (already on `tier2/any_type_componentization_20260621` branch) - `conductor/tracks/any_type_componentization_20260621/spec.md` — parent track spec - `conductor/tracks/code_path_audit_20260607/spec.md` — the audit that will quantify these estimates - `conductor/tracks/phase2_4_5_call_site_completion_20260621/spec.md` — the follow-up track that unblocks the audit