Skip to main content

car_server_core/
evolution.rs

1//! Self-evolution governor — live daemon wiring (arXiv 2507.21046, the
2//! remaining daemon steps from `docs/proposals/self-evolution-governor.md` /
3//! `docs/proposals/remaining-integration-work.md` §3).
4//!
5//! Three pieces live here:
6//!
7//! 1. **Signal populaters for the components memgine can't see.** The engine
8//!    folds Memory / Skills / Context off its own graph
9//!    (`MemgineEngine::evolution_component_states`); the daemon appends:
10//!    - **Harness** ← [`harness_component_from_events`] over the session event
11//!      log via `car_eventlog::harness_adapt::diagnose`. Pressure =
12//!      `min(1, implicated / total_events)` where `implicated` sums each
13//!      recurring intervention's `evidence_count` — the fraction of logged
14//!      events implicated in a *recurring* failure pattern (one-offs are noise
15//!      by the diagnosis's own rule). Evidence = the number of events
16//!      diagnosed over.
17//!    - **Tools** ← [`tools_component_from_connectors`] over the live
18//!      connector registry. Pressure = disconnected / total connectors.
19//!      Evidence = the connector count — `ConnectorStatus` carries no
20//!      per-connector call counters, so the population size is the honest
21//!      evidence figure (min_evidence 1: even one broken connector is real).
22//! 2. **The real executor** behind `evolution.run` and the cadence timer:
23//!    Memory → `consolidate()` sized by `maintenance::decide_maintenance`,
24//!    Skills → `evolve_skills(failed_events, domain)` over failure traces
25//!    folded from the event log ([`failed_trace_events`]), Harness → the
26//!    `harness_evolution` diagnose→gate→apply loop (HITL-gated; handler.rs
27//!    owns that arm because it needs the session `ApprovalLedger`), Context →
28//!    [`run_context_evolution`], the `context_evolution` loop over the engine's
29//!    own conversation-layer saturation. That arm now has TWO authorization
30//!    paths: an opt-in **pre-activation grade** (`context_measure` → two bench
31//!    replays over the same split, one under the live `MemgineConfig` and one
32//!    under it plus the patch, graded on task outcomes by
33//!    `EvolutionAgent::evaluate_context`, promoting or rejecting with no human
34//!    in the loop) and, for everything the grade did not run on or did not
35//!    decide, the original diagnose→approve→apply→measure→revert human path
36//!    with its post-apply margin check retained as defence in depth. Tools has
37//!    no mechanism *by decision* and
38//!    says so ([`TOOLS_OUT_OF_SCOPE_REASON`]) rather than erroring: a scope
39//!    boundary reported as a failed step is how a working system reads as a
40//!    broken one.
41//! 3. **The autonomous cadence timer** ([`spawn_evolution_cadence`]): one
42//!    background task over the daemon's *shared* engine, opt-in via
43//!    `.car/config.toml` `evolution_interval_secs` (absent/0 = off). Guarded
44//!    by [`CycleGuard`] so a slow cycle is never overlapped by the next tick;
45//!    each cycle's outcome is appended as an `EvolutionTriggered` event to a
46//!    dedicated journal (`<journal_dir>/evolution.jsonl`). The task dies with
47//!    the daemon's tokio runtime, like every other boot timer.
48
49use std::collections::HashMap;
50use std::path::PathBuf;
51use std::sync::Arc;
52
53use car_connectors::ConnectorStatus;
54use car_eventlog::{Event, EventKind, EventLog, RetentionPolicy};
55use car_memgine::maintenance::{decide_maintenance, MaintenanceDecision, MaintenanceInput};
56use car_memgine::self_evolution::{
57    run_evolution_cycle, ComponentState, EvolutionCycleReport, EvolutionOutcome, EvolutionPolicy,
58    EvolutionSignals, EvolvableComponent,
59};
60use car_memgine::{MemgineEngine, TraceEvent};
61use serde::{Deserialize, Serialize};
62use serde_json::Value;
63
64use crate::session::ServerState;
65
66// ---------------------------------------------------------------------------
67// The in-daemon harness evaluator seam
68// ---------------------------------------------------------------------------
69
70/// What the daemon needs in order to grade a harness candidate ITSELF.
71///
72/// Implemented above this crate (`car-bench` owns the task suite and the
73/// assistant-loop replay) and installed on
74/// [`ServerState`] by the daemon binary, because
75/// `car-bench` depends on `car-server-core` and the dependency cannot run the
76/// other way.
77///
78/// Injected rather than called directly for the same reason the rest of this
79/// module injects execution: the `evolution.run` orchestration — mutual
80/// exclusion, the dry-run rule, which mutations are measured at all, and the
81/// gate wiring — stays unit-testable against a stub that spends no model calls.
82#[async_trait::async_trait]
83pub trait HarnessMeasurer: Send + Sync {
84    /// Replay the requested split in process under `harness_config` and
85    /// `memgine_config`, and fold the runs' own event logs into one
86    /// `HarnessMetrics`.
87    ///
88    /// `harness_config` is the operating config the replay must run UNDER —
89    /// `None` means the runtime default. Measuring a candidate without
90    /// installing its config produces a run byte-identical to the baseline, so
91    /// an implementation that ignores this argument reports a comparison of a
92    /// config with itself.
93    ///
94    /// `memgine_config` is the **context-assembly** config the replay's memory
95    /// fixtures are seeded under — `None` means the memgine default. It is the
96    /// second pillar's twin of the argument above, and the identical warning
97    /// applies: a "candidate" context measurement taken without installing the
98    /// candidate config is a second measurement of the default, and the gate
99    /// would be comparing a config with itself. It matters because a bench task
100    /// that declares a `memory:` fixture is replayed with a real memgine
101    /// attached and the shipped `recall` tool advertised, so the assembled
102    /// context — and therefore the answer the task is graded on — genuinely
103    /// moves with `conversation_keep_recent`.
104    ///
105    /// ONE trait, not one per pillar: the replay is the same replay over the
106    /// same split with the same seed, and only which config is varied differs.
107    /// Two traits would let the two arms drift into measuring different task
108    /// sets, at which point a context grade and a harness grade stop being
109    /// comparable to each other or to a `car-bench-harness` CLI run.
110    ///
111    /// Every call must be a REAL measurement or an `Err`. Returning a
112    /// default/zero document is the one thing this trait must never do: an
113    /// all-zero `HarnessMetrics` is structurally indistinguishable from a
114    /// measurement of a harness that spends nothing, and the regression gate
115    /// would read it as one.
116    async fn measure(
117        &self,
118        request: &HarnessMeasureRequest,
119        harness_config: Option<&car_memgine::HarnessConfig>,
120        memgine_config: Option<&car_memgine::MemgineConfig>,
121    ) -> Result<car_eventlog::harness_metrics::HarnessMetrics, String>;
122}
123
124fn default_split() -> String {
125    "held-out".to_string()
126}
127
128fn default_held_in_fraction() -> f64 {
129    0.5
130}
131
132fn default_max_turns() -> u32 {
133    20
134}
135
136/// What to replay. Serde-derived: it is the `harness_measure` param of
137/// `evolution.run` verbatim.
138///
139/// The defaults mirror `car_bench::harness_bench::HarnessBenchConfig::default()`
140/// exactly — held-out, a 0.5 held-in fraction, seed 0, 20 turns — so an
141/// in-daemon measurement and a `car-bench-harness` CLI run are the *same*
142/// measurement over the *same* task split. A default that drifted from the
143/// CLI's would silently make an operator's baseline file incomparable with a
144/// daemon-measured candidate.
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146pub struct HarnessMeasureRequest {
147    /// Model id under test. REQUIRED — metrics not attributable to a model are
148    /// not metrics, and a router substitution would attribute a token count to
149    /// the wrong model.
150    pub model: String,
151    /// Which half of the deterministic split. Default `"held-out"` — the
152    /// regression-gate half, the tasks a mutation was not tuned on.
153    #[serde(default = "default_split")]
154    pub split: String,
155    /// Share of tasks assigned to held-in.
156    #[serde(default = "default_held_in_fraction")]
157    pub held_in_fraction: f64,
158    /// Seed for the deterministic split shuffle. Fix it for a lineage: two
159    /// runs at different seeds are over different task sets.
160    #[serde(default)]
161    pub split_seed: u64,
162    /// Assistant-loop turn cap per task.
163    #[serde(default = "default_max_turns")]
164    pub max_turns: u32,
165    /// Override the task suite directory. `None` = the suite built into the
166    /// binary.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub tasks_dir: Option<PathBuf>,
169}
170
171/// Read the `harness_measure` param of `evolution.run`. `Ok(None)` = not
172/// requested (measuring is strictly opt-in); a malformed object is an error,
173/// never a silently defaulted request — a typo'd model id would otherwise
174/// spend a real benchmark replay on the wrong model.
175pub fn parse_harness_measure_request(
176    params: &Value,
177) -> Result<Option<HarnessMeasureRequest>, String> {
178    match params.get("harness_measure") {
179        Some(v) if !v.is_null() => Ok(Some(
180            serde_json::from_value(v.clone())
181                .map_err(|e| format!("invalid harness_measure: {e}"))?,
182        )),
183        _ => Ok(None),
184    }
185}
186
187/// Measure the harness AS IT STANDS — the baseline half of the comparison.
188///
189/// Measured under the session runtime's live `HarnessConfig` (`None` = the
190/// runtime default), because the candidate is that same config plus one patch:
191/// baseline and candidate must differ by the mutation and nothing else.
192pub async fn measure_baseline(
193    measurer: &dyn HarnessMeasurer,
194    request: &HarnessMeasureRequest,
195    live_config: Option<&car_memgine::HarnessConfig>,
196) -> Result<car_eventlog::harness_metrics::HarnessMetrics, String> {
197    measurer
198        // `None` context config: this is the HARNESS arm, and its baseline and
199        // candidate must differ by the harness patch and nothing else. Passing
200        // a context config here would vary two things at once and the verdict
201        // would not attribute to either.
202        .measure(request, live_config, None)
203        .await
204        .map_err(|e| format!("baseline harness measurement failed: {e}"))
205}
206
207/// Measure a candidate: `base` with `patch` applied, replayed on the same
208/// split under the mutated config.
209///
210/// The projection is `HarnessConfig::with_patch_for_measurement` —
211/// deliberately ungoverned, because this config exists only to be graded and
212/// the grade is what authorizes the real apply. It returns a copy, so the live
213/// config is untouched until (and unless) the gate promotes.
214pub async fn measure_candidate(
215    measurer: &dyn HarnessMeasurer,
216    request: &HarnessMeasureRequest,
217    base: &car_memgine::HarnessConfig,
218    patch: &car_memgine::harness_evolution::HarnessConfigPatch,
219) -> Result<car_eventlog::harness_metrics::HarnessMetrics, String> {
220    let candidate = base.with_patch_for_measurement(patch);
221    // `None` context config, for the reason spelled out on `measure_baseline`:
222    // one variable per comparison.
223    measurer.measure(request, Some(&candidate), None).await
224}
225
226// ---------------------------------------------------------------------------
227// The CONTEXT arm's measure pair (the pre-activation grader)
228// ---------------------------------------------------------------------------
229
230/// Read the `context_measure` param of `evolution.run`. `Ok(None)` = not
231/// requested; a malformed object is an error, never a silently defaulted
232/// request.
233///
234/// Deliberately parsed into the SAME [`HarnessMeasureRequest`] type
235/// `harness_measure` uses, rather than a context-specific twin. The two arms
236/// replay the same task suite on the same deterministic split under the same
237/// seed and the same turn cap — only the config being varied differs — so a
238/// separate request type would buy nothing but the freedom for the two splits
239/// to drift apart, and a context grade taken over a different task set than a
240/// harness grade is not comparable with it or with a `car-bench-harness` CLI
241/// run.
242///
243/// Opt-in for the same reason `harness_measure` is: a benchmark replay is a
244/// paid side effect (real model calls, real money), and a daemon that starts
245/// spending them because a cycle happened to diagnose something is a daemon
246/// nobody can leave running.
247pub fn parse_context_measure_request(
248    params: &Value,
249) -> Result<Option<HarnessMeasureRequest>, String> {
250    match params.get("context_measure") {
251        Some(v) if !v.is_null() => Ok(Some(
252            serde_json::from_value(v.clone())
253                .map_err(|e| format!("invalid context_measure: {e}"))?,
254        )),
255        _ => Ok(None),
256    }
257}
258
259/// Measure the context config AS IT STANDS — the baseline half of the
260/// pre-activation comparison.
261///
262/// `harness_config` is deliberately `None` on both halves of this pair (see
263/// [`measure_context_candidate`]): the replay must differ by the context patch
264/// and nothing else, so both arms run under the runtime's default harness
265/// config. Varying the harness config here as well would produce a verdict that
266/// attributes to neither change.
267pub async fn measure_context_baseline(
268    measurer: &dyn HarnessMeasurer,
269    request: &HarnessMeasureRequest,
270    live: &car_memgine::MemgineConfig,
271) -> Result<car_eventlog::harness_metrics::HarnessMetrics, String> {
272    measurer
273        .measure(request, None, Some(live))
274        .await
275        .map_err(|e| format!("baseline context measurement failed: {e}"))
276}
277
278/// Measure a context candidate: `base` with `patch` applied, replayed on the
279/// same split under the mutated context config.
280///
281/// The projection is
282/// [`car_memgine::MemgineConfig::with_context_patch_for_measurement`] —
283/// deliberately ungoverned, because this config exists only to be graded and
284/// the grade is what authorizes the real apply. It returns a copy, so the live
285/// engine config is untouched until (and unless) the gate promotes and
286/// `apply_context_patch` installs it for real.
287///
288/// `harness_config: None` here matches [`measure_context_baseline`], and that
289/// pairing is the load-bearing part: baseline and candidate then differ by the
290/// context patch alone.
291pub async fn measure_context_candidate(
292    measurer: &dyn HarnessMeasurer,
293    request: &HarnessMeasureRequest,
294    base: &car_memgine::MemgineConfig,
295    patch: &car_memgine::ContextConfigPatch,
296) -> Result<car_eventlog::harness_metrics::HarnessMetrics, String> {
297    let candidate = base.with_context_patch_for_measurement(patch);
298    measurer.measure(request, None, Some(&candidate)).await
299}
300
301// ---------------------------------------------------------------------------
302// Signal populaters (item 1)
303// ---------------------------------------------------------------------------
304
305/// Fold the **Harness** component's evolution signals from an event-log tail.
306///
307/// Pressure = `min(1, implicated / total)` where `implicated` is the summed
308/// `evidence_count` of the recurring interventions
309/// `car_eventlog::harness_adapt::diagnose` proposes (min 2 occurrences — its
310/// own "one-offs are noise" rule): the fraction of logged events implicated in
311/// a recurring interaction-failure pattern. Evidence = the number of events
312/// diagnosed over. Returns `None` on an empty log — no telemetry is *absence
313/// of signal*, not zero pressure, matching how the engine omits Memory/Skills
314/// for an empty store.
315pub fn harness_component_from_events(events: &[Event]) -> Option<ComponentState> {
316    if events.is_empty() {
317        return None;
318    }
319    let report = car_eventlog::harness_adapt::diagnose(events, 2);
320    let implicated: usize = report.interventions.iter().map(|i| i.evidence_count).sum();
321    let pressure = (implicated as f64 / events.len() as f64).min(1.0);
322    Some(ComponentState {
323        component: EvolvableComponent::Harness,
324        signals: EvolutionSignals {
325            pressure,
326            evidence: events.len() as u64,
327            // Harness evolution regression-gates against telemetry; a thin
328            // tail can't support that.
329            min_evidence: 20,
330            // Costlier than a memory/skill pass: proposals need gating and
331            // possibly a human.
332            cost: 2.0,
333        },
334    })
335}
336
337/// Fold the **Harness** component's evolution signals from a caller-supplied
338/// [`car_eventlog::harness_metrics::HarnessMetrics`] snapshot — the planning
339/// twin of [`harness_component_from_events`] for `evolution.run` callers that
340/// pass `harness_baseline_metrics` (their own held-out telemetry). Pressure =
341/// failed attempts over total attempts; evidence = total attempts. `None`
342/// when the metrics record no attempts (nothing observed).
343pub fn harness_component_from_metrics(
344    m: &car_eventlog::harness_metrics::HarnessMetrics,
345) -> Option<ComponentState> {
346    let eff = &m.trajectory_efficiency;
347    let attempts = eff.actions_succeeded + eff.failed_attempts;
348    if attempts == 0 {
349        return None;
350    }
351    Some(ComponentState {
352        component: EvolvableComponent::Harness,
353        signals: EvolutionSignals {
354            pressure: (eff.failed_attempts as f64 / attempts as f64).clamp(0.0, 1.0),
355            evidence: attempts as u64,
356            min_evidence: 20,
357            cost: 2.0,
358        },
359    })
360}
361
362/// Fold the **Tools** component's evolution signals from connector health.
363///
364/// Pressure = disconnected connectors / total. Evidence = the connector count:
365/// `ConnectorStatus` carries no per-connector call counters (there is no
366/// call-volume signal to report honestly), so the population size is the
367/// evidence, with `min_evidence` 1 — a single broken connector is a real
368/// signal. Returns `None` when no connectors are configured (nothing to
369/// evolve).
370pub fn tools_component_from_connectors(connectors: &[ConnectorStatus]) -> Option<ComponentState> {
371    if connectors.is_empty() {
372        return None;
373    }
374    let unhealthy = connectors.iter().filter(|c| !c.connected).count();
375    Some(ComponentState {
376        component: EvolvableComponent::Tools,
377        signals: EvolutionSignals {
378            pressure: (unhealthy as f64 / connectors.len() as f64).clamp(0.0, 1.0),
379            evidence: connectors.len() as u64,
380            min_evidence: 1,
381            cost: 1.0,
382        },
383    })
384}
385
386// ---------------------------------------------------------------------------
387// Executor mechanics (item 2) — shared by evolution.run and the cadence timer
388// ---------------------------------------------------------------------------
389
390/// Fold the failure events `evolve_skills` consumes from an event-log tail:
391/// `ActionFailed` / `ActionRejected` / `PolicyViolation` / `ReplanExhausted`
392/// become `TraceEvent`s (kind = the event kind's snake_case name, tool lifted
393/// from `data.tool` when the executor recorded one, reward 0.0). This is what
394/// the session event log genuinely carries — per-action failure records, not
395/// full state-before/after trajectories; those fields stay `None` rather than
396/// being fabricated.
397pub fn failed_trace_events(events: &[Event]) -> Vec<TraceEvent> {
398    events
399        .iter()
400        .filter(|ev| match ev.kind {
401            EventKind::ActionFailed
402            | EventKind::ActionRejected
403            | EventKind::PolicyViolation
404            | EventKind::ReplanExhausted => true,
405            // A goal evaluation is a failure signal for skill evolution only
406            // when the agent claimed completion (met) yet the runtime could not
407            // ground it — the false-success case. Grounded or not-yet-met
408            // verdicts are normal and must not be folded as failures.
409            EventKind::GoalEvaluated => {
410                let met = ev
411                    .data
412                    .get("met")
413                    .and_then(|v| v.as_bool())
414                    .unwrap_or(false);
415                let grounded = ev
416                    .data
417                    .get("grounded")
418                    .and_then(|v| v.as_bool())
419                    .unwrap_or(true);
420                met && !grounded
421            }
422            EventKind::TurnCompleted => {
423                // A truncated completion (false-success) or a max_turns/stalled
424                // terminal is a failure exemplar for skill evolution; a clean
425                // empty_tool_calls finish is not.
426                let decision = ev
427                    .data
428                    .get("decision")
429                    .and_then(|v| v.as_str())
430                    .unwrap_or("");
431                let truncated = ev
432                    .data
433                    .get("was_truncated")
434                    .and_then(|v| v.as_bool())
435                    .unwrap_or(false);
436                truncated || decision == "max_turns" || decision == "stalled"
437            }
438            _ => false,
439        })
440        .map(|ev| TraceEvent {
441            kind: serde_json::to_value(&ev.kind)
442                .ok()
443                .and_then(|v| v.as_str().map(str::to_string))
444                .unwrap_or_default(),
445            action_id: ev.action_id.clone(),
446            tool: ev
447                .data
448                .get("tool")
449                .and_then(|v| v.as_str())
450                .map(str::to_string),
451            data: Value::Object(ev.data.clone().into_iter().collect()),
452            duration_ms: None,
453            state_before: None,
454            state_after: None,
455            reward: Some(0.0),
456        })
457        .collect()
458}
459
460/// Price the localized-vs-global maintenance decision off the live
461/// [`car_memgine::memsys::MemoryStats`]: dirty regions = the reconciliation
462/// backlog (`outstanding_outdated + facts_superseded` — the same backlog the
463/// Memory pressure signal counts), total regions = `total_facts`, unit cost
464/// per region on both paths, global structural gain = the supersede-churn
465/// share (a store reorganization's upside is proportional to how much of the
466/// store has churned), valued at one region per full unit of gain times the
467/// store size. Deterministic, no fabricated constants beyond the unit costs.
468pub fn maintenance_input_from_stats(stats: &car_memgine::memsys::MemoryStats) -> MaintenanceInput {
469    let total = stats.total_facts;
470    MaintenanceInput {
471        dirty_regions: stats.outstanding_outdated + stats.facts_superseded,
472        total_regions: total,
473        localized_cost_per_region: 1.0,
474        global_cost_per_region: 1.0,
475        global_structural_gain: if total > 0 {
476            (stats.facts_superseded as f64 / total as f64).clamp(0.0, 1.0)
477        } else {
478            0.0
479        },
480        gain_value: total as f64,
481    }
482}
483
484/// The Memory arm of the evolution executor: size the pass with
485/// [`decide_maintenance`] (localized vs global — recorded, since
486/// `consolidate()` is the single live mechanism for both today) and run
487/// `engine.consolidate()`. `dry_run` skips the consolidate and reports what
488/// would run (`applied == false`).
489pub async fn run_memory_evolution(
490    engine: &Arc<tokio::sync::Mutex<MemgineEngine>>,
491    dry_run: bool,
492) -> Result<EvolutionOutcome, String> {
493    let mut eng = engine.lock().await;
494    let decision: MaintenanceDecision =
495        decide_maintenance(&maintenance_input_from_stats(&eng.memory_stats()));
496    if dry_run {
497        return Ok(EvolutionOutcome::no_op(format!(
498            "dry_run: would consolidate (maintenance: {:?} — {})",
499            decision.strategy, decision.rationale
500        )));
501    }
502    let report = eng.consolidate().await;
503    let summary = serde_json::to_string(&serde_json::json!({
504        "mechanism": "consolidate",
505        "maintenance": decision,
506        "expired_pruned": report.expired_pruned,
507        "superseded_gc": report.superseded_gc,
508        "turns_compacted": report.turns_compacted,
509        "domains_evolved": report.domains_evolved,
510        "total_nodes": report.total_nodes,
511    }))
512    .map_err(|e| e.to_string())?;
513    // A real consolidate is a real pass over the store (GC, embedding flush,
514    // promotion gate) — it applied, even when nothing needed pruning.
515    Ok(EvolutionOutcome::applied(summary))
516}
517
518/// The Skills arm of the evolution executor: `evolve_skills(failed_events,
519/// domain)` for every domain `domains_needing_evolution` flags (success rate
520/// below 0.6 with ≥3 recorded outcomes — the engine's own threshold). Errors
521/// with `"no inference engine"` when the session engine has no model —
522/// evolution is inference-backed and silently returning nothing would be a
523/// stub. `failed_events` is whatever failure trace the caller's event source
524/// genuinely holds (possibly empty — the domain outcome stats, not the traces,
525/// are what elect a domain for evolution).
526pub async fn run_skills_evolution(
527    engine: &Arc<tokio::sync::Mutex<MemgineEngine>>,
528    failed_events: &[TraceEvent],
529    dry_run: bool,
530) -> Result<EvolutionOutcome, String> {
531    let mut eng = engine.lock().await;
532    if !eng.has_inference() {
533        return Err("no inference engine".to_string());
534    }
535    let domains = eng.domains_needing_evolution(0.6);
536    if domains.is_empty() {
537        return Ok(EvolutionOutcome::no_op(
538            "no domain below the evolution threshold (success < 0.6 over ≥3 outcomes) — nothing to evolve",
539        ));
540    }
541    if dry_run {
542        return Ok(EvolutionOutcome::no_op(format!(
543            "dry_run: would evolve domain(s) {:?} over {} failure trace(s)",
544            domains,
545            failed_events.len()
546        )));
547    }
548    let mut evolved = 0usize;
549    for domain in &domains {
550        evolved += eng.evolve_skills(failed_events, domain).await.len();
551    }
552    let summary = format!(
553        "evolved {} skill(s) across domain(s) {:?} over {} failure trace(s)",
554        evolved,
555        domains,
556        failed_events.len()
557    );
558    // Applied only when skills were actually produced (kernel review S2).
559    Ok(if evolved > 0 {
560        EvolutionOutcome::applied(summary)
561    } else {
562        EvolutionOutcome::no_op(summary)
563    })
564}
565
566/// The reason the Tools pillar records when it is planned. Verbatim in both
567/// call sites (`evolution.run` and the cadence) so an operator reading either
568/// report gets the same explanation, and so changing the boundary is one edit.
569pub const TOOLS_OUT_OF_SCOPE_REASON: &str =
570    "connector remediation means re-running a connector's OAuth or credential exchange. That is \
571     an access change, and this loop deliberately holds no authority to grant, refresh or move \
572     credentials — reconnect and re-auth stay operator actions through the `connectors.*` \
573     surface. Recorded as a deliberate scope decision, not a failure.";
574
575/// The standing half of every context pending reason: that a pre-activation
576/// grade EXISTS and why it is opt-in, plus what approving this fingerprint
577/// instead buys and how far that authorization reaches.
578///
579/// This paragraph used to open by saying no pre-activation measurement was
580/// available, because `car-bench-harness` replayed a task runtime with no
581/// memgine attached and never offered the model a `recall` tool. That is no
582/// longer true: a bench task may declare a `memory:` fixture, and a task that
583/// does is replayed with a real memgine seeded from it and the shipped `recall`
584/// tool advertised, so the assembled context — and the answer the task is
585/// graded on — moves with `conversation_keep_recent`. What remains true is that
586/// the grade costs a benchmark replay, so it never runs unless a caller asks
587/// for it.
588///
589/// Prefixed per-mutation with the SPECIFIC precondition that was missing (see
590/// [`context_pending_reason`]) — "a grade was available and you did not ask for
591/// one" and "you asked and the mutation had nothing to grade" are different
592/// facts and lead an operator to different next actions.
593const CONTEXT_PENDING_REASON: &str =
594    "A pre-activation grade IS available for a patched context mutation: the daemon can replay \
595     the deterministic bench split twice — once under the live context config, once under it \
596     plus this patch — and promote or reject on the resulting TASK pass rates, because bench \
597     tasks that declare a memory fixture answer out of assembled context and are therefore \
598     sensitive to this knob. It is opt-in via the `context_measure` param because a benchmark \
599     replay is a paid side effect (real model calls, real money), and it is deliberately never \
600     supplied by the unattended cadence. Activation therefore falls back to the human gate here. \
601     Approving this fingerprint once makes every later cycle that proposes the same change apply \
602     it, measure the conversation tokens it actually saved over compacting without it, and roll \
603     it back if it saved none — that post-apply margin check is retained on the approved path as \
604     defence in depth, and is a weaker claim than the task-outcome grade above because it can \
605     only falsify the predicted token saving, never confirm the assembled context still answers. \
606     The approval ledger is DAEMON-WIDE and the fingerprint names the change, not the engine: \
607     approving it authorizes this same conversation_keep_recent change on any engine this daemon \
608     evolves — the shared one the unattended cadence runs over, and every per-agent engine an \
609     `evolution.run` names — not only the one that proposed it.";
610
611/// No `context_measure` in the request (which includes every unattended cadence
612/// tick — see [`spawn_evolution_cadence`]).
613const CONTEXT_NOT_REQUESTED: &str =
614    "no pre-activation grade was requested for this cycle: the `context_measure` param was \
615     absent, and the unattended cadence never supplies it (a timer must not start spending \
616     benchmark replays because someone enabled `evolution_interval_secs`).";
617
618/// `context_measure` supplied together with `dry_run`.
619const CONTEXT_DRY_RUN_REASON: &str =
620    "`context_measure` was requested with `dry_run` — a benchmark replay is a paid side effect \
621     and a dry run performs none, so nothing was measured and nothing could be graded.";
622
623/// Did anything a [`car_memgine::ContextConfigPatch`] can reach move between
624/// the config the baseline was measured under and the config live now?
625///
626/// Compares the patch-REACHABLE fields — today exactly
627/// `conversation_keep_recent` — rather than the whole config or a digest of
628/// it, and that scope is precisely what the rollback-correctness argument
629/// turns on. `apply_context_patch` builds its inverse patch by reading each
630/// patched field's CURRENT value, so the inverse describes the measured base
631/// if and only if every field the patch touches still holds the value the
632/// baseline replay ran under. A field the patch cannot reach moving (a token
633/// budget, a layer threshold) does not change what the patch will overwrite or
634/// what the inverse will restore, and refusing a graded promotion over it
635/// would be a false alarm.
636///
637/// Whenever `ContextConfigPatch` gains a field this comparison gains a term —
638/// the same standing warning the patch struct and `apply_context_patch` both
639/// carry, and for the same reason: miss one and the inverse patch silently
640/// describes a value nobody measured.
641fn context_patch_base_moved(
642    measured_under: &car_memgine::MemgineConfig,
643    current: &car_memgine::MemgineConfig,
644) -> bool {
645    measured_under.conversation_keep_recent != current.conversation_keep_recent
646}
647
648/// The terminal status a graded promotion reports when the live config moved
649/// under it between the baseline replay and the apply.
650fn context_config_moved_reason(measured_under: usize, current: usize) -> String {
651    format!(
652        "the live context config moved while this mutation was being measured: \
653         conversation_keep_recent was {measured_under} when the baseline replay ran and is \
654         {current} now. Something else moved it — another session's `evolution.run` over the \
655         same engine, the human-approved path, or an unattended cadence tick — so the grade \
656         was computed against a base that no longer exists, and the inverse patch this apply \
657         would hand back for rollback would name the CURRENT value rather than the measured \
658         one. Nothing is applied. The mutation is NOT falsified and no backoff is recorded: \
659         the measurement was invalidated, not the change, and a later cycle re-diagnoses \
660         against the new base and re-measures against it."
661    )
662}
663
664/// A diagnosed mutation with no concrete patch — nothing to project into a
665/// candidate config, so nothing to replay.
666const CONTEXT_NO_PATCH_REASON: &str =
667    "this mutation carries no concrete config patch, so there is nothing to project into a \
668     candidate config and nothing to install on a replay — a human designs this change.";
669
670/// Compose the full reason an operator reads on a pending context mutation:
671/// the specific precondition that was missing, then the standing explanation of
672/// what a grade would have been and what approving instead authorizes.
673///
674/// Two parts rather than one blob because only the first half varies, and an
675/// operator triaging a queue needs to see *which* precondition failed without
676/// re-reading the same three sentences on every entry.
677fn context_pending_reason(missing: &str) -> String {
678    format!("{missing} {CONTEXT_PENDING_REASON}")
679}
680
681/// Why an approved-but-falsified context mutation is skipped this tick.
682const CONTEXT_BACKOFF_REASON: &str =
683    "this mutation's post-apply measurement falsified it on an earlier unattended tick, so it is \
684     in exponential backoff. Re-applying it every tick would re-run a full compaction pass under \
685     the engine lock to reach the same verdict — the Skills arm backs off for the same reason. \
686     The standing approval is untouched: the next attempt happens automatically once the window \
687     elapses, and a re-diagnosis that PAYS clears the backoff.";
688
689/// Per-fingerprint exponential backoff for context mutations whose post-apply
690/// measurement falsified them (kernel review S5, applied to Context).
691///
692/// The unattended cadence re-diagnoses from live signals every tick. A
693/// falsified mutation restores the knob it moved, so the *next* tick sees the
694/// same signals, mints the same fingerprint, matches the same standing
695/// approval, and applies-measures-reverts again — forever, each round paying
696/// for a full compaction pass under the engine lock. This is that loop's brake.
697/// It is deliberately keyed on the fingerprint (the change), not the component:
698/// a *different* proposal for the same pillar is not the thing that failed.
699///
700/// Only the unattended path uses it. `evolution.run` on a session is a person
701/// asking for the check to run now, and there is no reason to answer that with
702/// "in backoff" (see [`run_context_evolution`]'s `backoff` argument).
703#[derive(Debug, Default)]
704pub struct ContextBackoff {
705    map: HashMap<String, DomainAttempts>,
706}
707
708impl ContextBackoff {
709    /// True when `fingerprint` has never been falsified, or its backoff window
710    /// has elapsed.
711    pub fn is_due(&self, fingerprint: &str, tick: u64) -> bool {
712        self.map
713            .get(fingerprint)
714            .map(|a| tick >= a.next_tick)
715            .unwrap_or(true)
716    }
717
718    /// Record a falsified apply at `tick`: the next attempt is allowed
719    /// `2^attempts` ticks later, exponent capped at [`BACKOFF_MAX_EXPONENT`].
720    pub fn note_falsified(&mut self, fingerprint: &str, tick: u64) {
721        let entry = self
722            .map
723            .entry(fingerprint.to_string())
724            .or_insert(DomainAttempts {
725                attempts: 0,
726                next_tick: tick,
727            });
728        entry.attempts = (entry.attempts + 1).min(BACKOFF_MAX_EXPONENT);
729        entry.next_tick = tick + (1u64 << entry.attempts);
730    }
731
732    /// Drop backoff state for a mutation that has now measurably paid — a
733    /// later relapse starts fresh rather than inheriting an old window.
734    pub fn clear(&mut self, fingerprint: &str) {
735        self.map.remove(fingerprint);
736    }
737}
738
739fn backoff_due(backoff: Option<(&std::sync::Mutex<ContextBackoff>, u64)>, fp: &str) -> bool {
740    match backoff {
741        Some((b, tick)) => b.lock().unwrap().is_due(fp, tick),
742        None => true,
743    }
744}
745
746fn note_falsified(backoff: Option<(&std::sync::Mutex<ContextBackoff>, u64)>, fp: &str) {
747    if let Some((b, tick)) = backoff {
748        b.lock().unwrap().note_falsified(fp, tick);
749    }
750}
751
752fn clear_backoff(backoff: Option<(&std::sync::Mutex<ContextBackoff>, u64)>, fp: &str) {
753    if let Some((b, _)) = backoff {
754        b.lock().unwrap().clear(fp);
755    }
756}
757
758/// The Context arm of the evolution executor — the pillar's real mechanism
759/// (`car_memgine::context_evolution`), replacing the `not_executable` error
760/// that used to make a documented boundary read as a failing subsystem.
761///
762/// The shape mirrors the Harness arm in `handler.rs`: diagnose from live
763/// signals, fingerprint each mutation, resolve it against the daemon's SHARED
764/// durable approval ledger (approve on one connection, apply on another,
765/// survives a restart), and report a per-mutation detail object plus a summary.
766///
767/// **Authorization resolves most-binding-first, over two paths.** A durable
768/// operator decision always wins; only a mutation nobody has decided on reaches
769/// the pre-activation gate; only a mutation the gate could not run on (or could
770/// not decide) reaches the human gate:
771///
772/// 1. `Rejected` in the ledger → `rejected_by_operator`. An operator's "no" is
773///    the most binding thing in the system and is never re-litigated by a
774///    measurement.
775/// 2. `Approved` in the ledger → the human-approved path below, unchanged:
776///    backoff check, dry-run, then the one-lock baseline-compact / apply /
777///    re-compact / revert-unless-positive margin measurement.
778/// 3. No prior decision **and** a grade is runnable — a measurer and a
779///    `context_measure` request were handed in, this is not a `dry_run`, and
780///    the mutation carries a patch that
781///    [`car_memgine::context_evolution::requires_human_approval`] says is
782///    gradeable → **the pre-activation gate**. Read the live
783///    [`car_memgine::MemgineConfig`] off the engine, replay the split twice
784///    (baseline under the live config, candidate under the live config plus the
785///    patch — see [`measure_context_baseline`] /
786///    [`measure_context_candidate`]), and hand both documents to
787///    [`car_memgine::harness_evolution::EvolutionAgent::evaluate_context`],
788///    which is literally the same gate, with the same guards, that grades a
789///    harness mutation. `Promote` applies for real (`applied`, `governance:
790///    "promoted"`); `Reject` applies nothing and reports `rejected_by_gate`;
791///    `NeedsApproval` / `Incomparable` fall through to `pending_approval`
792///    carrying the gate's own reason; a replay error reports
793///    `measurement_failed` and applies nothing; and a `Promote` whose measured
794///    base moved before the apply reports `config_moved_during_measurement` and
795///    also applies nothing.
796/// 4. Anything else → `pending_approval`, with a reason naming the precondition
797///    that was missing ([`context_pending_reason`]).
798///
799/// Three properties of that resolution are load-bearing:
800///
801/// - **`rejected_by_gate` does NOT fall through to the human gate.** It is a
802///   verdict, not an absence of one: the daemon measured this exact change on
803///   task outcomes and it came back a regression. Listing it for approval would
804///   invite an operator to approve a change the gate had just measured as worse
805///   — and because the ledger is daemon-wide and keyed on the change, that
806///   approval would then stand on every engine, permanently, over the top of a
807///   real measurement. An operator who disagrees can still approve the
808///   fingerprint directly through `permission.approve`; what must not happen is
809///   the daemon *soliciting* it.
810/// - **A `measurement_failed` mutation is not falsified.** The measurement was.
811///   [`ContextBackoff::note_falsified`] is deliberately NOT called on that path:
812///   backing a mutation off because the bench errored would punish the change
813///   for an infrastructure failure and delay the retry that would have graded
814///   it honestly.
815/// - **A promotion is re-checked against the live config before it applies.**
816///   The engine lock is DROPPED across the two replays, so the config the grade
817///   was measured under can move before the apply — another session's
818///   `evolution.run` over the same engine, the human-approved path, a cadence
819///   tick. Under the same lock hold that would apply the patch, the fields a
820///   [`car_memgine::ContextConfigPatch`] can reach are compared against the
821///   config the baseline ran under (`context_patch_base_moved`). If they moved,
822///   the step reports `config_moved_during_measurement` carrying both values
823///   and applies NOTHING: the verdict was computed against a base that no
824///   longer exists, and the inverse patch `apply_context_patch` hands back
825///   would describe the CURRENT value rather than the measured one, so even the
826///   rollback the contract promises would restore the wrong config. Like
827///   `measurement_failed`, this records NO backoff — the measurement was
828///   invalidated, not the change — and the correct recovery is a later cycle
829///   re-diagnosing and re-measuring against the new base.
830///
831/// The post-apply margin measurement is retained, unchanged, on the
832/// human-approved path (2) — defence in depth, and the only automatic check on
833/// a change an operator authorized without asking for a grade:
834///
835/// - **On that path the measurement happens AFTER the apply, and it measures
836///   the MARGIN.**
837///   Under ONE lock acquisition on the engine — otherwise another task's ingest
838///   would be credited or blamed — the arm compacts under the *unchanged*
839///   `conversation_keep_recent` first (`conversation_tokens_baseline`), then
840///   applies the patch, compacts again, and re-reads
841///   (`conversation_tokens_after`). Comparing against that baseline rather than
842///   against the uncompacted layer is the load-bearing part: the uncompacted
843///   comparison would credit the mutation with every token compaction was going
844///   to save anyway, and on a change an operator authorized without asking for
845///   a grade this is the ONLY automatic check on it. If the margin is not
846///   positive, the contract predicted something that did not happen, so the
847///   inverse patch goes back on inside the same lock hold and the step reports
848///   `rolled_back` (or `rollback_failed`, its own status, when even that does
849///   not take) and counts nothing as applied. Note what a rollback does and does
850///   not restore: the config knob goes back, the summarization performed while
851///   measuring does not — compaction replaces turns with summaries and keeps
852///   them in the layer, which is what the engine's own heuristic does at this
853///   saturation anyway.
854/// - **`backoff`** is `Some` only on the unattended cadence. A falsified
855///   mutation restores the knob, so the next tick re-diagnoses it, re-matches
856///   the same standing approval and repeats the whole apply-measure-revert
857///   round under the engine lock — forever. [`ContextBackoff`] is that brake,
858///   keyed per fingerprint, mirroring [`SkillsBackoff`] (kernel review S5). A
859///   session-driven `evolution.run` passes `None`: a person asking for the
860///   check now should get it now.
861/// - **`measure`** is `Some` only when the caller supplied `context_measure`
862///   AND this build has an in-process measurer installed. The unattended
863///   cadence passes `None` on purpose: a timer that started spending benchmark
864///   replays because someone set `evolution_interval_secs` would turn an opt-in
865///   cost into a background one. `dry_run` is honoured here rather than by the
866///   caller so the pending reason can say *which* precondition was missing — a
867///   dry run that reports "you did not ask for a grade" would be lying.
868pub async fn run_context_evolution(
869    engine: &Arc<tokio::sync::Mutex<MemgineEngine>>,
870    state: &Arc<ServerState>,
871    dry_run: bool,
872    pending: &std::sync::Mutex<Vec<Value>>,
873    backoff: Option<(&std::sync::Mutex<ContextBackoff>, u64)>,
874    measure: Option<(&dyn HarnessMeasurer, &HarnessMeasureRequest)>,
875) -> Result<EvolutionOutcome, String> {
876    use car_memgine::context_evolution::{
877        context_mutation_fingerprint, diagnose_context, requires_human_approval,
878    };
879    use car_memgine::harness_evolution::{EvolutionAgent, PromotionDecision};
880
881    let signals = { engine.lock().await.context_evolution_signals() };
882    let Some(signals) = signals else {
883        return Ok(EvolutionOutcome::no_op("no observable context signal"));
884    };
885    let mutations = diagnose_context(&signals);
886    if mutations.is_empty() {
887        return Ok(EvolutionOutcome::no_op(
888            "no context mutations diagnosed from live context signals",
889        ));
890    }
891
892    let mut details: Vec<Value> = Vec::new();
893    let mut applied = 0usize;
894    let mut pending_count = 0usize;
895    // How many mutations ATTEMPTED the pre-activation gate. Incremented on
896    // ENTERING the grading path, before either replay runs, so a mutation whose
897    // measurement then errors still counts here — which is why it is named for
898    // attempts and not for grades. What it lets an operator tell apart is "a
899    // grade was requested and every mutation was already decided in the ledger"
900    // (0) from "a grade was requested and at least one mutation reached the
901    // replays" (>0); which of those attempts actually produced a verdict is in
902    // the per-mutation statuses, not in this number.
903    let mut grade_attempts = 0usize;
904
905    for m in &mutations {
906        let fingerprint = context_mutation_fingerprint(m);
907        let prior = {
908            let ledger = state.approval_ledger.read().await;
909            ledger.lookup(&fingerprint).map(|r| r.decision)
910        };
911        let status: Value = match prior {
912            Some(car_policy::ApprovalDecision::Rejected) => {
913                serde_json::json!({ "status": "rejected_by_operator" })
914            }
915            Some(car_policy::ApprovalDecision::Approved) => match m.patch.as_ref() {
916                None => serde_json::json!({
917                    "status": "approved_no_patch",
918                    "note": "approved but carries no concrete config patch — a human designs this change",
919                }),
920                // Backoff is read BEFORE `dry_run`: a dry run exists to report
921                // what the next real cycle would do, and what it would do with a
922                // backed-off fingerprint is wait.
923                Some(_) if !backoff_due(backoff, &fingerprint) => serde_json::json!({
924                    "status": "in_backoff",
925                    "governance": "human_approved",
926                    "reason": CONTEXT_BACKOFF_REASON,
927                }),
928                Some(_) if dry_run => {
929                    serde_json::json!({ "status": "would_apply", "governance": "human_approved" })
930                }
931                Some(patch) => {
932                    // ONE lock acquisition: baseline-compact, measure, apply,
933                    // compact again, re-measure, and (if it did not pay)
934                    // revert — with no window for another task's ingest to land
935                    // in the middle and be credited or blamed.
936                    let mut eng = engine.lock().await;
937                    // The baseline is a compaction under the CURRENT, unchanged
938                    // `conversation_keep_recent` — NOT the uncompacted layer.
939                    // Measuring from the uncompacted layer would hand this
940                    // mutation every token that compaction was about to save
941                    // anyway, and it would be promoted on the strength of a
942                    // saving it did not cause. What its contract predicts is a
943                    // MARGINAL saving: more turns summarized *because* the knob
944                    // fell. That is the only thing this compares.
945                    //
946                    // Two limits worth knowing, because the report says which
947                    // one it hit. (1) `compact_conversation_heuristic` gates on
948                    // the tokens held in VERBATIM turns, and the baseline pass
949                    // has just cut that number — so when the surviving turns
950                    // land under the hard threshold the second pass is refused
951                    // outright and the margin reads zero. That is the engine's
952                    // own "not full enough to compact" policy, not a verdict on
953                    // the knob, and it self-corrects: the baseline pass leaves
954                    // the layer in the steady-state shape (verbatim turns only,
955                    // everything older summarized) where the next attempt's
956                    // baseline is a no-op and the margin is the whole effect.
957                    // (2) Saturation counts summaries, compaction's gate does
958                    // not, so a layer whose pressure is carried by summaries
959                    // diagnoses forever and this knob can never relieve it —
960                    // that one measures as zero every time, which is exactly
961                    // what the backoff bounds.
962                    let baseline = eng.compact_conversation_heuristic();
963                    let before = eng
964                        .context_evolution_signals()
965                        .map(|s| s.conversation_tokens)
966                        .unwrap_or(0);
967                    match eng.apply_context_patch(patch) {
968                        Err(e) => {
969                            // Backed off like a falsified apply, and for the
970                            // stronger reason: an apply that refuses will refuse
971                            // again next tick, and reaching it costs a full
972                            // baseline compaction pass under the engine lock.
973                            note_falsified(backoff, &fingerprint);
974                            serde_json::json!({
975                                "status": "apply_failed",
976                                "error": e,
977                                "baseline_turns_summarized": baseline.turns_summarized,
978                            })
979                        }
980                        Ok(inverse) => {
981                            let report = eng.compact_conversation_heuristic();
982                            let after = eng
983                                .context_evolution_signals()
984                                .map(|s| s.conversation_tokens)
985                                .unwrap_or(before);
986                            if after >= before {
987                                let why = if report.turns_summarized == 0 {
988                                    "compaction under the new conversation_keep_recent did no \
989                                     work at all: after the baseline pass the turns still held \
990                                     verbatim are below the engine's own hard threshold, so it \
991                                     refuses to compact them whatever this knob says. That is \
992                                     \"no saving available right now\", not \"this knob cannot \
993                                     help\" — the baseline pass has left the layer in the shape \
994                                     where a later cycle's attempt can pay, and the backoff \
995                                     window is when it retries."
996                                } else {
997                                    "compaction under the new conversation_keep_recent ran and \
998                                     still saved nothing over compaction under the old one, so \
999                                     the change's predicted improvement is falsified."
1000                                };
1001                                let falsified = format!(
1002                                    "{why} ({before} → {after} tokens.) The config is reverted; \
1003                                     the summarization the measurement itself performed is not \
1004                                     undone — compaction replaces turns with summaries and keeps \
1005                                     them in the layer, which is what the engine's own heuristic \
1006                                     does at this same saturation.",
1007                                );
1008                                note_falsified(backoff, &fingerprint);
1009                                match eng.apply_context_patch(&inverse) {
1010                                    Ok(_) => serde_json::json!({
1011                                        "status": "rolled_back",
1012                                        "governance": "human_approved",
1013                                        "reason": falsified,
1014                                        "conversation_tokens_baseline": before,
1015                                        "conversation_tokens_after": after,
1016                                        "baseline_turns_summarized": baseline.turns_summarized,
1017                                        "turns_summarized": report.turns_summarized,
1018                                    }),
1019                                    // The config is MUTATED and could not be put
1020                                    // back. Reporting this as `rolled_back` would
1021                                    // tell an operator nothing changed while the
1022                                    // engine runs at the new value, so it gets its
1023                                    // own terminal status and a log line.
1024                                    Err(rollback_error) => {
1025                                        tracing::error!(
1026                                            fingerprint = %fingerprint,
1027                                            error = %rollback_error,
1028                                            "context mutation was falsified but its rollback \
1029                                             failed — the engine is running at the mutated \
1030                                             conversation_keep_recent"
1031                                        );
1032                                        serde_json::json!({
1033                                            "status": "rollback_failed",
1034                                            "governance": "human_approved",
1035                                            "reason": falsified,
1036                                            "rollback_error": rollback_error,
1037                                            "rollback_patch": inverse,
1038                                            "conversation_tokens_baseline": before,
1039                                            "conversation_tokens_after": after,
1040                                            "baseline_turns_summarized": baseline.turns_summarized,
1041                                            "turns_summarized": report.turns_summarized,
1042                                        })
1043                                    }
1044                                }
1045                            } else {
1046                                applied += 1;
1047                                clear_backoff(backoff, &fingerprint);
1048                                serde_json::json!({
1049                                    "status": "applied",
1050                                    "governance": "human_approved",
1051                                    "rollback_patch": inverse,
1052                                    "conversation_tokens_baseline": before,
1053                                    "conversation_tokens_after": after,
1054                                    "baseline_turns_summarized": baseline.turns_summarized,
1055                                    "turns_summarized": report.turns_summarized,
1056                                })
1057                            }
1058                        }
1059                    }
1060                }
1061            },
1062            // Nobody has decided on this fingerprint. The pre-activation gate
1063            // gets its turn before the human does — that is the whole point of
1064            // having one — and the human gate is the fallback for everything
1065            // the gate could not run on or could not decide.
1066            None => {
1067                // A mutation is gradeable iff it carries a patch to project
1068                // into a candidate config. `requires_human_approval` is the
1069                // authority on that question (today it is exactly
1070                // `patch.is_none()`), and it is consulted rather than
1071                // re-derived here so a future rule that makes some patched
1072                // mutation human-only lands in one place.
1073                let gradeable = m.patch.as_ref().filter(|_| !requires_human_approval(m));
1074                match (measure, gradeable) {
1075                    (Some((measurer, request)), Some(patch)) if !dry_run => {
1076                        // Read the LIVE config and drop the lock before the
1077                        // replays. Each replay is a full benchmark run — many
1078                        // seconds of model calls — and holding the engine lock
1079                        // across it would stall every ingest, recall and
1080                        // consolidate on this daemon for the duration. The
1081                        // config is a cheap clone and the value only has to be
1082                        // consistent at the moment the comparison is anchored.
1083                        let live = { engine.lock().await.config().clone() };
1084                        grade_attempts += 1;
1085                        let replays = match measure_context_baseline(measurer, request, &live).await
1086                        {
1087                            Err(e) => Err(e),
1088                            Ok(baseline) => {
1089                                match measure_context_candidate(measurer, request, &live, patch)
1090                                    .await
1091                                {
1092                                    Err(e) => Err(e),
1093                                    Ok(candidate) => Ok((baseline, candidate)),
1094                                }
1095                            }
1096                        };
1097                        match replays {
1098                            // The measurement failed, not the mutation. Nothing
1099                            // is applied, nothing is synthesized, and
1100                            // `note_falsified` is deliberately NOT called: the
1101                            // change was never graded, so backing it off would
1102                            // punish it for a bench failure and delay the retry
1103                            // that would have graded it honestly.
1104                            Err(error) => serde_json::json!({
1105                                "status": "measurement_failed",
1106                                "error": error,
1107                            }),
1108                            Ok((baseline, candidate)) => {
1109                                let decision = EvolutionAgent::new()
1110                                    .evaluate_context(m, &baseline, &candidate);
1111                                let mut status = match decision {
1112                                    PromotionDecision::Promote { reason } => {
1113                                        // Graded and promoted — apply for real,
1114                                        // through the one mutation door, which
1115                                        // enforces the floor and hands back the
1116                                        // inverse patch the contract's rollback
1117                                        // promises. No ledger entry was needed
1118                                        // and none is written: the authorization
1119                                        // here is the measurement.
1120                                        let mut eng = engine.lock().await;
1121                                        // TOCTOU: the lock was DROPPED across
1122                                        // the two replays, which are minutes of
1123                                        // model calls, so the config the grade
1124                                        // was measured under may not be the
1125                                        // config about to be patched. Anything
1126                                        // else with a handle on this engine can
1127                                        // have moved it in that window — another
1128                                        // session's `evolution.run`, the
1129                                        // human-approved path, an unattended
1130                                        // cadence tick. Re-read it under the
1131                                        // SAME lock hold that would apply the
1132                                        // patch and compare against what the
1133                                        // baseline ran under; if it moved,
1134                                        // refuse rather than degrade, which is
1135                                        // how the rest of this module handles a
1136                                        // precondition it cannot honour.
1137                                        //
1138                                        // Optimistic re-check rather than
1139                                        // holding the lock across the replays:
1140                                        // the lock is the engine's ONLY lock, so
1141                                        // holding it for the duration of a
1142                                        // benchmark run would stall every
1143                                        // ingest, recall and consolidate on this
1144                                        // daemon for minutes to protect a window
1145                                        // that is almost never contended. The
1146                                        // re-check costs one config clone and
1147                                        // turns the rare collision into a
1148                                        // refusal instead of a promotion
1149                                        // justified by a comparison that no
1150                                        // longer applies.
1151                                        let current = eng.config().clone();
1152                                        if context_patch_base_moved(&live, &current) {
1153                                            serde_json::json!({
1154                                                "status": "config_moved_during_measurement",
1155                                                "governance": "promoted",
1156                                                "reason": context_config_moved_reason(
1157                                                    live.conversation_keep_recent,
1158                                                    current.conversation_keep_recent,
1159                                                ),
1160                                                "gate_reason": reason,
1161                                                "measured_under_conversation_keep_recent":
1162                                                    live.conversation_keep_recent,
1163                                                "current_conversation_keep_recent":
1164                                                    current.conversation_keep_recent,
1165                                            })
1166                                        } else {
1167                                            match eng.apply_context_patch(patch) {
1168                                                Ok(inverse) => {
1169                                                    applied += 1;
1170                                                    serde_json::json!({
1171                                                        "status": "applied",
1172                                                        "governance": "promoted",
1173                                                        "reason": reason,
1174                                                        "rollback_patch": inverse,
1175                                                    })
1176                                                }
1177                                                Err(e) => serde_json::json!({
1178                                                    "status": "apply_failed",
1179                                                    "governance": "promoted",
1180                                                    "error": e,
1181                                                }),
1182                                            }
1183                                        }
1184                                    }
1185                                    // A VERDICT, and it deliberately stops here
1186                                    // rather than falling through to
1187                                    // `pending_approval`. Soliciting an
1188                                    // operator's approval for a change the
1189                                    // daemon just measured as a regression —
1190                                    // onto a daemon-wide ledger keyed on the
1191                                    // change, where it would stand for every
1192                                    // engine forever — is how a measured system
1193                                    // gets talked out of its own measurement.
1194                                    PromotionDecision::Reject { reason } => serde_json::json!({
1195                                        "status": "rejected_by_gate",
1196                                        "reason": reason,
1197                                    }),
1198                                    // No verdict. `NeedsApproval` means the gate
1199                                    // passed but the mutation is human-only
1200                                    // anyway; `Incomparable` means the two
1201                                    // documents cannot be compared (task pass
1202                                    // rates over different task sets). Both are
1203                                    // "the measurement did not decide", which is
1204                                    // exactly what the human gate is the
1205                                    // fallback for — carrying the gate's own
1206                                    // reason so an operator reads WHY it did not.
1207                                    PromotionDecision::NeedsApproval { reason }
1208                                    | PromotionDecision::Incomparable { reason } => {
1209                                        let reason = context_pending_reason(&reason);
1210                                        pending_count += 1;
1211                                        pending.lock().unwrap().push(serde_json::json!({
1212                                            "fingerprint": fingerprint,
1213                                            "mutation": m.id,
1214                                            "component": m.contract.component,
1215                                            "safety_affecting": m.contract.component.is_safety_affecting(),
1216                                            "rationale": m.rationale,
1217                                            "reason": reason,
1218                                        }));
1219                                        serde_json::json!({
1220                                            "status": "pending_approval",
1221                                            "reason": reason,
1222                                        })
1223                                    }
1224                                };
1225                                // Audit what the verdict was computed FROM, on
1226                                // every graded outcome including the ones that
1227                                // applied nothing. A promotion (or a rejection)
1228                                // nobody can re-derive from the response is not
1229                                // an audited one, and these six numbers are
1230                                // exactly the inputs `evaluate_context` reads
1231                                // for a `ContextBudget` mutation.
1232                                if let Some(obj) = status.as_object_mut() {
1233                                    obj.insert(
1234                                        "baseline_task_pass_rate".into(),
1235                                        serde_json::to_value(baseline.task_pass_rate)
1236                                            .unwrap_or(Value::Null),
1237                                    );
1238                                    obj.insert(
1239                                        "baseline_task_pass_denominator".into(),
1240                                        serde_json::to_value(baseline.task_pass_denominator)
1241                                            .unwrap_or(Value::Null),
1242                                    );
1243                                    obj.insert(
1244                                        "baseline_total_tokens".into(),
1245                                        Value::from(baseline.trajectory_efficiency.total_tokens),
1246                                    );
1247                                    obj.insert(
1248                                        "candidate_task_pass_rate".into(),
1249                                        serde_json::to_value(candidate.task_pass_rate)
1250                                            .unwrap_or(Value::Null),
1251                                    );
1252                                    obj.insert(
1253                                        "candidate_task_pass_denominator".into(),
1254                                        serde_json::to_value(candidate.task_pass_denominator)
1255                                            .unwrap_or(Value::Null),
1256                                    );
1257                                    obj.insert(
1258                                        "candidate_total_tokens".into(),
1259                                        Value::from(candidate.trajectory_efficiency.total_tokens),
1260                                    );
1261                                }
1262                                status
1263                            }
1264                        }
1265                    }
1266                    // No grade was runnable. Say which precondition was
1267                    // missing — "you did not ask for one", "you asked on a dry
1268                    // run", and "this mutation has nothing to grade" lead an
1269                    // operator to three different next actions, and collapsing
1270                    // them into one sentence is how an opt-in feature reads as
1271                    // broken.
1272                    _ => {
1273                        let missing = if gradeable.is_none() {
1274                            CONTEXT_NO_PATCH_REASON
1275                        } else if measure.is_none() {
1276                            CONTEXT_NOT_REQUESTED
1277                        } else {
1278                            CONTEXT_DRY_RUN_REASON
1279                        };
1280                        let reason = context_pending_reason(missing);
1281                        pending_count += 1;
1282                        pending.lock().unwrap().push(serde_json::json!({
1283                            "fingerprint": fingerprint,
1284                            "mutation": m.id,
1285                            "component": m.contract.component,
1286                            // The component's own safety classification, not
1287                            // `requires_human_approval` — that function now
1288                            // answers "is this mutation gradeable", which is a
1289                            // different question. Reading it as a safety
1290                            // classification would report every patchless
1291                            // proposal as safety-affecting, telling an operator
1292                            // that a `conversation_keep_recent` change touches
1293                            // a safety boundary. It does not.
1294                            "safety_affecting": m.contract.component.is_safety_affecting(),
1295                            "rationale": m.rationale,
1296                            "reason": reason,
1297                        }));
1298                        serde_json::json!({
1299                            "status": "pending_approval",
1300                            "reason": reason,
1301                        })
1302                    }
1303                }
1304            }
1305        };
1306        let mut d = serde_json::json!({
1307            "mutation": m.id,
1308            "component": m.contract.component,
1309            "fingerprint": fingerprint,
1310            "rationale": m.rationale,
1311        });
1312        if let (Some(obj), Some(s)) = (d.as_object_mut(), status.as_object()) {
1313            for (k, v) in s {
1314                obj.insert(k.clone(), v.clone());
1315            }
1316        }
1317        details.push(d);
1318    }
1319
1320    let mut summary_obj = serde_json::json!({
1321        "mechanism": "context_evolution",
1322        "mutations": mutations.len(),
1323        "applied": applied,
1324        "pending": pending_count,
1325        "details": details,
1326    });
1327    // Mirrors the Harness arm's `measurement` key, and exists for the same
1328    // reason: a benchmark replay is a paid side effect, and a caller who asked
1329    // for one needs to see in the response whether it happened. Present ONLY
1330    // when `context_measure` was supplied, so its absence means "no measurement
1331    // was needed" while `grade_attempts: 0` means "one was requested and
1332    // nothing reached the gate".
1333    if let (Some(obj), Some((_, request))) = (summary_obj.as_object_mut(), measure) {
1334        obj.insert(
1335            "context_measured".into(),
1336            serde_json::json!({
1337                "status": if dry_run { "skipped_dry_run" } else { "measured" },
1338                "grade_attempts": grade_attempts,
1339                "model": request.model,
1340                "split": request.split,
1341                "split_seed": request.split_seed,
1342            }),
1343        );
1344    }
1345    let summary = serde_json::to_string(&summary_obj).map_err(|e| e.to_string())?;
1346    // "Evolved" means a patch landed AND survived its post-apply measurement —
1347    // a rolled-back mutation changed nothing by the time this returns (S2).
1348    Ok(if applied > 0 {
1349        EvolutionOutcome::applied(summary)
1350    } else {
1351        EvolutionOutcome::no_op(summary)
1352    })
1353}
1354
1355// ---------------------------------------------------------------------------
1356// Cadence Skills backoff (kernel review S5): the UNATTENDED loop must not
1357// re-spend inference on the same failing domain every tick forever. Each
1358// attempted domain gets an exponentially growing tick-skip; the counter
1359// resets only when the domain is observed recovered (no longer flagged by
1360// `domains_needing_evolution`).
1361// ---------------------------------------------------------------------------
1362
1363/// Per-domain exponential backoff state for the cadence Skills arm.
1364#[derive(Debug, Default)]
1365pub struct SkillsBackoff {
1366    map: HashMap<String, DomainAttempts>,
1367}
1368
1369#[derive(Debug)]
1370struct DomainAttempts {
1371    attempts: u32,
1372    next_tick: u64,
1373}
1374
1375/// Cap on the backoff exponent: 2^6 = 64 ticks max between attempts.
1376const BACKOFF_MAX_EXPONENT: u32 = 6;
1377
1378impl SkillsBackoff {
1379    /// The flagged domains that are due an attempt at `tick` (never attempted,
1380    /// or past their backoff window).
1381    pub fn due(&self, flagged: &[String], tick: u64) -> Vec<String> {
1382        flagged
1383            .iter()
1384            .filter(|d| {
1385                self.map
1386                    .get(*d)
1387                    .map(|a| tick >= a.next_tick)
1388                    .unwrap_or(true)
1389            })
1390            .cloned()
1391            .collect()
1392    }
1393
1394    /// Record that `domain` was attempted at `tick`: the next attempt is
1395    /// allowed `2^attempts` ticks later (exponent capped at
1396    /// [`BACKOFF_MAX_EXPONENT`]). Every attempt widens the window — a domain
1397    /// only stops backing off by *recovering* (see
1398    /// [`Self::reset_recovered`]), so unattended inference spend on a domain
1399    /// that stays broken decays geometrically instead of repeating each tick.
1400    pub fn note_attempt(&mut self, domain: &str, tick: u64) {
1401        let entry = self
1402            .map
1403            .entry(domain.to_string())
1404            .or_insert(DomainAttempts {
1405                attempts: 0,
1406                next_tick: tick,
1407            });
1408        entry.attempts = (entry.attempts + 1).min(BACKOFF_MAX_EXPONENT);
1409        entry.next_tick = tick + (1u64 << entry.attempts);
1410    }
1411
1412    /// Drop backoff state for domains no longer flagged — an observed
1413    /// recovery resets the counter, so a relapse starts fresh.
1414    pub fn reset_recovered(&mut self, flagged: &[String]) {
1415        self.map.retain(|d, _| flagged.iter().any(|f| f == d));
1416    }
1417}
1418
1419/// The cadence-timer Skills arm: like [`run_skills_evolution`] but
1420/// backoff-gated per domain (kernel review S5) and — being unattended, with
1421/// no session — running over an empty failure-trace set (the engine's own
1422/// per-domain outcome stats are what elect a domain; see the cadence scope
1423/// notes on [`run_evolution_cadence_cycle`]).
1424pub async fn run_skills_evolution_backoff(
1425    engine: &Arc<tokio::sync::Mutex<MemgineEngine>>,
1426    backoff: &std::sync::Mutex<SkillsBackoff>,
1427    tick: u64,
1428) -> Result<EvolutionOutcome, String> {
1429    let mut eng = engine.lock().await;
1430    if !eng.has_inference() {
1431        return Err("no inference engine".to_string());
1432    }
1433    let flagged = eng.domains_needing_evolution(0.6);
1434    let due = {
1435        let mut b = backoff.lock().unwrap();
1436        b.reset_recovered(&flagged);
1437        b.due(&flagged, tick)
1438    };
1439    if flagged.is_empty() {
1440        return Ok(EvolutionOutcome::no_op(
1441            "no domain below the evolution threshold — nothing to evolve",
1442        ));
1443    }
1444    if due.is_empty() {
1445        return Ok(EvolutionOutcome::no_op(format!(
1446            "all {} flagged domain(s) in backoff — no attempt this tick",
1447            flagged.len()
1448        )));
1449    }
1450    let mut evolved = 0usize;
1451    for domain in &due {
1452        evolved += eng.evolve_skills(&[], domain).await.len();
1453        backoff.lock().unwrap().note_attempt(domain, tick);
1454    }
1455    let summary = format!(
1456        "evolved {} skill(s) across due domain(s) {:?} ({} flagged total)",
1457        evolved,
1458        due,
1459        flagged.len()
1460    );
1461    Ok(if evolved > 0 {
1462        EvolutionOutcome::applied(summary)
1463    } else {
1464        EvolutionOutcome::no_op(summary)
1465    })
1466}
1467
1468// ---------------------------------------------------------------------------
1469// Cadence timer (item 3)
1470// ---------------------------------------------------------------------------
1471
1472/// Non-overlap guard for the cadence timer: a tick that arrives while the
1473/// previous cycle is still running is skipped, never queued. RAII — dropping
1474/// the token releases the guard even if the cycle errors.
1475#[derive(Debug, Default)]
1476pub struct CycleGuard {
1477    running: std::sync::atomic::AtomicBool,
1478}
1479
1480/// Held while a cycle runs; releases the guard on drop.
1481pub struct CycleToken<'a> {
1482    guard: &'a CycleGuard,
1483}
1484
1485impl CycleGuard {
1486    /// Claim the guard. `None` when a cycle is already in flight.
1487    pub fn try_begin(&self) -> Option<CycleToken<'_>> {
1488        self.running
1489            .compare_exchange(
1490                false,
1491                true,
1492                std::sync::atomic::Ordering::SeqCst,
1493                std::sync::atomic::Ordering::SeqCst,
1494            )
1495            .ok()
1496            .map(|_| CycleToken { guard: self })
1497    }
1498}
1499
1500impl Drop for CycleToken<'_> {
1501    fn drop(&mut self) {
1502        self.guard
1503            .running
1504            .store(false, std::sync::atomic::Ordering::SeqCst);
1505    }
1506}
1507
1508/// Read the opt-in cadence interval from the `.car/` project's `config.toml`
1509/// (`evolution_interval_secs`), discovered from the same anchor as
1510/// [`crate::seed_memgine_config`]: `$CAR_PROJECT_DIR` when set, else the
1511/// process cwd. Absent, `0`, or no project → `None` (off — the no-surprise
1512/// default).
1513pub fn seed_evolution_interval() -> Option<u64> {
1514    let anchor = std::env::var_os("CAR_PROJECT_DIR")
1515        .map(std::path::PathBuf::from)
1516        .or_else(|| std::env::current_dir().ok())?;
1517    let car_dir = car_memgine::project::discover_project(&anchor)?;
1518    car_memgine::project::load_config_overrides(&car_dir)?
1519        .evolution_interval_secs
1520        .filter(|s| *s > 0)
1521}
1522
1523/// Run one unattended evolution cycle over the daemon's **shared** engine:
1524/// fold its live Memory / Skills / Context signals plus Tools from connector
1525/// health, plan under the default policy, and dispatch `EvolveNow` components
1526/// to the same mechanics as `evolution.run` (`dry_run = false`).
1527///
1528/// Scope boundaries, stated rather than papered over:
1529/// - **Harness is not populated** here — harness telemetry (action events)
1530///   lives in per-session event logs; the daemon holds no cross-session action
1531///   log, and the harness apply path is HITL-gated on a *session's* approval
1532///   flow. Harness evolution runs through `evolution.run` on a session.
1533/// - **Skills** run with an empty failure-trace set for the same reason; the
1534///   engine's own per-domain outcome stats (what `domains_needing_evolution`
1535///   folds) are the evidence that elects a domain. Attempts are per-domain
1536///   exponentially backed off across ticks ([`SkillsBackoff`], kernel review
1537///   S5) so unattended inference spend never repeats the same failing domain
1538///   every tick.
1539/// - **Context** runs the real mechanism ([`run_context_evolution`]) — the
1540///   cadence has both the shared engine and the shared approval ledger, which
1541///   is everything that arm needs — but with **no pre-activation grader**: it
1542///   passes `None` for `measure`, because a grade costs a benchmark replay per
1543///   mutation and an unattended timer must not start spending model calls
1544///   because someone enabled `evolution_interval_secs`. The cadence therefore
1545///   still applies only what a human approved once. It runs under a
1546///   per-fingerprint [`ContextBackoff`],
1547///   so an approved mutation the measurement falsifies is not re-applied and
1548///   re-reverted on every tick forever (the Skills arm's S5 rule, applied to
1549///   Context). Its pending approvals are collected locally and folded into the
1550///   step summary as a count: an unattended cycle has no response to attach a
1551///   `pending_approvals` array to, and the durable ledger is where an operator
1552///   actually resolves them.
1553/// - **Tools** is recorded as `out_of_scope` (not a failure): connector
1554///   remediation is a credential operation this loop holds no authority to
1555///   perform ([`TOOLS_OUT_OF_SCOPE_REASON`]).
1556///
1557/// Returns the typed cycle report (`None` when the daemon has no shared
1558/// engine); the cadence loop decides logging.
1559pub async fn run_evolution_cadence_cycle(
1560    state: &Arc<ServerState>,
1561    backoff: &std::sync::Mutex<SkillsBackoff>,
1562    context_backoff: &std::sync::Mutex<ContextBackoff>,
1563    tick: u64,
1564) -> Option<EvolutionCycleReport> {
1565    let engine = state.shared_memgine.as_ref()?.clone();
1566
1567    let mut components = { engine.lock().await.evolution_component_states() };
1568    state.ensure_connectors_loaded().await;
1569    let connector_list = state.connectors().list().await;
1570    if let Some(t) = tools_component_from_connectors(&connector_list) {
1571        components.push(t);
1572    }
1573
1574    // The cadence has no response to hang a `pending_approvals` array off, so
1575    // the Context arm's pending entries land here and are reported as a count
1576    // in its summary; the durable ledger is where they are actually resolved.
1577    let context_pending: std::sync::Mutex<Vec<Value>> = std::sync::Mutex::new(Vec::new());
1578    let context_pending_ref = &context_pending;
1579
1580    let policy = EvolutionPolicy::default();
1581    let report = run_evolution_cycle(&components, &policy, |c| {
1582        let engine = engine.clone();
1583        async move {
1584            match c {
1585                EvolvableComponent::Memory => run_memory_evolution(&engine, false).await,
1586                EvolvableComponent::Skills => {
1587                    run_skills_evolution_backoff(&engine, backoff, tick).await
1588                }
1589                // A boundary, not a breakage. This used to be an
1590                // `Err("not_executable: …")`, which the cycle records as
1591                // `ran: false` — the same shape a crashed mechanism produces.
1592                // The cadence never appends Harness to `components` today, so
1593                // the arm is unreachable in practice; it is corrected here so
1594                // that stays true if a future cadence does plan it.
1595                EvolvableComponent::Harness => Ok(EvolutionOutcome::out_of_scope(
1596                    "harness telemetry and the HITL apply path are per-session — the cadence has \
1597                     no session, so harness evolution is driven via evolution.run on one. \
1598                     Recorded as a deliberate scope decision, not a failure.",
1599                )),
1600                EvolvableComponent::Context => {
1601                    // `None` measure, deliberately. The pre-activation grade
1602                    // costs a full benchmark replay per mutation — real model
1603                    // calls, real money — and an unattended timer must not
1604                    // start spending them because someone set
1605                    // `evolution_interval_secs`. That is a boundary, not an
1606                    // omission: an operator who wants an unattended cycle
1607                    // graded runs `evolution.run` with `context_measure`, where
1608                    // the spend is something they asked for. The cadence still
1609                    // applies changes whose fingerprint a human approved once,
1610                    // under the post-apply margin check and the backoff.
1611                    run_context_evolution(
1612                        &engine,
1613                        state,
1614                        false,
1615                        context_pending_ref,
1616                        Some((context_backoff, tick)),
1617                        None,
1618                    )
1619                    .await
1620                }
1621                EvolvableComponent::Tools => {
1622                    Ok(EvolutionOutcome::out_of_scope(TOOLS_OUT_OF_SCOPE_REASON))
1623                }
1624            }
1625        }
1626    })
1627    .await;
1628
1629    let pending = context_pending.into_inner().unwrap();
1630    if !pending.is_empty() {
1631        tracing::info!(
1632            count = pending.len(),
1633            "evolution cadence surfaced context mutation(s) awaiting operator approval; \
1634             approve by fingerprint via permission.approve"
1635        );
1636    }
1637
1638    Some(report)
1639}
1640
1641/// Retention cap for the cadence's dedicated event log (kernel review S4): a
1642/// long-running daemon's twin log must not grow unboundedly in memory.
1643const EVOLUTION_LOG_MAX_EVENTS: usize = 1000;
1644
1645/// Spawn the autonomous cadence timer (opt-in via `.car/config.toml`
1646/// `evolution_interval_secs`): ONE background task over the daemon's shared
1647/// engine that every `interval_secs` runs [`run_evolution_cadence_cycle`] and
1648/// appends the outcome as an `EvolutionTriggered` event (`data.source =
1649/// "cadence"`) to `<journal_dir>/evolution.jsonl` — capped at
1650/// [`EVOLUTION_LOG_MAX_EVENTS`] in memory, and **no-op cycles (nothing
1651/// planned, nothing run) are not appended** (kernel review S4), so an idle
1652/// daemon doesn't mint an audit line per tick. A tick that lands while the
1653/// previous cycle is still running is **skipped** ([`CycleGuard`]); each cycle
1654/// runs in its own task so a panic is isolated to that tick. The task ends
1655/// with the daemon's tokio runtime. Returns `None` (and warns) when the daemon
1656/// has no shared engine to evolve.
1657pub fn spawn_evolution_cadence(
1658    state: Arc<ServerState>,
1659    interval_secs: u64,
1660) -> Option<tokio::task::JoinHandle<()>> {
1661    if state.shared_memgine.is_none() {
1662        tracing::warn!(
1663            "evolution_interval_secs set but the daemon has no shared engine; cadence not started"
1664        );
1665        return None;
1666    }
1667    let journal = state.journal_dir.join("evolution.jsonl");
1668    Some(tokio::spawn(async move {
1669        let mut log = EventLog::with_journal(journal);
1670        log.set_retention(Some(RetentionPolicy {
1671            max_events: Some(EVOLUTION_LOG_MAX_EVENTS),
1672            max_age_secs: None,
1673        }));
1674        let guard = Arc::new(CycleGuard::default());
1675        // Per-domain Skills backoff persists across ticks for the daemon's
1676        // lifetime (kernel review S5).
1677        let backoff = Arc::new(std::sync::Mutex::new(SkillsBackoff::default()));
1678        // Per-fingerprint Context backoff, same lifetime and same reason: a
1679        // mutation the measurement falsified must not be re-applied every tick.
1680        let context_backoff = Arc::new(std::sync::Mutex::new(ContextBackoff::default()));
1681        let mut tick_no: u64 = 0;
1682        let mut tick = tokio::time::interval(std::time::Duration::from_secs(interval_secs.max(1)));
1683        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1684        // Skip the immediate first tick — nothing has accrued at boot.
1685        tick.tick().await;
1686        loop {
1687            tick.tick().await;
1688            tick_no += 1;
1689            let Some(_token) = guard.try_begin() else {
1690                tracing::warn!("evolution cadence tick skipped: previous cycle still running");
1691                continue;
1692            };
1693            // Isolate a panicking cycle to its tick; the token is held across
1694            // the await so an overlapping tick still skips.
1695            let cycle_state = state.clone();
1696            let cycle_backoff = backoff.clone();
1697            let cycle_context_backoff = context_backoff.clone();
1698            let outcome = tokio::spawn(async move {
1699                run_evolution_cadence_cycle(
1700                    &cycle_state,
1701                    &cycle_backoff,
1702                    &cycle_context_backoff,
1703                    tick_no,
1704                )
1705                .await
1706            })
1707            .await;
1708            let mut data: HashMap<String, Value> = HashMap::new();
1709            data.insert("source".into(), Value::from("cadence"));
1710            match outcome {
1711                Ok(Some(report)) => {
1712                    // No-op cycle: nothing planned, nothing run — skip the
1713                    // append entirely (S4).
1714                    if report.plan.evolve_now.is_empty() && report.steps.is_empty() {
1715                        continue;
1716                    }
1717                    data.insert(
1718                        "report".into(),
1719                        serde_json::to_value(&report).unwrap_or(Value::Null),
1720                    );
1721                }
1722                Ok(None) => {
1723                    data.insert("error".into(), Value::from("no shared engine"));
1724                }
1725                Err(e) => {
1726                    data.insert("error".into(), Value::from(format!("cycle panicked: {e}")));
1727                }
1728            }
1729            log.append(EventKind::EvolutionTriggered, None, None, data);
1730        }
1731    }))
1732}
1733
1734#[cfg(test)]
1735mod tests {
1736    use super::*;
1737
1738    fn ev(kind: EventKind, action: Option<&str>) -> Event {
1739        Event {
1740            kind,
1741            run_id: None,
1742            client_id: None,
1743            policy_session_id: None,
1744            action_id: action.map(str::to_string),
1745            proposal_id: None,
1746            data: HashMap::new(),
1747            timestamp: chrono::Utc::now(),
1748            prev_hash: None,
1749            hash: None,
1750        }
1751    }
1752
1753    fn connector(slug: &str, connected: bool) -> ConnectorStatus {
1754        ConnectorStatus {
1755            slug: slug.into(),
1756            name: slug.into(),
1757            url: format!("https://example.com/{slug}"),
1758            connected,
1759            tool_count: 1,
1760            enabled_count: 1,
1761            last_error: if connected {
1762                None
1763            } else {
1764                Some("dial failed".into())
1765            },
1766        }
1767    }
1768
1769    // --- populaters ---
1770
1771    #[test]
1772    fn harness_pressure_counts_recurring_failure_share() {
1773        // 4 recurring rejections of the same action + 4 unrelated successes:
1774        // implicated 4 of 8 events → pressure 0.5, evidence 8.
1775        let mut events = vec![
1776            ev(EventKind::ActionRejected, Some("a1")),
1777            ev(EventKind::ActionRejected, Some("a1")),
1778            ev(EventKind::ActionRejected, Some("a1")),
1779            ev(EventKind::ActionRejected, Some("a1")),
1780        ];
1781        for _ in 0..4 {
1782            events.push(ev(EventKind::ActionSucceeded, Some("ok")));
1783        }
1784        let c = harness_component_from_events(&events).expect("component");
1785        assert_eq!(c.component, EvolvableComponent::Harness);
1786        assert!((c.signals.pressure - 0.5).abs() < 1e-9, "{c:?}");
1787        assert_eq!(c.signals.evidence, 8);
1788    }
1789
1790    #[test]
1791    fn harness_one_off_failures_are_zero_pressure() {
1792        // Single occurrences never form an intervention (min_occurrences 2).
1793        let events = vec![
1794            ev(EventKind::ActionRejected, Some("a1")),
1795            ev(EventKind::ActionFailed, Some("a2")),
1796            ev(EventKind::ActionSucceeded, Some("a3")),
1797        ];
1798        let c = harness_component_from_events(&events).unwrap();
1799        assert_eq!(c.signals.pressure, 0.0);
1800        assert_eq!(c.signals.evidence, 3);
1801    }
1802
1803    #[test]
1804    fn harness_empty_log_is_absent_not_zero() {
1805        assert!(harness_component_from_events(&[]).is_none());
1806    }
1807
1808    #[test]
1809    fn tools_pressure_is_disconnected_share() {
1810        let list = vec![
1811            connector("up", true),
1812            connector("down1", false),
1813            connector("down2", false),
1814            connector("up2", true),
1815        ];
1816        let c = tools_component_from_connectors(&list).expect("component");
1817        assert_eq!(c.component, EvolvableComponent::Tools);
1818        assert!((c.signals.pressure - 0.5).abs() < 1e-9, "{c:?}");
1819        assert_eq!(c.signals.evidence, 4);
1820    }
1821
1822    #[test]
1823    fn tools_absent_when_no_connectors_configured() {
1824        assert!(tools_component_from_connectors(&[]).is_none());
1825    }
1826
1827    // --- failure-trace folding ---
1828
1829    #[test]
1830    fn failed_trace_events_fold_failure_kinds_only() {
1831        let mut failed = ev(EventKind::ActionFailed, Some("a1"));
1832        failed.data.insert("tool".into(), Value::from("http_get"));
1833        failed.data.insert("error".into(), Value::from("timeout"));
1834        let events = vec![
1835            failed,
1836            ev(EventKind::ActionSucceeded, Some("a2")),
1837            ev(EventKind::PolicyViolation, Some("a3")),
1838            ev(EventKind::ReplanExhausted, None),
1839        ];
1840        let traces = failed_trace_events(&events);
1841        assert_eq!(traces.len(), 3);
1842        assert_eq!(traces[0].kind, "action_failed");
1843        assert_eq!(traces[0].tool.as_deref(), Some("http_get"));
1844        assert_eq!(traces[0].action_id.as_deref(), Some("a1"));
1845        assert_eq!(traces[0].reward, Some(0.0));
1846        assert_eq!(traces[1].kind, "policy_violation");
1847        assert_eq!(traces[2].kind, "replan_exhausted");
1848    }
1849
1850    #[test]
1851    fn failed_trace_events_fold_only_ungrounded_completions() {
1852        // A completion the runtime could not ground (met but !grounded) is a
1853        // false-success failure exemplar; a grounded or not-yet-met verdict is not.
1854        let mut ungrounded = ev(EventKind::GoalEvaluated, None);
1855        ungrounded.data.insert("met".into(), Value::Bool(true));
1856        ungrounded
1857            .data
1858            .insert("grounded".into(), Value::Bool(false));
1859        let mut grounded = ev(EventKind::GoalEvaluated, None);
1860        grounded.data.insert("met".into(), Value::Bool(true));
1861        grounded.data.insert("grounded".into(), Value::Bool(true));
1862        let mut in_progress = ev(EventKind::GoalEvaluated, None);
1863        in_progress.data.insert("met".into(), Value::Bool(false));
1864        in_progress
1865            .data
1866            .insert("grounded".into(), Value::Bool(false));
1867
1868        let traces = failed_trace_events(&[ungrounded, grounded, in_progress]);
1869        assert_eq!(
1870            traces.len(),
1871            1,
1872            "only the met-but-ungrounded completion is a failure"
1873        );
1874        assert_eq!(traces[0].kind, "goal_evaluated");
1875        assert_eq!(traces[0].reward, Some(0.0));
1876    }
1877
1878    #[test]
1879    fn failed_trace_events_fold_problematic_turn_completions_only() {
1880        let mut truncated = ev(EventKind::TurnCompleted, None);
1881        truncated
1882            .data
1883            .insert("decision".into(), Value::from("empty_tool_calls"));
1884        truncated
1885            .data
1886            .insert("was_truncated".into(), Value::Bool(true));
1887        let mut capped = ev(EventKind::TurnCompleted, None);
1888        capped
1889            .data
1890            .insert("decision".into(), Value::from("max_turns"));
1891        capped
1892            .data
1893            .insert("was_truncated".into(), Value::Bool(false));
1894        let mut clean = ev(EventKind::TurnCompleted, None);
1895        clean
1896            .data
1897            .insert("decision".into(), Value::from("empty_tool_calls"));
1898        clean
1899            .data
1900            .insert("was_truncated".into(), Value::Bool(false));
1901
1902        let traces = failed_trace_events(&[truncated, capped, clean]);
1903        assert_eq!(traces.len(), 2, "a clean finish is not a failure");
1904        assert!(traces.iter().all(|t| t.kind == "turn_completed"));
1905    }
1906
1907    // --- maintenance sizing ---
1908
1909    #[test]
1910    fn maintenance_input_prices_backlog_off_live_stats() {
1911        let stats = car_memgine::memsys::MemoryStats {
1912            total_facts: 100,
1913            outstanding_outdated: 5,
1914            facts_superseded: 10,
1915            ..Default::default()
1916        };
1917        let input = maintenance_input_from_stats(&stats);
1918        assert_eq!(input.dirty_regions, 15);
1919        assert_eq!(input.total_regions, 100);
1920        assert!((input.global_structural_gain - 0.10).abs() < 1e-9);
1921        // Low churn → localized wins under decide_maintenance.
1922        let d = decide_maintenance(&input);
1923        assert_eq!(
1924            d.strategy,
1925            car_memgine::maintenance::MaintenanceStrategy::Localized,
1926            "{d:?}"
1927        );
1928    }
1929
1930    #[test]
1931    fn maintenance_input_clean_store_is_noop() {
1932        let input = maintenance_input_from_stats(&car_memgine::memsys::MemoryStats::default());
1933        let d = decide_maintenance(&input);
1934        assert_eq!(
1935            d.strategy,
1936            car_memgine::maintenance::MaintenanceStrategy::NoOp
1937        );
1938    }
1939
1940    // --- executor mechanics ---
1941
1942    #[tokio::test]
1943    async fn memory_evolution_dry_run_reports_without_consolidating() {
1944        let engine = Arc::new(tokio::sync::Mutex::new(MemgineEngine::new(None)));
1945        let out = run_memory_evolution(&engine, true).await.unwrap();
1946        assert!(out.summary.starts_with("dry_run"), "{out:?}");
1947        assert!(!out.applied, "dry run must not count as applied (S2)");
1948        // Real run against an empty engine still completes (consolidate is
1949        // side-effect-observable via its report fields) and IS an applied pass.
1950        let real = run_memory_evolution(&engine, false).await.unwrap();
1951        assert!(
1952            real.summary.contains("\"mechanism\":\"consolidate\""),
1953            "{real:?}"
1954        );
1955        assert!(real.applied);
1956    }
1957
1958    #[tokio::test]
1959    async fn skills_evolution_without_inference_is_an_honest_error() {
1960        let engine = Arc::new(tokio::sync::Mutex::new(MemgineEngine::new(None)));
1961        let err = run_skills_evolution(&engine, &[], false).await.unwrap_err();
1962        assert_eq!(err, "no inference engine");
1963    }
1964
1965    // --- cadence context backoff (S5, applied to Context) ---
1966
1967    #[test]
1968    fn context_backoff_widens_exponentially_and_clears_when_the_change_pays() {
1969        let fp = "context:context:abcd1234";
1970        let mut b = ContextBackoff::default();
1971
1972        // Never falsified → due immediately.
1973        assert!(b.is_due(fp, 1));
1974        // Falsified at tick 1 → next attempt allowed at 1 + 2^1 = 3.
1975        b.note_falsified(fp, 1);
1976        assert!(!b.is_due(fp, 2), "tick 2 still backing off");
1977        assert!(b.is_due(fp, 3));
1978        // Falsified again at 3 → next at 3 + 2^2 = 7.
1979        b.note_falsified(fp, 3);
1980        assert!(!b.is_due(fp, 6));
1981        assert!(b.is_due(fp, 7));
1982        // A different proposal for the same pillar is not the thing that
1983        // failed, so it is unaffected.
1984        assert!(b.is_due("context:context:99999999", 4));
1985        // A change that measurably pays clears its window; a relapse starts
1986        // fresh rather than inheriting the old exponent.
1987        b.clear(fp);
1988        assert!(b.is_due(fp, 4));
1989        b.note_falsified(fp, 4);
1990        assert!(b.is_due(fp, 6), "exponent restarted at 2^1");
1991    }
1992
1993    #[test]
1994    fn context_backoff_helpers_are_transparent_without_a_backoff() {
1995        // The session path passes `None`: a person asking for the check now
1996        // gets it now, and the helpers must never gate on absence.
1997        assert!(backoff_due(None, "context:context:abcd1234"));
1998        note_falsified(None, "context:context:abcd1234");
1999        clear_backoff(None, "context:context:abcd1234");
2000    }
2001
2002    // --- cadence skills backoff (S5) ---
2003
2004    #[test]
2005    fn skills_backoff_widens_exponentially_and_resets_on_recovery() {
2006        let mut b = SkillsBackoff::default();
2007        let flagged = vec!["web".to_string()];
2008
2009        // Never attempted → due immediately.
2010        assert_eq!(b.due(&flagged, 1), flagged);
2011        // Attempt at tick 1 → next allowed at 1 + 2^1 = 3.
2012        b.note_attempt("web", 1);
2013        assert!(b.due(&flagged, 2).is_empty(), "tick 2 still backing off");
2014        assert_eq!(b.due(&flagged, 3), flagged);
2015        // Second attempt at tick 3 → next at 3 + 2^2 = 7.
2016        b.note_attempt("web", 3);
2017        assert!(b.due(&flagged, 6).is_empty());
2018        assert_eq!(b.due(&flagged, 7), flagged);
2019        // Recovery (no longer flagged) resets the counter.
2020        b.reset_recovered(&[]);
2021        b.note_attempt("web", 10);
2022        // Fresh entry: attempts back to 1 → next at 10 + 2 = 12.
2023        assert_eq!(b.due(&flagged, 12), flagged);
2024    }
2025
2026    #[test]
2027    fn skills_backoff_exponent_is_capped() {
2028        let mut b = SkillsBackoff::default();
2029        for t in 0..20 {
2030            b.note_attempt("stuck", t);
2031        }
2032        // Cap: 2^6 = 64 ticks after the last attempt (at tick 19).
2033        let flagged = vec!["stuck".to_string()];
2034        assert!(b.due(&flagged, 19 + 63).is_empty());
2035        assert_eq!(b.due(&flagged, 19 + 64), flagged);
2036    }
2037
2038    #[test]
2039    fn skills_backoff_only_gates_the_attempted_domain() {
2040        let mut b = SkillsBackoff::default();
2041        let flagged = vec!["a".to_string(), "b".to_string()];
2042        b.note_attempt("a", 1);
2043        assert_eq!(b.due(&flagged, 2), vec!["b".to_string()]);
2044    }
2045
2046    // --- cadence guard ---
2047
2048    #[test]
2049    fn cycle_guard_blocks_overlap_and_releases_on_drop() {
2050        let guard = CycleGuard::default();
2051        let token = guard.try_begin().expect("first claim");
2052        assert!(guard.try_begin().is_none(), "in-flight cycle must block");
2053        drop(token);
2054        assert!(guard.try_begin().is_some(), "released after drop");
2055    }
2056
2057    #[test]
2058    fn cycle_guard_releases_even_when_cycle_errors() {
2059        let guard = CycleGuard::default();
2060        let r: Result<(), ()> = {
2061            let _token = guard.try_begin().unwrap();
2062            Err(())
2063        };
2064        assert!(r.is_err());
2065        assert!(guard.try_begin().is_some(), "drop on error path releases");
2066    }
2067}