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, ¤t) {
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}