"""
cortex/config.py — CortexConfig dataclass + TOML loader.

Central configuration for the entire Cortex system. All modules receive
their tunable parameters through CortexConfig rather than hardcoding values.

Load order (later wins):
    1. Package defaults  (cortex/config/defaults.toml — shipped with the package)
    2. User config file  (path passed to from_toml())
    3. Environment variables  (CORTEX_<FIELD_NAME> — e.g., CORTEX_DB_PATH)
    4. In-process overlay dict  (e.g., CLI flags or test overrides)

Note: The cortex_config DB table stores config values for reference/audit but
is NOT read back at runtime. Config is loaded once from TOML at startup.
There is no runtime hot-reload mechanism.

Design decisions:
    - Flat dataclass (no nested objects) for simplicity — section prefixes
      (storage_, retrieval_, etc.) group related fields.
    - Every field has a sane default, so CortexConfig() with no args works.
    - TOML sections are flattened during _from_raw() via explicit mapping.

Dependencies: None (leaf module).
Dependents: Every module that needs tunable parameters — all of them.
"""
from __future__ import annotations

import os
from dataclasses import MISSING as dataclass_MISSING, dataclass, field, fields
from pathlib import Path
from typing import Any, List, Optional

from cortex.types import CortexTier

# Python 3.11+ has tomllib in stdlib; 3.10 and below need tomli (third-party).
try:
    import tomllib  # type: ignore
except ImportError:
    try:
        import tomli as tomllib  # type: ignore
    except ImportError:
        tomllib = None  # type: ignore


_DEFAULTS_PATH = Path(__file__).parent / "config" / "defaults.toml"


@dataclass
class CortexConfig:
    """
    Runtime configuration for Cortex v3.

    Configuration is loaded once from TOML at process startup. The
    cortex_config DB table mirrors these values for reference/audit but
    is not read back at runtime — there is no hot-reload mechanism.

    Fields with a ``storage_`` prefix come from the [cortex.storage] TOML
    section; they are handled separately because they affect DB connection
    setup (before the table exists).
    """

    # ------------------------------------------------------------------ #
    # Storage / connection (M1)
    # These affect DB connection setup — read BEFORE the DB is opened.
    # ------------------------------------------------------------------ #
    storage_backend: str = "sqlite"
    """Storage backend type. Only 'sqlite' is implemented; 'postgres' planned for Phase 5."""

    storage_db_path: str = "cortex.db"
    """Path to SQLite database file. Relative paths resolved against cwd or base arg."""

    storage_enable_wal: bool = True
    """Enable Write-Ahead Logging for concurrent read performance. Almost always True."""

    storage_busy_timeout_ms: int = 5000
    """SQLite busy timeout in ms. Prevents SQLITE_BUSY on concurrent access."""

    # ------------------------------------------------------------------ #
    # Embeddings (used by M8 retrieval + M11 drift detection)
    # ------------------------------------------------------------------ #
    embedding_model: str = "text-embedding-3-small"
    """Model ID for vector embeddings. Must match what StorageAdapter stores."""

    embedding_dim: int = 1536
    """Dimensionality of embedding vectors. Must match the model's output dim."""

    # ------------------------------------------------------------------ #
    # LLM provider cascade (M13 — CortexPlugin provider selection)
    # ------------------------------------------------------------------ #
    llm_provider_priority: List[str] = field(
        default_factory=lambda: ["anthropic", "openai", "ollama"]
    )
    """Ordered list of LLM provider names to try during plugin init.

    Cortex tries each provider in order and uses the first one that
    initialises successfully (API key present, SDK installed). This lets
    operators pin a provider or define a fallback chain without code changes.

    Supported values: "anthropic", "openai", "ollama".

    DEPRECATED in v1.2.1: Use provider_mode + provider_llm_* fields instead.
    Kept for backward compatibility — if [cortex.llm] section exists in config,
    a deprecation warning is logged and old fields are mapped to new ones.
    """

    # ------------------------------------------------------------------ #
    # Provider architecture (v1.2.1 — replaces provider cascade)
    # ------------------------------------------------------------------ #
    provider_mode: str = "host"
    """Provider mode: 'host' (OpenClaw plugin), 'byok' (user keys), 'bundled' (cloud managed)."""

    # Provider LLM config
    provider_llm_provider: str = ""
    """LLM provider name: openai | anthropic | venice | ollama. Auto-detected in host mode."""

    provider_llm_model: str = "gpt-4.1-nano"
    """Model used for extraction/reconciliation in the new provider system."""

    provider_llm_api_key: str = ""
    """LLM API key. Not needed in host mode (supplied by host at runtime)."""

    provider_llm_base_url: str = ""
    """LLM API base URL. Not needed in host mode (supplied by host at runtime)."""

    # Provider embedding config
    provider_embedding_provider: str = ""
    """Embedding provider name: openai | voyage. Auto-detected in host mode."""

    provider_embedding_model: str = "voyage-code-3"
    """Embedding model for vector search in the new provider system."""

    provider_embedding_api_key: str = ""
    """Embedding API key. Not needed in host mode (supplied by host at runtime)."""

    provider_embedding_base_url: str = ""
    """Embedding API base URL. Not needed in host mode."""

    provider_embedding_dim: int = 1024
    """Embedding dimension. Must match the model's output dim."""

    # Usage tracking
    provider_usage_enabled: bool = False
    """Enable per-owner token counting for tier enforcement (bundled mode)."""

    provider_usage_tier: str = ""
    """Subscription tier: pro | scale | enterprise. Used in bundled mode."""

    provider_usage_monthly_token_cap: int = 0
    """Monthly token cap. 0 = unlimited. Used in bundled mode."""

    provider_usage_monthly_embedding_cap: int = 0
    """Monthly embedding call cap. 0 = unlimited. Used in bundled mode."""

    # ------------------------------------------------------------------ #
    # LLM models (used by M2 extraction + M4 reconciliation)
    # ------------------------------------------------------------------ #
    extraction_model: str = "gpt-4.1-nano-2025-04-14"
    """Model used by ExtractionPipeline for claim extraction. Nano = cheap + fast."""

    reconciliation_model: str = "gpt-4.1-nano-2025-04-14"
    """Model used by ReconciliationEngine for dedup decisions. Can be different from extraction."""

    dialectic_model: Optional[str] = None
    """Model used by DialecticEngine and QueryPlanner for synthesis and planning.

    When None (default), falls back to ``extraction_model``. Set this to a
    more capable model (e.g. gpt-4.1-mini) when dialectic quality matters more
    than cost, without affecting the cheaper extraction path.
    """

    # ------------------------------------------------------------------ #
    # Token budgets (M9 — TokenBudgetManager)
    # These control how much of the LLM context window is allocated to memory.
    # ------------------------------------------------------------------ #
    cornerstone_token_budget: int = 800
    """Tokens reserved exclusively for cornerstone injection. Always allocated first."""

    retrieval_token_budget: int = 3000
    """Max tokens for retrieved context (semantic + session + procedural layers)."""

    total_memory_token_budget: int = 4000
    """Hard ceiling for ALL memory content (cornerstones + retrieval). Must be
    ≥ cornerstone_token_budget + retrieval_token_budget."""

    # Issue #374 — hard context-window budget fractions
    token_budget_memory_fraction: float = 0.25
    """Fraction of the total LLM context window allocated to memory/compiler blocks.
    Target range: 20-30%.  Default: 0.25."""

    token_budget_user_fraction: float = 0.55
    """Fraction of context for user input, tool results, and reasoning room.
    Target range: 50-60%.  Default: 0.55."""

    token_budget_reserve_fraction: float = 0.20
    """Reserve fraction for dynamic updates or long answers.
    Target range: 10-20%.  Default: 0.20."""

    token_budget_slot_priorities: List[str] = field(
        default_factory=lambda: [
            "task_context", "boot_context", "open_loops",
            "semantic_claims", "procedural", "episodic",
        ]
    )
    """Ordered list of InjectionSlot value strings defining memory slot priority.
    First entry = highest priority (allocated first, truncated last).
    Issue #374 — slot priority order."""

    # ------------------------------------------------------------------ #
    # Drift detection (M11 — DriftCalculator)
    # Thresholds that determine DriftCalculator actions:
    #   score < 0.1    → 'none'  (healthy)
    #   0.1 ≤ s < 0.3  → 'warn'  (log + monitor)
    #   0.3 ≤ s < 0.5  → 'restore' (auto-restore from golden copy)
    #   s ≥ 0.5        → 'alert' (restore + alert operator)
    # ------------------------------------------------------------------ #
    drift_check_threshold: float = 0.3
    """Drift score threshold that triggers auto-restore from golden copy."""

    drift_restore_threshold: float = 0.5
    """Drift score threshold that triggers operator alert (above restore)."""

    anti_compression_confidence: float = 0.9
    """Confidence threshold for anti-compression detection in cornerstones."""

    # --- Drift threshold aliases (read-only properties below) ---
    # The original field names (drift_check_threshold, drift_restore_threshold)
    # are confusingly swapped relative to the actions they trigger:
    #   drift_check_threshold  (0.3) → triggers RESTORE action
    #   drift_restore_threshold (0.5) → triggers ALERT action
    # The properties drift_restore_score and drift_alert_score provide
    # correctly named aliases. Prefer these in new code.

    # ------------------------------------------------------------------ #
    # Brain Graph (M10c — Watcher + Auto-Registration)
    # ------------------------------------------------------------------ #
    brain_graph_watch_dirs: List[str] = field(default_factory=lambda: ["docs"])
    brain_graph_include_patterns: List[str] = field(
        default_factory=lambda: ["*.md", "*.txt", "*.py"]
    )
    brain_graph_exclude_patterns: List[str] = field(
        default_factory=lambda: ["*.pyc", "__pycache__/*", "*.egg-info/*", ".git/*"]
    )
    brain_graph_debounce_seconds: float = 5.0

    # ------------------------------------------------------------------ #
    # Deprecation / lifecycle
    # ------------------------------------------------------------------ #
    deprecation_cooling_days: int = 14
    deprecation_archive_days: int = 90

    # ------------------------------------------------------------------ #
    # Session / confidence defaults (M13 — CortexPlugin lifecycle)
    # ------------------------------------------------------------------ #
    session_confidence_default: float = 0.5
    """Default confidence for newly extracted session claims."""

    promoted_confidence_default: float = 0.8
    """Confidence assigned to claims when promoted from session to long-term."""

    session_fatigue_nap_threshold: float = 0.7
    """Fatigue score at which the agent should take a short nap (compact context)."""

    session_fatigue_sleep_threshold: float = 0.9
    """Fatigue score at which the agent should sleep (full session end)."""

    # ------------------------------------------------------------------ #
    # Commitments / open loops
    # ------------------------------------------------------------------ #
    open_loop_stale_threshold: int = 5
    """Number of sleep/wake cycles after which an open loop is auto-resolved
    as stale. Higher values let loops survive longer before auto-cleanup."""

    # ------------------------------------------------------------------ #
    # Circadian / dream cycle
    # ------------------------------------------------------------------ #
    dream_cycle_hour_utc: int = 8
    dream_cycle_idle_timeout_sec: int = 3600
    dream_cycle_enabled: bool = True
    dream_cycle_timeout_sec: int = 600
    auto_sleep_after_minutes: int = 30
    fatigue_interval_sec: int = 300
    """Seconds between background fatigue time-increment ticks (default 5 minutes)."""

    # ------------------------------------------------------------------ #
    # Cornerstones
    # ------------------------------------------------------------------ #
    cornerstone_auto_apply: bool = False
    max_cornerstone_count: int = 10
    cornerstone_drift_threshold: float = 0.05
    cornerstone_auto_apply_threshold: Optional[float] = None
    """Confidence threshold for auto-applying evolution proposals (0.0–1.0).

    When set to a float, proposals with LLM confidence >= this threshold are
    automatically applied without operator review.  When None (the default),
    ALL proposals require manual review via apply_evolution().

    Recommended value if enabling: 0.95 — only very high-confidence, purely-
    additive changes are auto-applied.  Never set below 0.80 in production.
    """

    # ------------------------------------------------------------------ #
    # Retrieval (M8 — RetrievalPipeline)
    # ------------------------------------------------------------------ #
    retrieval_default_strategy: str = "hybrid"
    """Default retrieval strategy: 'hybrid' | 'semantic' | 'bm25'.
    Hybrid combines vector similarity (70%) with BM25 keyword match (30%)."""

    retrieval_max_tokens: int = 4000
    """Max tokens returned from a single retrieval call."""

    retrieval_vector_weight: float = 0.7
    """Weight for vector (semantic) similarity in hybrid scoring (0-1)."""

    retrieval_bm25_weight: float = 0.3
    """Weight for BM25 keyword match in hybrid scoring (0-1).
    Must satisfy: vector_weight + bm25_weight = 1.0."""

    retrieval_recency_weight: float = 0.0

    retrieval_temporal_boost_weight: float = 0.15
    """Weight for temporal proximity boost (issue #513). When > 0, claims with
    event_date near a temporal hint detected in the query get a score bonus."""
    """Weight for recency bonus in hybrid scoring (default 0.0, disabled). (#271)"""

    retrieval_rrf_k: int = 60
    """Smoothing constant for Reciprocal Rank Fusion (RRF).

    Controls how much weight top-ranked items get vs lower-ranked ones.
    k=60 is the standard value from the original RRF paper (Cormack 2009).
    Higher k = more uniform weighting across ranks."""

    retrieval_rerank_enabled: bool = True
    """Enable Voyage rerank step after initial vector/BM25 retrieval.

    When True, the pipeline fetches retrieval_rerank_initial_candidates results,
    passes them through Voyage rerank API, and returns the top retrieval_rerank_top_k.
    Requires VOYAGE_API_KEY env var. Falls back gracefully if the API call fails.
    """

    retrieval_rerank_model: str = "rerank-2.5-lite"
    """Voyage rerank model to use. Options: 'rerank-2.5-lite' (fast/cheap),
    'rerank-2.5' (higher quality). Both are in the 200M free token tier."""

    retrieval_rerank_top_k: int = 5
    """Number of final results to return after reranking.
    The rerank pass selects the top_k most relevant items from initial_candidates."""

    retrieval_rerank_initial_candidates: int = 30
    """Number of initial candidates fetched before the rerank step.
    Higher values give the reranker more to work with at the cost of first-pass latency."""

    # ------------------------------------------------------------------ #
    # Extraction misc
    # ------------------------------------------------------------------ #
    extraction_enabled: bool = True
    """Master toggle for claim extraction. When False, remember() logs the
    episode event but skips LLM extraction entirely. Useful for import-only
    or evidence-logging-only deployments."""

    coding_noise_filter: bool = True

    extraction_batch_size: int = 5
    """Max messages per LLM extraction call (batch size).
    Larger batches = fewer calls but higher per-call latency.
    Default 5 balances cost and latency for typical conversations."""

    extraction_custom_instructions: str = ""
    """Optional deployment-specific extraction rules appended to the system prompt.

    When non-empty, this string is appended after EXTRACTION_SYSTEM_PROMPT before
    the LLM call.  Use it to add project-specific memory rules without editing
    source code.

    Example (in user config TOML):
        [cortex.extraction]
        custom_instructions = \"\"\"
        ADDITIONAL RULES:
        - Always tag mentions of the project 'Cortex' under engineering category.
        - Never extract salary or compensation details.
        \"\"\"
    """

    max_claims_per_extraction: int = 25
    """Hard cap on extracted claims per remember() call.

    Applies after all extraction batches are aggregated. Set to 0 to disable.
    """

    max_entities_per_extraction: int = 100
    """Hard cap on total entity mentions kept per extraction result.

    Excess entities are trimmed from later claims first. Set to 0 to disable.
    """

    max_concurrent_extractions: int = 4
    """Maximum concurrent extraction LLM calls per plugin instance.

    Implemented via an async semaphore in CortexPlugin.remember().
    """

    # ------------------------------------------------------------------ #
    # LLM call budgets (M13 — cost guard rails)
    # Prevent runaway sessions from exceeding the $8/month target.
    # ------------------------------------------------------------------ #
    max_llm_calls_per_session: int = 1000
    """Hard limit on LLM calls per session (extraction + reconciliation + retrieval).
    When exhausted the plugin returns errors instead of calling the LLM.
    Resets on wake(). Set to 0 to disable."""

    max_extraction_calls_per_batch: int = 25
    """Max number of claims passed from extraction → reconciliation per remember() call.
    Excess claims are silently dropped (cheapest ones first)."""

    # ------------------------------------------------------------------ #
    # Authentication (#542)
    # ------------------------------------------------------------------ #
    auth_jwt_secret: str = ""
    """Supabase JWT secret for HS256 token validation.

    Set via SUPABASE_JWT_SECRET environment variable (preferred) or in
    [cortex.auth] jwt_secret in your TOML config.  When set, the server
    validates Bearer tokens as Supabase JWTs and extracts owner_id from
    the sub claim.
    """

    auth_api_key: str = ""
    """Legacy API key for self-host and power-user access.

    Set via CORTEX_API_KEY environment variable or [cortex.auth] api_key.
    When jwt_secret is also set, JWT is checked first and this key is the
    fallback.  When neither is set, the server operates in open-access mode.
    """

    auth_require_auth: bool = False
    """When True, unauthenticated (open-access) requests are rejected with 401.

    Default False for local dev.  Set to True in hosted / production deployments.
    """

    auth_public_endpoints: list = None  # type: ignore[assignment]
    """Paths that bypass authentication regardless of require_auth.

    Defaults to ['/api/v1/health', '/api/v1/metrics', '/docs', '/openapi.json', '/redoc'].
    """

    def __post_init__(self) -> None:
        """Apply default mutable values that can't be set as dataclass defaults."""
        if self.auth_public_endpoints is None:
            self.auth_public_endpoints = [
                "/api/v1/health",
                "/api/v1/metrics",
                "/docs",
                "/openapi.json",
                "/redoc",
            ]


    # ------------------------------------------------------------------ #
    # Rate limiting (#71)
    # ------------------------------------------------------------------ #
    rate_limit_rpm: int = 60
    """HTTP API sustained request rate limit (requests per minute, per identity).
    Set to 0 to disable rate limiting entirely."""

    rate_limit_burst: int = 10
    """HTTP API burst allowance (max tokens above the sustained rate).
    Allows short bursts before the per-minute limit kicks in."""

    # ------------------------------------------------------------------ #
    # Remember body size limit (Issue #486)
    # ------------------------------------------------------------------ #
    max_remember_body_bytes: int = 1_000_000
    """Hard cap on serialised JSON size of /remember request bodies (bytes).

    Prevents arbitrarily large payloads from reaching the extraction pipeline.
    Returns HTTP 413 when exceeded.  Set to 0 to disable (not recommended).
    Override in your config TOML under [cortex.api] max_remember_body_bytes.
    """

    # ------------------------------------------------------------------ #
    # Pagination (L2 audit fix)
    # ------------------------------------------------------------------ #
    max_page_size: int = 200
    """Hard cap on per_page for all paginated API and storage queries.

    The HTTP layer enforces this at the route level; the storage layer
    (claim_queries.MAX_PAGE_SIZE) clamps it independently so direct storage
    callers cannot bypass the limit.  Override in your config TOML under
    [cortex.pagination] max_page_size.
    """

    # ------------------------------------------------------------------ #
    # Log retention / TTL (L3 audit fix)
    # ------------------------------------------------------------------ #
    log_retention_days: int = 90
    """Days to retain event_log and request_log rows.

    Rows older than this threshold are deleted by ``prune_old_logs()``.
    Wire into the circadian cycle or call via the admin prune endpoint.
    Set to 0 to disable automatic pruning (retain forever).
    """

    # ------------------------------------------------------------------ #
    # Working Memory Cache (#371)
    # ------------------------------------------------------------------ #
    working_memory_enabled: bool = True
    """Enable the in-memory working memory cache. When False, all WorkingMemoryCache
    operations are no-ops and retrieval falls through directly to DB queries."""

    working_memory_max_items: int = 100
    """Per-session item cap. When a session fills up, the least-recently-used
    entry is evicted. Range: 1–10000."""

    working_memory_default_ttl_seconds: int = 1800
    """Default time-to-live for working memory entries in seconds.
    0 = no TTL (entries live until session end). Default: 1800s (30 min)."""

    # ------------------------------------------------------------------ #
    # Embedding Cache (v1.2.1)
    # ------------------------------------------------------------------ #
    embedding_cache_enabled: bool = True
    """Enable in-memory LRU+TTL cache for embedding vectors.

    When True, repeated queries that normalise to the same text hit the cache
    instead of calling the Voyage API (~150ms saved per hit).
    When False, every retrieve() call embeds from scratch."""

    embedding_cache_max_size: int = 1000
    """Maximum number of embedding vectors to hold in memory.

    At 1024 dimensions (float32), each entry is ~4KB.
    1000 entries ≈ 4MB RAM. Least-recently-used entries are evicted when full.
    Range: 1–100000."""

    embedding_cache_ttl_seconds: int = 3600
    """Time-to-live for each cached embedding in seconds.

    Entries older than this are automatically expired.
    Default: 3600s (1 hour) — covers a full conversation session.
    Set to 0 to use maxsize-only LRU eviction (no TTL). Note: cachetools
    requires ttl > 0; use a very large value to approximate no-TTL behaviour."""

    # ------------------------------------------------------------------ #
    # DB Pool / per-user connection pool (#551)
    # ------------------------------------------------------------------ #
    db_pool_max_open: int = 100
    """Maximum number of simultaneously open per-user SQLite connections.

    DBManager evicts the least-recently-used connection when this limit is
    exceeded. Range: 1–10000. Default: 100."""

    db_pool_idle_timeout_seconds: float = 300.0
    """Seconds after which an idle per-user connection is lazily evicted.

    Set to 0 to disable age-based eviction (LRU-only). Default: 300s (5 min)."""

    db_pool_data_dir: str = "/data"
    """Base directory for per-user SQLite files: ``{data_dir}/{owner_id}.db``.

    Must be an absolute path in production. Override with the CORTEX_DB_POOL_DATA_DIR
    env var or in your config TOML under [cortex.db_pool]."""

    # ------------------------------------------------------------------ #
    # Model routing (#375 — three-tier model selection)
    # ------------------------------------------------------------------ #
    model_routing_enabled: bool = False
    """Enable three-tier model routing for LLM calls.

    When True, each LLM callsite uses the operation-to-tier mapping to select
    the appropriate model (fast/main/reasoning). When False (default), all
    calls use the existing single-provider behavior — zero behavior change.
    """

    model_routing_fast: str = ""
    """Model name/ID for the FAST tier (cheap, low-latency).

    Used for: extraction, classification, routing, lightweight summarization.
    Example: 'gpt-4.1-nano-2025-04-14'
    """

    model_routing_main: str = ""
    """Model name/ID for the MAIN tier (higher quality).

    Used for: consolidation, handoff building, deep summarization.
    Example: 'gpt-4.1-mini-2025-04-14'
    """

    model_routing_reasoning: str = ""
    """Model name/ID for the REASONING tier (best quality, expensive).

    Used for: contradiction analysis, reconciliation, entity resolution.
    Example: 'gpt-4.1-2025-04-14'
    """

    model_routing_operation_map: dict = field(default_factory=dict)
    """Custom operation-to-tier overrides.

    Maps operation name strings to tier name strings ('fast', 'main', 'reasoning').
    Overrides the hardcoded defaults in cortex.llm.router.DEFAULT_OPERATION_MAP.
    Example: {"extraction": "main", "reconciliation": "fast"}
    """

    # ------------------------------------------------------------------ #
    # Metrics (#368 — observability)
    # ------------------------------------------------------------------ #
    metrics_enabled: bool = True
    """Enable metrics collection. When False, all MetricsCollector operations
    are zero-cost no-ops."""

    metrics_export_format: str = "json"
    """Export format for metrics endpoint: 'json' or 'prometheus'."""

    # ------------------------------------------------------------------ #
    # Quality Gates (#368 — production readiness checks)
    # ------------------------------------------------------------------ #
    quality_gates_enabled: bool = True
    """Enable quality gate evaluation in health checks. When False, gates
    are skipped entirely in health()."""

    # ------------------------------------------------------------------ #
    # Feature Flags (#368 — gradual rollout)
    # ------------------------------------------------------------------ #
    feature_flags_enabled: bool = True
    """Enable feature flag gating. When False, all features are considered
    enabled (no gating). When True, flags control feature activation."""

    # ------------------------------------------------------------------ #
    # Circuit breaker (#347)
    # ------------------------------------------------------------------ #
    circuit_breaker_enabled: bool = True
    """Enable per-provider circuit breaker for quota/rate-limit resilience.

    When True, repeated 429/quota failures open the breaker and block
    write operations (remember/extract) while reads (retrieval) continue
    via non-LLM paths. Set to False to disable entirely (not recommended
    for production).
    """

    circuit_breaker_threshold: int = 5
    """Consecutive quota/rate-limit failures before opening the circuit breaker.

    Only QUOTA_EXHAUSTED and RATE_LIMITED failures count. Transient errors
    and model errors do not contribute to this count (handled by retry logic).
    Default: 5. Lower values trip the breaker sooner; higher values allow
    more retries before blocking writes.
    """

    circuit_breaker_cooldown_sec: int = 300
    """Seconds after opening before the circuit breaker auto-transitions to
    HALF_OPEN (probe state). In HALF_OPEN, one write attempt is allowed to
    test provider recovery. Default: 300s (5 minutes).
    """

    # ------------------------------------------------------------------ #
    # Webhook SSRF protection (#435)
    # ------------------------------------------------------------------ #
    webhook_allow_private_urls: bool = False
    """Allow webhooks to target private/internal URLs (development only)."""

    # ------------------------------------------------------------------ #
    # Re-Retrieval (#372 — mid-turn supplemental retrieval)
    # ------------------------------------------------------------------ #
    re_retrieval_enabled: bool = True
    """Enable mid-turn re-retrieval triggered by tool results, topic shifts,
    user corrections, or explicit agent requests. When False, on_tool_result()
    is a no-op."""

    re_retrieval_max_per_turn: int = 2
    """Maximum number of supplemental re-retrievals allowed per conversation
    turn. Prevents runaway retrieval loops. Range: 1–10."""

    re_retrieval_topic_shift_threshold: float = 0.7
    """Keyword divergence score (0.0–1.0) above which a topic shift trigger
    fires. Higher = requires more divergence to trigger. Default: 0.7."""

    re_retrieval_min_budget_tokens: int = 500
    """Minimum remaining token budget required before a re-retrieval is
    attempted. If budget_tokens < this, the re-retrieval is skipped."""

    # ------------------------------------------------------------------ #
    # Context Compiler (#363)
    # ------------------------------------------------------------------ #
    context_compiler_enabled: bool = True
    """Enable the context compiler for assembling retrieval results into
    injection-ready bundles. When False, retrieval results bypass the
    compiler and are injected without bundling."""

    context_compiler_max_bundles: int = 7
    """Maximum number of bundle types to include in compiled context.
    Range: 1–7. Default: 7 (all bundle types)."""

    context_compiler_budget_overflow_strategy: str = "compress_then_drop"
    """Strategy when compiled bundles exceed the token budget.
    'compress_then_drop': compress lowest-priority bundles first,
    then drop entirely if still over budget."""

    # ------------------------------------------------------------------ #
    # Boot Loader (#361)
    # ------------------------------------------------------------------ #
    boot_enabled: bool = True
    """Enable the session boot loader. When True, a deterministic boot sequence
    is built at session start to provide continuity context."""

    boot_max_recent_sessions: int = 3
    """Maximum number of recent session summaries to load during boot.
    Range: 1–10. Default: 3."""

    boot_min_stability_threshold: float = 0.7
    """Minimum confidence/stability score for loading preferences during boot.
    Claims with confidence below this threshold are excluded.
    Range: 0.0–1.0. Default: 0.7."""

    boot_budget_fraction: float = 0.15
    """Fraction of the total context window reserved for boot context.
    Range: 0.01–0.50. Default: 0.15 (15% of context budget)."""

    # ------------------------------------------------------------------ #
    # Retrieval Policy Engine (#362)
    # ------------------------------------------------------------------ #
    retrieval_policy_enabled: bool = True
    """Enable task-mode driven retrieval policy routing. When True, queries
    are classified into task modes and retrieval channels are selected based
    on the mode's policy. When False, existing retrieval behavior is unchanged."""

    retrieval_policy_confidence_threshold: float = 0.3
    """Minimum classification confidence required to use a mode-specific policy.
    Below this threshold, the fallback (balanced) policy is used instead.
    Range: 0.0–1.0. Default: 0.3."""

    retrieval_policy_default_mode: str = "recall_history"
    """Default task mode when detection fails entirely (no signals matched).
    Must be a valid TaskMode value. Default: 'recall_history'."""

    # ------------------------------------------------------------------ #
    # Promotion scoring (#376)
    # ------------------------------------------------------------------ #
    promotion_weight_evidence_strength: float = 0.30
    """Weight for evidence_strength component in promotion scoring."""

    promotion_weight_cross_session_frequency: float = 0.25
    """Weight for cross_session_frequency component in promotion scoring."""

    promotion_weight_user_explicitness: float = 0.20
    """Weight for user_explicitness component in promotion scoring."""

    promotion_weight_retrieval_utility: float = 0.10
    """Weight for retrieval_utility component in promotion scoring."""

    promotion_weight_temporal_stability: float = 0.10
    """Weight for temporal_stability component in promotion scoring."""

    promotion_weight_contradiction_absence: float = 0.05
    """Weight for contradiction_absence component in promotion scoring."""

    promotion_threshold: float = 0.6
    """Score threshold for promoting a claim from session → long-term (0.0–1.0)."""

    promotion_min_sessions: int = 1
    """Minimum distinct sessions referencing a claim before it is eligible for promotion."""

    # ------------------------------------------------------------------ #
    # Context Window Calculator (#329)
    # ------------------------------------------------------------------ #
    context_calculator_enabled: bool = True
    """Enable the context window calculator for real-time token tracking.
    When False, ContextWindowCalculator is still usable but no metrics are emitted."""

    context_calculator_reserve_fraction: float = 0.15
    """Fraction of the total context window held back for model output.
    Range: 0.0–0.5. Default: 0.15 (15%)."""

    context_calculator_allocation_strategy: str = "proportional"
    """Budget allocation strategy name. Only 'proportional' is implemented;
    it distributes (total - reserve) across categories using category_weights."""

    context_calculator_category_weights: dict = field(default_factory=dict)
    """Per-category allocation weights (must sum to ~1.0 minus reserve_fraction).

    When empty (default), the built-in DEFAULT_CATEGORY_WEIGHTS are used:
        boot: 0.10, system: 0.10, user: 0.25, episodic: 0.15, semantic: 0.15,
        working_memory: 0.05, procedural: 0.05

    Override in cortex.toml under [cortex.context_calculator.category_weights].
    """

    # ------------------------------------------------------------------ #
    # Deployment tier
    # ------------------------------------------------------------------ #
    tier: CortexTier = CortexTier.LITE
    """Deployment tier (lite or full). Controls feature availability.

    Set to 'full' in your config TOML to enable advanced features.
    Defaults to 'lite' (entry-level, conservative feature set).
    """

    # ------------------------------------------------------------------ #
    # Debug Logging (debug/observability layer)
    # ------------------------------------------------------------------ #
    debug_logging_enabled: bool = False
    """Enable debug request/response and pipeline event logging.
    When False, the debug middleware is a zero-cost no-op passthrough."""

    debug_log_max_entries: int = 10000
    """Maximum entries in the in-memory debug ring buffer.
    Oldest entries are evicted when full. Restarts clear the buffer."""

    # ------------------------------------------------------------------ #
    # Shared Stats DB (cross-user usage metering + billing analytics)
    # ------------------------------------------------------------------ #
    stats_enabled: bool = True
    """Enable the shared stats.db for cross-user usage metering and Stripe billing."""

    stats_db_path: str = "/data/stats.db"
    """Path to the shared stats SQLite database. Separate from per-user memory DBs."""

    stats_prune_after_days: int = 30
    """Delete request_log entries older than this many days during nightly prune."""

    # ------------------------------------------------------------------ #
    # Owner / multitenancy
    # ------------------------------------------------------------------ #
    owner_id: str = "default"

    # ------------------------------------------------------------------ #
    # Drift threshold aliases (properly named)
    # ------------------------------------------------------------------ #

    @property
    def drift_restore_score(self) -> float:
        """Drift score at or above which auto-restore is triggered.

        Alias for drift_check_threshold (0.3 by default). The original name
        is misleading — "check" doesn't convey that a restore happens.
        """
        return self.drift_check_threshold

    @property
    def drift_alert_score(self) -> float:
        """Drift score at or above which an operator alert is triggered.

        Alias for drift_restore_threshold (0.5 by default). The original name
        is misleading — "restore" doesn't convey that an alert happens.
        """
        return self.drift_restore_threshold

    # ------------------------------------------------------------------ #
    # Helpers
    # ------------------------------------------------------------------ #

    @classmethod
    def from_toml(
        cls,
        path: Optional[str | Path] = None,
        *,
        overlay: Optional[dict[str, Any]] = None,
    ) -> "CortexConfig":
        """
        Build a CortexConfig from TOML file(s).

        Load order (later wins):
          1. Package defaults  (cortex/config/defaults.toml)
          2. ``path`` argument (user config, if provided)
          3. ``overlay`` dict  (in-process overrides, e.g. CLI flags)
        """
        if tomllib is None:
            raise ImportError(
                "tomllib not available. "
                "Install 'tomli' (`pip install tomli`) for Python < 3.11."
            )

        raw: dict[str, Any] = {}

        # 1. Package defaults
        if _DEFAULTS_PATH.exists():
            with open(_DEFAULTS_PATH, "rb") as fh:
                raw = tomllib.load(fh)

        # 2. User-supplied config
        if path is not None:
            user_path = Path(path)
            if user_path.exists():
                with open(user_path, "rb") as fh:
                    _deep_merge(raw, tomllib.load(fh))

        # 3. In-process overlay
        if overlay:
            _deep_merge(raw, overlay)

        config = cls._from_raw(raw)

        # 4. Environment variable overrides (CORTEX_<FIELD_NAME>)
        #    Applied last so env vars take highest precedence after overlay.
        env_overrides = cls._env_overrides()
        if env_overrides:
            for field_name, value in env_overrides.items():
                object.__setattr__(config, field_name, value)

        return config

    @classmethod
    def _from_raw(cls, raw: dict[str, Any]) -> "CortexConfig":
        """Flatten nested TOML dict into CortexConfig field values."""
        cortex = raw.get("cortex", {})
        storage = cortex.get("storage", {})
        embeddings = cortex.get("embeddings", {})
        llm = cortex.get("llm", {})
        extraction = cortex.get("extraction", {})
        reconciliation = cortex.get("reconciliation", {})
        token_budgets = cortex.get("token_budgets", {})
        drift = cortex.get("drift", {})
        brain_graph = cortex.get("brain_graph", {})
        cornerstones = cortex.get("cornerstones", {})
        confidence = cortex.get("confidence", {})
        fatigue = cortex.get("fatigue", {})
        circadian = cortex.get("circadian", {})
        retrieval = cortex.get("retrieval", {})
        deprecation = cortex.get("deprecation", {})
        budgets = cortex.get("budgets", {})

        kwargs: dict[str, Any] = {}

        # Top-level cortex keys
        if "version" in cortex:
            pass  # not stored in config dataclass
        if "storage_backend" in cortex:
            kwargs["storage_backend"] = cortex["storage_backend"]
        if "owner_id" in cortex:
            kwargs["owner_id"] = cortex["owner_id"]
        if "tier" in cortex:
            kwargs["tier"] = CortexTier(cortex["tier"])

        # Storage section
        kwargs.update({
            "storage_db_path": storage.get("db_path", "cortex.db"),
            "storage_enable_wal": storage.get("enable_wal", True),
            "storage_busy_timeout_ms": storage.get("busy_timeout_ms", 5000),
        })

        # Embeddings section
        if "model" in embeddings:
            kwargs["embedding_model"] = embeddings["model"]
        if "dim" in embeddings:
            kwargs["embedding_dim"] = embeddings["dim"]

        # LLM provider cascade (legacy — deprecated in v1.2.1)
        if "provider_priority" in llm:
            priority = llm["provider_priority"]
            if isinstance(priority, list) and all(isinstance(p, str) for p in priority):
                kwargs["llm_provider_priority"] = priority

        # Provider architecture (v1.2.1)
        providers = cortex.get("providers", {})

        # Backward compatibility: if old [cortex.llm] section has active user config
        # AND the new [cortex.providers] section is absent, log deprecation warning.
        _has_new_providers = bool(providers.get("mode") or providers.get("llm") or providers.get("embedding"))
        _has_old_llm_config = llm and any(k in llm for k in ("venice",))
        if _has_old_llm_config and not _has_new_providers:
            import warnings
            warnings.warn(
                "[cortex.llm] config section is deprecated in v1.2.1. "
                "Use [cortex.providers] instead. Old settings will be mapped "
                "automatically but will be removed in v1.3.0.",
                DeprecationWarning,
                stacklevel=2,
            )
        if "mode" in providers:
            kwargs["provider_mode"] = str(providers["mode"])

        providers_llm = providers.get("llm", {})
        if "provider" in providers_llm:
            kwargs["provider_llm_provider"] = str(providers_llm["provider"])
        if "model" in providers_llm:
            kwargs["provider_llm_model"] = str(providers_llm["model"])
        if "api_key" in providers_llm:
            kwargs["provider_llm_api_key"] = str(providers_llm["api_key"])
        if "base_url" in providers_llm:
            kwargs["provider_llm_base_url"] = str(providers_llm["base_url"])

        providers_embedding = providers.get("embedding", {})
        if "provider" in providers_embedding:
            kwargs["provider_embedding_provider"] = str(providers_embedding["provider"])
        if "model" in providers_embedding:
            kwargs["provider_embedding_model"] = str(providers_embedding["model"])
        if "api_key" in providers_embedding:
            kwargs["provider_embedding_api_key"] = str(providers_embedding["api_key"])
        if "base_url" in providers_embedding:
            kwargs["provider_embedding_base_url"] = str(providers_embedding["base_url"])
        if "dim" in providers_embedding:
            kwargs["provider_embedding_dim"] = int(providers_embedding["dim"])

        providers_usage = providers.get("usage", {})
        if "enabled" in providers_usage:
            kwargs["provider_usage_enabled"] = bool(providers_usage["enabled"])
        if "tier" in providers_usage:
            kwargs["provider_usage_tier"] = str(providers_usage["tier"])
        if "monthly_token_cap" in providers_usage:
            kwargs["provider_usage_monthly_token_cap"] = int(providers_usage["monthly_token_cap"])
        if "monthly_embedding_cap" in providers_usage:
            kwargs["provider_usage_monthly_embedding_cap"] = int(providers_usage["monthly_embedding_cap"])

        # Extraction
        if "enabled" in extraction:
            kwargs["extraction_enabled"] = bool(extraction["enabled"])
        if "model" in extraction:
            kwargs["extraction_model"] = extraction["model"]
        if "coding_noise_filter" in extraction:
            kwargs["coding_noise_filter"] = extraction["coding_noise_filter"]
        if "batch_size" in extraction:
            kwargs["extraction_batch_size"] = int(extraction["batch_size"])
        if "custom_instructions" in extraction:
            kwargs["extraction_custom_instructions"] = str(extraction["custom_instructions"])
        if "max_claims_per_extraction" in extraction:
            kwargs["max_claims_per_extraction"] = int(extraction["max_claims_per_extraction"])
        if "max_entities_per_extraction" in extraction:
            kwargs["max_entities_per_extraction"] = int(extraction["max_entities_per_extraction"])
        if "max_concurrent_extractions" in extraction:
            kwargs["max_concurrent_extractions"] = int(extraction["max_concurrent_extractions"])

        # Reconciliation
        if "model" in reconciliation:
            kwargs["reconciliation_model"] = reconciliation["model"]
        if "dialectic_model" in reconciliation:
            val = reconciliation["dialectic_model"]
            kwargs["dialectic_model"] = str(val) if val is not None else None

        # Token budgets
        if "cornerstone" in token_budgets:
            kwargs["cornerstone_token_budget"] = token_budgets["cornerstone"]
        if "retrieval" in token_budgets:
            kwargs["retrieval_token_budget"] = token_budgets["retrieval"]
        if "total_memory" in token_budgets:
            kwargs["total_memory_token_budget"] = token_budgets["total_memory"]
        # Issue #374 — hard context-window budget fractions
        if "memory_fraction" in token_budgets:
            kwargs["token_budget_memory_fraction"] = float(token_budgets["memory_fraction"])
        if "user_fraction" in token_budgets:
            kwargs["token_budget_user_fraction"] = float(token_budgets["user_fraction"])
        if "reserve_fraction" in token_budgets:
            kwargs["token_budget_reserve_fraction"] = float(token_budgets["reserve_fraction"])
        if "slot_priorities" in token_budgets:
            priorities = token_budgets["slot_priorities"]
            if isinstance(priorities, list) and all(isinstance(p, str) for p in priorities):
                kwargs["token_budget_slot_priorities"] = priorities

        # Drift detection
        if "check_threshold" in drift:
            kwargs["drift_check_threshold"] = drift["check_threshold"]
        if "restore_threshold" in drift:
            kwargs["drift_restore_threshold"] = drift["restore_threshold"]
        if "anti_compression_confidence" in drift:
            kwargs["anti_compression_confidence"] = drift["anti_compression_confidence"]

        # Brain graph watcher
        if "watch_dirs" in brain_graph:
            kwargs["brain_graph_watch_dirs"] = brain_graph["watch_dirs"]
        if "include_patterns" in brain_graph:
            kwargs["brain_graph_include_patterns"] = brain_graph["include_patterns"]
        if "exclude_patterns" in brain_graph:
            kwargs["brain_graph_exclude_patterns"] = brain_graph["exclude_patterns"]
        if "debounce_seconds" in brain_graph:
            kwargs["brain_graph_debounce_seconds"] = brain_graph["debounce_seconds"]

        # Cornerstones
        if "max_count" in cornerstones:
            kwargs["max_cornerstone_count"] = cornerstones["max_count"]
        if "drift_threshold" in cornerstones:
            kwargs["cornerstone_drift_threshold"] = cornerstones["drift_threshold"]
        if "auto_apply" in cornerstones:
            kwargs["cornerstone_auto_apply"] = cornerstones["auto_apply"]
        if "auto_apply_threshold" in cornerstones:
            val = cornerstones["auto_apply_threshold"]
            kwargs["cornerstone_auto_apply_threshold"] = float(val) if val is not None else None

        # Confidence defaults
        if "session_default" in confidence:
            kwargs["session_confidence_default"] = confidence["session_default"]
        if "promoted_default" in confidence:
            kwargs["promoted_confidence_default"] = confidence["promoted_default"]

        # Fatigue thresholds
        if "nap_threshold" in fatigue:
            kwargs["session_fatigue_nap_threshold"] = fatigue["nap_threshold"]
        if "sleep_threshold" in fatigue:
            kwargs["session_fatigue_sleep_threshold"] = fatigue["sleep_threshold"]

        # Circadian
        if "auto_sleep_after_minutes" in circadian:
            kwargs["auto_sleep_after_minutes"] = circadian["auto_sleep_after_minutes"]
        if "dream_cycle_enabled" in circadian:
            kwargs["dream_cycle_enabled"] = circadian["dream_cycle_enabled"]
        if "dream_cycle_hour_utc" in circadian:
            kwargs["dream_cycle_hour_utc"] = circadian["dream_cycle_hour_utc"]
        if "idle_timeout_sec" in circadian:
            kwargs["dream_cycle_idle_timeout_sec"] = circadian["idle_timeout_sec"]
        if "dream_cycle_timeout_sec" in circadian:
            kwargs["dream_cycle_timeout_sec"] = int(circadian["dream_cycle_timeout_sec"])
        if "fatigue_interval_sec" in circadian:
            kwargs["fatigue_interval_sec"] = int(circadian["fatigue_interval_sec"])

        # Retrieval
        if "default_strategy" in retrieval:
            kwargs["retrieval_default_strategy"] = retrieval["default_strategy"]
        if "max_tokens" in retrieval:
            kwargs["retrieval_max_tokens"] = retrieval["max_tokens"]
        if "vector_weight" in retrieval:
            kwargs["retrieval_vector_weight"] = retrieval["vector_weight"]
        if "bm25_weight" in retrieval:
            kwargs["retrieval_bm25_weight"] = retrieval["bm25_weight"]
        if "recency_weight" in retrieval:
            kwargs["retrieval_recency_weight"] = float(retrieval["recency_weight"])
        if "temporal_boost_weight" in retrieval:
            kwargs["retrieval_temporal_boost_weight"] = float(retrieval["temporal_boost_weight"])
        if "rrf_k" in retrieval:
            kwargs["retrieval_rrf_k"] = int(retrieval["rrf_k"])

        # Retrieval rerank (Voyage rerank-2.5 family, #502)
        rerank = retrieval.get("rerank", {})
        if "enabled" in rerank:
            kwargs["retrieval_rerank_enabled"] = bool(rerank["enabled"])
        if "model" in rerank:
            kwargs["retrieval_rerank_model"] = str(rerank["model"])
        if "top_k" in rerank:
            kwargs["retrieval_rerank_top_k"] = int(rerank["top_k"])
        if "initial_candidates" in rerank:
            kwargs["retrieval_rerank_initial_candidates"] = int(rerank["initial_candidates"])

        # Deprecation
        # grace_period_hours → cooling_days (convert hours → days, min 1)
        if "grace_period_hours" in deprecation:
            kwargs["deprecation_cooling_days"] = max(
                1, deprecation["grace_period_hours"] // 24
            )
        if "archive_days" in deprecation:
            kwargs["deprecation_archive_days"] = deprecation["archive_days"]

        # LLM call budgets
        if "max_llm_calls_per_session" in budgets:
            kwargs["max_llm_calls_per_session"] = budgets["max_llm_calls_per_session"]
        if "max_extraction_calls_per_batch" in budgets:
            kwargs["max_extraction_calls_per_batch"] = budgets["max_extraction_calls_per_batch"]

        # Commitments / open loops
        commitments = cortex.get("commitments", {})
        if "open_loop_stale_threshold" in commitments:
            kwargs["open_loop_stale_threshold"] = int(commitments["open_loop_stale_threshold"])

        # Promotion scoring (#376)
        promotion = cortex.get("promotion", {})
        promotion_weights = promotion.get("weights", {})
        if "evidence_strength" in promotion_weights:
            kwargs["promotion_weight_evidence_strength"] = float(promotion_weights["evidence_strength"])
        if "cross_session_frequency" in promotion_weights:
            kwargs["promotion_weight_cross_session_frequency"] = float(promotion_weights["cross_session_frequency"])
        if "user_explicitness" in promotion_weights:
            kwargs["promotion_weight_user_explicitness"] = float(promotion_weights["user_explicitness"])
        if "retrieval_utility" in promotion_weights:
            kwargs["promotion_weight_retrieval_utility"] = float(promotion_weights["retrieval_utility"])
        if "temporal_stability" in promotion_weights:
            kwargs["promotion_weight_temporal_stability"] = float(promotion_weights["temporal_stability"])
        if "contradiction_absence" in promotion_weights:
            kwargs["promotion_weight_contradiction_absence"] = float(promotion_weights["contradiction_absence"])
        if "threshold" in promotion:
            kwargs["promotion_threshold"] = float(promotion["threshold"])
        if "min_sessions" in promotion:
            kwargs["promotion_min_sessions"] = int(promotion["min_sessions"])

        # Auth (#542)
        auth_section = cortex.get("auth", {})
        if "jwt_secret" in auth_section:
            kwargs["auth_jwt_secret"] = str(auth_section["jwt_secret"])
        if "api_key" in auth_section:
            kwargs["auth_api_key"] = str(auth_section["api_key"])
        if "require_auth" in auth_section:
            kwargs["auth_require_auth"] = bool(auth_section["require_auth"])
        if "public_endpoints" in auth_section:
            eps = auth_section["public_endpoints"]
            if isinstance(eps, list) and all(isinstance(e, str) for e in eps):
                kwargs["auth_public_endpoints"] = eps

        # Rate limiting (#71)
        rate_limiting = cortex.get("rate_limiting", {})
        if "rpm" in rate_limiting:
            kwargs["rate_limit_rpm"] = int(rate_limiting["rpm"])
        if "burst" in rate_limiting:
            kwargs["rate_limit_burst"] = int(rate_limiting["burst"])

        # Pagination (L2 audit fix)
        pagination = cortex.get("pagination", {})
        if "max_page_size" in pagination:
            kwargs["max_page_size"] = int(pagination["max_page_size"])

        # Remember body size limit (Issue #486)
        api_section = cortex.get("api", {})
        if "max_remember_body_bytes" in api_section:
            kwargs["max_remember_body_bytes"] = int(api_section["max_remember_body_bytes"])

        # Log retention / TTL (L3 audit fix)
        log_retention = cortex.get("log_retention", {})
        if "retention_days" in log_retention:
            kwargs["log_retention_days"] = int(log_retention["retention_days"])

        # Working Memory Cache (#371)
        working_memory = cortex.get("working_memory", {})
        if "enabled" in working_memory:
            kwargs["working_memory_enabled"] = bool(working_memory["enabled"])
        if "max_items" in working_memory:
            kwargs["working_memory_max_items"] = int(working_memory["max_items"])
        if "default_ttl_seconds" in working_memory:
            kwargs["working_memory_default_ttl_seconds"] = int(working_memory["default_ttl_seconds"])

        # Embedding Cache (v1.2.1)
        embedding_cache = cortex.get("embedding_cache", {})
        if "enabled" in embedding_cache:
            kwargs["embedding_cache_enabled"] = bool(embedding_cache["enabled"])
        if "max_size" in embedding_cache:
            kwargs["embedding_cache_max_size"] = int(embedding_cache["max_size"])
        if "ttl_seconds" in embedding_cache:
            kwargs["embedding_cache_ttl_seconds"] = int(embedding_cache["ttl_seconds"])

        # DB Pool (#551)
        db_pool = cortex.get("db_pool", {})
        if "max_open" in db_pool:
            kwargs["db_pool_max_open"] = int(db_pool["max_open"])
        if "idle_timeout_seconds" in db_pool:
            kwargs["db_pool_idle_timeout_seconds"] = float(db_pool["idle_timeout_seconds"])
        if "data_dir" in db_pool:
            kwargs["db_pool_data_dir"] = str(db_pool["data_dir"])

        # Model routing (#375)
        model_routing = cortex.get("model_routing", {})
        if "enabled" in model_routing:
            kwargs["model_routing_enabled"] = bool(model_routing["enabled"])
        tiers = model_routing.get("tiers", {})
        if "fast" in tiers:
            kwargs["model_routing_fast"] = str(tiers["fast"])
        if "main" in tiers:
            kwargs["model_routing_main"] = str(tiers["main"])
        if "reasoning" in tiers:
            kwargs["model_routing_reasoning"] = str(tiers["reasoning"])
        if "operation_map" in model_routing:
            op_map = model_routing["operation_map"]
            if isinstance(op_map, dict):
                kwargs["model_routing_operation_map"] = {
                    str(k): str(v) for k, v in op_map.items()
                }

        # Metrics (#368)
        metrics_section = cortex.get("metrics", {})
        if "enabled" in metrics_section:
            kwargs["metrics_enabled"] = bool(metrics_section["enabled"])
        if "export_format" in metrics_section:
            kwargs["metrics_export_format"] = str(metrics_section["export_format"])

        # Quality Gates (#368)
        quality_gates_section = cortex.get("quality_gates", {})
        if "enabled" in quality_gates_section:
            kwargs["quality_gates_enabled"] = bool(quality_gates_section["enabled"])

        # Feature Flags (#368)
        feature_flags_section = cortex.get("feature_flags", {})
        if "enabled" in feature_flags_section:
            kwargs["feature_flags_enabled"] = bool(feature_flags_section["enabled"])

        # Circuit breaker (#347)
        circuit_breaker = cortex.get("circuit_breaker", {})
        if "enabled" in circuit_breaker:
            kwargs["circuit_breaker_enabled"] = bool(circuit_breaker["enabled"])
        if "threshold" in circuit_breaker:
            kwargs["circuit_breaker_threshold"] = int(circuit_breaker["threshold"])
        if "cooldown_sec" in circuit_breaker:
            kwargs["circuit_breaker_cooldown_sec"] = int(circuit_breaker["cooldown_sec"])

        # Re-Retrieval (#372)
        re_retrieval = cortex.get("re_retrieval", {})
        if "enabled" in re_retrieval:
            kwargs["re_retrieval_enabled"] = bool(re_retrieval["enabled"])
        if "max_per_turn" in re_retrieval:
            kwargs["re_retrieval_max_per_turn"] = int(re_retrieval["max_per_turn"])
        if "topic_shift_threshold" in re_retrieval:
            kwargs["re_retrieval_topic_shift_threshold"] = float(re_retrieval["topic_shift_threshold"])
        if "min_budget_tokens" in re_retrieval:
            kwargs["re_retrieval_min_budget_tokens"] = int(re_retrieval["min_budget_tokens"])

        # Context Compiler (#363)
        context_compiler = cortex.get("context_compiler", {})
        if "enabled" in context_compiler:
            kwargs["context_compiler_enabled"] = bool(context_compiler["enabled"])
        if "max_bundles" in context_compiler:
            kwargs["context_compiler_max_bundles"] = int(context_compiler["max_bundles"])
        if "budget_overflow_strategy" in context_compiler:
            kwargs["context_compiler_budget_overflow_strategy"] = str(context_compiler["budget_overflow_strategy"])

        # Boot Loader (#361)
        boot = cortex.get("boot", {})
        if "enabled" in boot:
            kwargs["boot_enabled"] = bool(boot["enabled"])
        if "max_recent_sessions" in boot:
            kwargs["boot_max_recent_sessions"] = int(boot["max_recent_sessions"])
        if "min_stability_threshold" in boot:
            kwargs["boot_min_stability_threshold"] = float(boot["min_stability_threshold"])
        if "budget_fraction" in boot:
            kwargs["boot_budget_fraction"] = float(boot["budget_fraction"])

        # Retrieval Policy Engine (#362)
        retrieval_policy = cortex.get("retrieval_policy", {})
        if "enabled" in retrieval_policy:
            kwargs["retrieval_policy_enabled"] = bool(retrieval_policy["enabled"])
        if "confidence_threshold" in retrieval_policy:
            kwargs["retrieval_policy_confidence_threshold"] = float(retrieval_policy["confidence_threshold"])
        if "default_mode" in retrieval_policy:
            kwargs["retrieval_policy_default_mode"] = str(retrieval_policy["default_mode"])

        # Context Window Calculator (#329)
        context_calculator = cortex.get("context_calculator", {})
        if "enabled" in context_calculator:
            kwargs["context_calculator_enabled"] = bool(context_calculator["enabled"])
        if "reserve_fraction" in context_calculator:
            kwargs["context_calculator_reserve_fraction"] = float(context_calculator["reserve_fraction"])
        if "allocation_strategy" in context_calculator:
            kwargs["context_calculator_allocation_strategy"] = str(context_calculator["allocation_strategy"])
        category_weights = context_calculator.get("category_weights", {})
        if category_weights and isinstance(category_weights, dict):
            kwargs["context_calculator_category_weights"] = {
                str(k): float(v) for k, v in category_weights.items()
            }

        # Debug logging
        debug = cortex.get("debug", {})
        if "enabled" in debug:
            kwargs["debug_logging_enabled"] = bool(debug["enabled"])
        if "max_entries" in debug:
            kwargs["debug_log_max_entries"] = int(debug["max_entries"])

        # Shared stats DB
        stats = cortex.get("stats", {})
        if "enabled" in stats:
            kwargs["stats_enabled"] = bool(stats["enabled"])
        if "db_path" in stats:
            kwargs["stats_db_path"] = str(stats["db_path"])
        if "prune_after_days" in stats:
            kwargs["stats_prune_after_days"] = int(stats["prune_after_days"])

        return cls(**{k: v for k, v in kwargs.items() if k in {f.name for f in fields(cls)}})

    @classmethod
    def _env_overrides(cls) -> dict[str, Any]:
        """Build a raw-style overlay dict from CORTEX_* environment variables.

        Environment variables are mapped to CortexConfig field names:
            CORTEX_<FIELD_NAME_UPPER> → field_name_lower

        Examples:
            CORTEX_DB_PATH=/data/cortex.db     → storage_db_path = "/data/cortex.db"
            CORTEX_EXTRACTION_ENABLED=false     → extraction_enabled = False
            CORTEX_EMBEDDING_DIM=768            → embedding_dim = 768

        The mapping goes directly into kwargs for the CortexConfig constructor,
        bypassing the TOML section structure.  Type coercion is based on the
        dataclass field's default value type.

        Returns:
            Dict suitable for passing as ``**kwargs`` to ``CortexConfig()``.
            Wrapped in ``{"__env__": {...}}`` so ``_from_raw`` can pick it up.
        """
        valid_fields = {f.name: f for f in fields(cls)}
        env_prefix = "CORTEX_"
        overrides: dict[str, Any] = {}

        for key, value in os.environ.items():
            if not key.startswith(env_prefix):
                continue
            field_name = key[len(env_prefix):].lower()
            if field_name not in valid_fields:
                continue

            f = valid_fields[field_name]
            try:
                overrides[field_name] = _coerce_env_value(value, f)
            except (ValueError, TypeError):
                import warnings
                warnings.warn(
                    f"Ignoring env var {key}={value!r}: "
                    f"cannot coerce to {f.type}",
                    stacklevel=2,
                )

        return overrides

    @classmethod
    def defaults(cls) -> "CortexConfig":
        """Return config loaded purely from the package defaults.toml."""
        return cls.from_toml()

    def db_path_resolved(self, base: Optional[str | Path] = None) -> Path:
        """Resolve storage_db_path relative to *base* (or cwd if None).

        Args:
            base: Base directory for relative paths. If None, uses os.getcwd().

        Returns:
            Absolute Path to the database file.
        """
        p = Path(self.storage_db_path)
        if p.is_absolute():
            return p
        if base is not None:
            return Path(base) / p
        return Path.cwd() / p


def validate_config(config: "CortexConfig") -> List[str]:
    """
    Validate a CortexConfig instance and return a list of warning/error strings.

    Rules (#73):
    - Required fields must exist (they always do given dataclass defaults, but
      we check that they have non-sentinel values here).
    - ``extraction_model`` and ``reconciliation_model`` must be non-empty strings.
    - ``embedding_dim`` must be > 0.
    - The directory component of ``storage_db_path`` must exist (warns if not).
    - ``rate_limit_rpm`` must be >= 0; ``rate_limit_burst`` must be >= 0.

    Returns:
        List of warning strings.  Empty list = config is valid.
        Callers should log these as warnings; they do NOT raise exceptions so
        that graceful degradation is preserved.
    """
    issues: List[str] = []

    try:
        # --- Model name validation ---
        if not config.extraction_model or not config.extraction_model.strip():
            issues.append(
                "extraction_model is empty — LLM extraction will fail at runtime"
            )
        if not config.reconciliation_model or not config.reconciliation_model.strip():
            issues.append(
                "reconciliation_model is empty — reconciliation will fail at runtime"
            )

        # --- Embedding dimension ---
        if config.embedding_dim <= 0:
            issues.append(
                f"embedding_dim must be > 0, got {config.embedding_dim}"
            )

        # --- DB path directory existence ---
        db_path = Path(config.storage_db_path)
        parent = db_path.parent if not db_path.is_absolute() else db_path.parent
        # For relative paths resolve against cwd for the warning check
        resolved_parent = (Path.cwd() / db_path).parent if not db_path.is_absolute() else parent
        if not resolved_parent.exists():
            issues.append(
                f"db_path directory does not exist: '{resolved_parent}' "
                f"(from storage_db_path='{config.storage_db_path}'). "
                "Database cannot be created until this directory exists."
            )

        # --- Rate limit sanity ---
        if config.rate_limit_rpm < 0:
            issues.append(
                f"rate_limit_rpm must be >= 0 (0 = disabled), got {config.rate_limit_rpm}"
            )
        if config.rate_limit_burst < 0:
            issues.append(
                f"rate_limit_burst must be >= 0, got {config.rate_limit_burst}"
            )

        # --- Extraction guardrails sanity ---
        if config.max_claims_per_extraction < 0:
            issues.append(
                f"max_claims_per_extraction must be >= 0 (0 = disabled), got {config.max_claims_per_extraction}"
            )
        if config.max_entities_per_extraction < 0:
            issues.append(
                f"max_entities_per_extraction must be >= 0 (0 = disabled), got {config.max_entities_per_extraction}"
            )
        if config.max_concurrent_extractions < 1:
            issues.append(
                f"max_concurrent_extractions must be >= 1, got {config.max_concurrent_extractions}"
            )

        # --- Token budget consistency ---
        if config.total_memory_token_budget < (
            config.cornerstone_token_budget + config.retrieval_token_budget
        ):
            issues.append(
                "total_memory_token_budget is less than cornerstone_token_budget + "
                "retrieval_token_budget — memory context will be silently truncated"
            )

        # --- Working memory validation (audit fix) ---
        if config.working_memory_max_items < 1:
            issues.append(
                f"working_memory_max_items must be >= 1, got {config.working_memory_max_items}"
            )
        if config.working_memory_default_ttl_seconds < 0:
            issues.append(
                f"working_memory_default_ttl_seconds must be >= 0, got {config.working_memory_default_ttl_seconds}"
            )

        # --- Token budget fraction validation (audit fix) ---
        for frac_name in (
            "token_budget_memory_fraction",
            "token_budget_user_fraction",
            "token_budget_reserve_fraction",
        ):
            frac_val = getattr(config, frac_name, None)
            if frac_val is not None and (frac_val < 0.0 or frac_val > 1.0):
                issues.append(
                    f"{frac_name} must be in [0.0, 1.0], got {frac_val}"
                )

        # --- Promotion scoring validation (audit fix C-HIGH-3) ---
        promotion_weight_sum = (
            config.promotion_weight_evidence_strength
            + config.promotion_weight_cross_session_frequency
            + config.promotion_weight_user_explicitness
            + config.promotion_weight_retrieval_utility
            + config.promotion_weight_temporal_stability
            + config.promotion_weight_contradiction_absence
        )
        if abs(promotion_weight_sum - 1.0) > 0.01:
            issues.append(
                f"promotion weights must sum to 1.0 (±0.01), got {promotion_weight_sum:.4f}"
            )
        if not (0.0 <= config.promotion_threshold <= 1.0):
            issues.append(
                f"promotion_threshold must be in [0.0, 1.0], got {config.promotion_threshold}"
            )
        if config.promotion_min_sessions < 1:
            issues.append(
                f"promotion_min_sessions must be >= 1, got {config.promotion_min_sessions}"
            )
    except (TypeError, AttributeError):
        # Config is not a real CortexConfig (e.g. MagicMock or _FakeConfig in tests).
        # validate_config is a best-effort guard — never block startup.
        pass

    return issues


def is_full_tier(config: "CortexConfig") -> bool:
    """Return True if the config specifies the FULL deployment tier.

    Use this as the canonical feature gate throughout the codebase instead of
    comparing config.tier directly — it reads more clearly at call sites.

    Args:
        config: A CortexConfig instance.

    Returns:
        True when config.tier == CortexTier.FULL, False otherwise.
    """
    return config.tier == CortexTier.FULL


def _coerce_env_value(raw: str, f: "dataclass_field") -> Any:  # noqa: F821
    """Coerce a string environment variable value to the type of a dataclass field.

    Supports: str, int, float, bool, Optional variants, and List[str] (comma-separated).
    When the default is None (Optional fields), inspects the type annotation to
    determine the correct target type.
    """
    import typing

    # Determine the target type from the field's default value
    default = f.default if f.default is not dataclass_MISSING else (
        f.default_factory() if f.default_factory is not dataclass_MISSING else None
    )

    if isinstance(default, bool):
        return raw.lower() in ("true", "1", "yes", "on")
    elif isinstance(default, int):
        return int(raw)
    elif isinstance(default, float):
        return float(raw)
    elif isinstance(default, list):
        # Comma-separated list
        return [item.strip() for item in raw.split(",") if item.strip()]
    elif default is None:
        # Optional[T] field — inspect the type annotation to find T
        hint = f.type if isinstance(f.type, type) else None
        if hint is None and isinstance(f.type, str):
            # String annotations — try to resolve common Optional patterns
            type_str = f.type
            # Handle "Optional[float]", "float | None", etc.
            for candidate_type, coercer in [
                ("float", float), ("int", int), ("bool", lambda v: v.lower() in ("true", "1", "yes", "on")),
            ]:
                if candidate_type in type_str.lower():
                    return coercer(raw)
            # Fall through to string
            return raw
        # Try typing.get_args for runtime-available type hints
        type_hint = f.type
        args = typing.get_args(type_hint)
        # For Optional[X], args is (X, NoneType)
        inner_types = [a for a in args if a is not type(None)]
        if inner_types:
            inner = inner_types[0]
            if inner is float:
                return float(raw)
            elif inner is int:
                return int(raw)
            elif inner is bool:
                return raw.lower() in ("true", "1", "yes", "on")
        # Default: string (for Optional[str] or unresolvable)
        return raw
    else:
        # Default: string
        return raw


def _deep_merge(base: dict, override: dict) -> None:
    """Merge *override* into *base* in-place (nested dicts merged recursively)."""
    for k, v in override.items():
        if k in base and isinstance(base[k], dict) and isinstance(v, dict):
            _deep_merge(base[k], v)
        else:
            base[k] = v
