Skip to main content

lean_ctx/core/
handoff_ledger_merge.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Merging handoff ledgers (`ctx_handoff merge`): several agents' ledgers of
3//! one project become one new ledger another agent can pull.
4//!
5//! The merge is a pure selection over what the inputs already carry:
6//!
7//! - **served first**: every input passes [`load_served`] — the same whole-
8//!   ledger re-authorization as `show` and `pull`. A withheld input refuses
9//!   the merge, typed and without content;
10//! - **subset only**: every finding, fact, excerpt, decision and step of the
11//!   result is one of an input's, byte for byte. Nothing is rewritten,
12//!   summarized or ranked; duplicates collapse, bounds cut the tail;
13//! - **conflicts kept**: a fact key with different values keeps every value
14//!   and is reported, never resolved by picking a winner;
15//! - **authority carried**: a part is recorded as bound when an input that
16//!   carries the same bytes recorded it as bound — the same text, and that
17//!   input's sources are in the result's lineage. The lineage is re-read
18//!   through the admitted reader now, against ONE budget as a read of the
19//!   result will re-read it; more sources have to hold, never fewer;
20//! - **no excerpt without its source**: a curated excerpt is kept only when
21//!   its file is re-bound into the result's lineage. A merged ledger carries
22//!   no session snapshot, so an unbound excerpt would otherwise pass for part
23//!   of a complete file lineage;
24//! - **deterministic** (#498): the inputs are ordered canonically and the
25//!   result takes the newest input's `created_at`, so the same set of ledgers
26//!   merges to the same bytes and the same file, in any argument order.
27
28use std::collections::{BTreeMap, BTreeSet};
29use std::path::{Path, PathBuf};
30
31use super::authority::CURATED_TOOL;
32use super::{
33    BoundEntry, CuratedRef, HandoffLedgerV1, KnowledgeExcerpt, KnowledgeFactMini, MAX_CURATED_REFS,
34    MAX_KNOWLEDGE_FACTS, MAX_LEDGER_FINDINGS, PartKind, SCHEMA_VERSION, SessionExcerpt,
35    ToolCallsSummary, load_served, manifest_md5, persist_ledger,
36};
37use crate::core::context_admission::carrier::Withheld;
38use crate::core::context_admission::stored::SourceRef;
39
40/// Most ledgers one merge reads.
41pub const MAX_MERGE_INPUTS: usize = 8;
42/// Decisions and next steps a merged ledger keeps, as `create_ledger` does.
43const MAX_DECISIONS: usize = 10;
44const MAX_NEXT_STEPS: usize = 20;
45
46const SOURCE_CHANGED: &str = "store.source_changed";
47
48/// A fact key that carries more than one value across the inputs. Every
49/// value stays in the merged ledger.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct FactConflict {
52    pub category: String,
53    pub key: String,
54    pub values: usize,
55}
56
57/// What a bound cut from the merged ledger: the parts beyond each bound,
58/// counted after duplicates collapsed.
59#[derive(Debug, Clone, Default, PartialEq, Eq)]
60pub struct Dropped {
61    pub findings: usize,
62    pub facts: usize,
63    pub curated_refs: usize,
64    /// Curated excerpts left out because their file is not re-bound.
65    pub unbound_curated: usize,
66    pub decisions: usize,
67    pub next_steps: usize,
68}
69
70/// The result of a merge: the stored ledger and what the reply reports.
71#[derive(Debug)]
72pub struct MergeOutcome {
73    pub ledger: HandoffLedgerV1,
74    pub path: PathBuf,
75    pub conflicts: Vec<FactConflict>,
76    pub distinct_tasks: usize,
77    pub dropped: Dropped,
78}
79
80/// One input as it was served.
81struct Input {
82    ledger: HandoffLedgerV1,
83    /// The content digest recomputed from the served ledger: the stored
84    /// `content_md5` field is not taken on trust.
85    digest: String,
86    /// The elements recorded as bound at the input's creation.
87    bound: Vec<BoundEntry>,
88    /// `(project, [(path, tool, admitted digest)])` of the input's binding.
89    files: Option<(String, Vec<(String, String, String)>)>,
90}
91
92impl Input {
93    fn bound(&self, kind: PartKind, index: usize) -> bool {
94        self.bound
95            .iter()
96            .any(|entry| entry.kind == kind && entry.index == index)
97    }
98}
99
100/// Merge the stored ledgers at `paths` into a new stored ledger.
101pub fn merge_ledgers(paths: &[PathBuf]) -> Result<MergeOutcome, String> {
102    let unique: BTreeSet<&Path> = paths.iter().map(PathBuf::as_path).collect();
103    if unique.len() > MAX_MERGE_INPUTS {
104        return Err(format!("merge reads at most {MAX_MERGE_INPUTS} ledgers"));
105    }
106    let mut inputs = Vec::with_capacity(unique.len());
107    for path in unique {
108        let served = load_served(path)?;
109        inputs.push(Input {
110            digest: super::ledger_content_md5(&served.ledger),
111            ledger: served.ledger,
112            bound: served
113                .lineage
114                .map(|lineage| lineage.bound)
115                .unwrap_or_default(),
116            files: served.files,
117        });
118    }
119    // Canonical order, one input per content digest.
120    inputs
121        .sort_by(|a, b| (&a.ledger.created_at, &a.digest).cmp(&(&b.ledger.created_at, &b.digest)));
122    inputs.dedup_by(|a, b| a.digest == b.digest);
123    if inputs.len() < 2 {
124        return Err("merge needs at least two different ledgers".to_string());
125    }
126    let project_root = inputs[0].ledger.project_root.clone();
127    if inputs
128        .iter()
129        .any(|input| input.ledger.project_root != project_root)
130    {
131        return Err("merge refused: the ledgers belong to different projects".to_string());
132    }
133
134    let mut dropped = Dropped::default();
135    let (findings, finding_bound) = merge_findings(&inputs, &mut dropped);
136    let (facts, fact_bound, conflicts) = merge_facts(&inputs, &mut dropped);
137    let curated = merge_curated(&inputs, &mut dropped);
138    let any_bound = finding_bound.iter().chain(&fact_bound).any(|bound| *bound);
139    let (curated_refs, sources) = rebind(&inputs, curated, any_bound, &mut dropped)?;
140    let tasks: BTreeSet<&str> = inputs
141        .iter()
142        .filter_map(|input| input.ledger.session.task.as_deref())
143        .collect();
144    let distinct_tasks = tasks.len();
145    let merged_from: Vec<String> = inputs.iter().map(|input| input.digest.clone()).collect();
146
147    let mut ledger = HandoffLedgerV1 {
148        schema_version: SCHEMA_VERSION,
149        created_at: inputs
150            .iter()
151            .map(|input| input.ledger.created_at.clone())
152            .max()
153            .unwrap_or_default(),
154        content_md5: String::new(),
155        manifest_md5: manifest_md5(),
156        project_root: project_root.clone(),
157        agent_id: None,
158        client_name: None,
159        workflow: common(&inputs, |ledger| ledger.workflow.as_ref()),
160        // Each input's snapshot describes its own session; none describes
161        // the merged one.
162        session_snapshot: String::new(),
163        session: SessionExcerpt {
164            id: format!("merge-{}", short_digest(&merged_from)),
165            task: (distinct_tasks == 1)
166                .then(|| tasks.first().map(|task| (*task).to_owned()))
167                .flatten(),
168            decisions: keep_last(
169                union(&inputs, |ledger| &ledger.session.decisions),
170                MAX_DECISIONS,
171                &mut dropped.decisions,
172            ),
173            findings,
174            next_steps: keep_first(
175                union(&inputs, |ledger| &ledger.session.next_steps),
176                MAX_NEXT_STEPS,
177                &mut dropped.next_steps,
178            ),
179        },
180        tool_calls: sum_tool_calls(&inputs),
181        evidence_keys: inputs
182            .iter()
183            .flat_map(|input| input.ledger.evidence_keys.iter().cloned())
184            .collect::<BTreeSet<_>>()
185            .into_iter()
186            .collect(),
187        knowledge: KnowledgeExcerpt {
188            project_hash: inputs
189                .iter()
190                .find_map(|input| input.ledger.knowledge.project_hash.clone()),
191            facts,
192        },
193        curated_refs,
194        active_overlays: merge_overlays(&inputs),
195        merged_from,
196        request_id: None,
197        request_digest: None,
198    };
199    ledger.content_md5 = super::ledger_content_md5(&ledger);
200
201    let path = persist_ledger(&ledger, sources, |kind, index| {
202        let flags = match kind {
203            PartKind::Finding => &finding_bound,
204            PartKind::Fact => &fact_bound,
205        };
206        flags.get(index).copied().unwrap_or(false)
207    })?;
208    Ok(MergeOutcome {
209        ledger,
210        path,
211        conflicts,
212        distinct_tasks,
213        dropped,
214    })
215}
216
217/// Findings in canonical order, one per text; bound when any input that
218/// carries the text recorded it as bound. The last ones are kept, as at
219/// creation.
220fn merge_findings(inputs: &[Input], dropped: &mut Dropped) -> (Vec<String>, Vec<bool>) {
221    let mut order: Vec<String> = Vec::new();
222    let mut bound: BTreeMap<String, bool> = BTreeMap::new();
223    for input in inputs {
224        for (index, finding) in input.ledger.session.findings.iter().enumerate() {
225            let is_bound = input.bound(PartKind::Finding, index);
226            if let Some(flag) = bound.get_mut(finding) {
227                *flag |= is_bound;
228            } else {
229                order.push(finding.clone());
230                bound.insert(finding.clone(), is_bound);
231            }
232        }
233    }
234    let kept = keep_last(order, MAX_LEDGER_FINDINGS, &mut dropped.findings);
235    let flags = kept.iter().map(|finding| bound[finding]).collect();
236    (kept, flags)
237}
238
239/// Facts in canonical order, one per (category, key, value) with the highest
240/// confidence any input gave it; bound when any input recorded it as bound.
241/// A (category, key) with several values keeps them all and is reported.
242fn merge_facts(
243    inputs: &[Input],
244    dropped: &mut Dropped,
245) -> (Vec<KnowledgeFactMini>, Vec<bool>, Vec<FactConflict>) {
246    let mut facts: Vec<(KnowledgeFactMini, bool)> = Vec::new();
247    for input in inputs {
248        for (index, fact) in input.ledger.knowledge.facts.iter().enumerate() {
249            let is_bound = input.bound(PartKind::Fact, index);
250            let same = facts.iter_mut().find(|(kept, _)| {
251                kept.category == fact.category && kept.key == fact.key && kept.value == fact.value
252            });
253            match same {
254                Some((kept, flag)) => {
255                    kept.confidence = kept.confidence.max(fact.confidence);
256                    *flag |= is_bound;
257                }
258                None => facts.push((fact.clone(), is_bound)),
259            }
260        }
261    }
262    let mut values: BTreeMap<(&str, &str), usize> = BTreeMap::new();
263    for (fact, _) in &facts {
264        *values.entry((&fact.category, &fact.key)).or_insert(0) += 1;
265    }
266    let conflicts = values
267        .into_iter()
268        .filter(|(_, count)| *count > 1)
269        .map(|((category, key), values)| FactConflict {
270            category: category.to_owned(),
271            key: key.to_owned(),
272            values,
273        })
274        .collect();
275    let kept = keep_first(facts, MAX_KNOWLEDGE_FACTS, &mut dropped.facts);
276    let (facts, flags): (Vec<_>, Vec<_>) = kept.into_iter().unzip();
277    (facts, flags, conflicts)
278}
279
280/// Curated excerpts, one per (path, content). The digest is taken of the
281/// content itself, not of the stored `content_md5` field.
282fn merge_curated(inputs: &[Input], dropped: &mut Dropped) -> Vec<CuratedRef> {
283    let mut seen = BTreeSet::new();
284    let refs = inputs
285        .iter()
286        .flat_map(|input| input.ledger.curated_refs.iter())
287        .filter(|excerpt| {
288            let digest = crate::core::hasher::hash_hex(excerpt.content.as_bytes());
289            seen.insert((excerpt.path.clone(), digest))
290        })
291        .cloned()
292        .collect();
293    keep_first(refs, MAX_CURATED_REFS, &mut dropped.curated_refs)
294}
295
296fn merge_overlays(inputs: &[Input]) -> Vec<serde_json::Value> {
297    let mut seen = BTreeSet::new();
298    inputs
299        .iter()
300        .flat_map(|input| input.ledger.active_overlays.iter())
301        .filter(|overlay| seen.insert(overlay.to_string()))
302        .cloned()
303        .collect()
304}
305
306fn sum_tool_calls(inputs: &[Input]) -> ToolCallsSummary {
307    let mut sum = ToolCallsSummary::default();
308    for calls in inputs.iter().map(|input| &input.ledger.tool_calls) {
309        sum.total += calls.total;
310        for (tool, count) in &calls.by_tool {
311            *sum.by_tool.entry(tool.clone()).or_insert(0) += count;
312        }
313        for (mode, count) in &calls.by_ctx_read_mode {
314            *sum.by_ctx_read_mode.entry(mode.clone()).or_insert(0) += count;
315        }
316    }
317    sum
318}
319
320/// The value every input carries, when they all carry the same one.
321fn common<T: Clone + serde::Serialize>(
322    inputs: &[Input],
323    value: impl Fn(&HandoffLedgerV1) -> Option<&T>,
324) -> Option<T> {
325    let first = value(&inputs.first()?.ledger)?;
326    let first_json = serde_json::to_value(first).ok()?;
327    inputs
328        .iter()
329        .all(|input| {
330            value(&input.ledger)
331                .and_then(|other| serde_json::to_value(other).ok())
332                .is_some_and(|other| other == first_json)
333        })
334        .then(|| first.clone())
335}
336
337/// Texts of every input in canonical order, each once.
338fn union(inputs: &[Input], texts: impl Fn(&HandoffLedgerV1) -> &Vec<String>) -> Vec<String> {
339    let mut seen = BTreeSet::new();
340    inputs
341        .iter()
342        .flat_map(|input| texts(&input.ledger).iter())
343        .filter(|text| seen.insert(text.as_str()))
344        .cloned()
345        .collect()
346}
347
348fn keep_first<T>(mut items: Vec<T>, bound: usize, dropped: &mut usize) -> Vec<T> {
349    *dropped = items.len().saturating_sub(bound);
350    items.truncate(bound);
351    items
352}
353
354fn keep_last<T>(mut items: Vec<T>, bound: usize, dropped: &mut usize) -> Vec<T> {
355    let cut = items.len().saturating_sub(bound);
356    *dropped = cut;
357    items.drain(..cut);
358    items
359}
360
361fn short_digest(inputs: &[String]) -> String {
362    let joined = inputs.join("\n");
363    crate::core::hasher::hash_hex(joined.as_bytes())
364        .chars()
365        .take(16)
366        .collect()
367}
368
369/// The lineage of the merged ledger and the excerpts it keeps. Every source
370/// is re-read through the admitted reader now, against one budget — the one
371/// a read of the result re-reads it with — and bound only when it returns the
372/// bytes the input was bound to; the re-reads are a check, not a delivery.
373///
374/// - an excerpt is kept with its file's source; one whose input binding names
375///   no source for it (a Community-mode excerpt) is left out and counted;
376/// - the sources of bound findings and facts come from the inputs that
377///   recorded bound parts, and only when a bound part is kept: a lineage with
378///   part sources but no recorded part would not agree with its ledger;
379/// - under protection a source that no longer re-binds refuses the merge
380///   before anything is written; without protection the excerpt is left out
381///   and a part source is skipped (nothing is recorded as bound then).
382fn rebind(
383    inputs: &[Input],
384    curated: Vec<CuratedRef>,
385    any_bound: bool,
386    dropped: &mut Dropped,
387) -> Result<(Vec<CuratedRef>, Vec<SourceRef>), String> {
388    let required = crate::core::context_admission::stored::source_authority_required();
389    let refused = || Withheld::new(&[SOURCE_CHANGED]).message("handoff merge");
390    let mut remaining = crate::core::limits::max_read_bytes();
391    crate::core::archive::authority::without_capture(|| {
392        let mut sources = Vec::new();
393        let mut kept = Vec::new();
394        for excerpt in curated {
395            let named = inputs.iter().find_map(|input| {
396                let (project, files) = input.files.as_ref()?;
397                files
398                    .iter()
399                    .find(|(path, tool, _)| *path == excerpt.path && tool == CURATED_TOOL)
400                    .map(|(path, tool, digest)| (project, path, tool, digest))
401            });
402            let Some((project, path, tool, digest)) = named else {
403                dropped.unbound_curated += 1;
404                continue;
405            };
406            match SourceRef::read_as_admitted_within(path, project, tool, digest, &mut remaining) {
407                Some(source) => {
408                    sources.push(source);
409                    kept.push(excerpt);
410                }
411                None if required => return Err(refused()),
412                None => dropped.unbound_curated += 1,
413            }
414        }
415        if any_bound {
416            for input in inputs.iter().filter(|input| !input.bound.is_empty()) {
417                let Some((project, files)) = input.files.as_ref() else {
418                    continue;
419                };
420                for (path, tool, digest) in files.iter().filter(|(_, tool, _)| tool != CURATED_TOOL)
421                {
422                    match SourceRef::read_as_admitted_within(
423                        path,
424                        project,
425                        tool,
426                        digest,
427                        &mut remaining,
428                    ) {
429                        Some(source) => sources.push(source),
430                        None if required => return Err(refused()),
431                        None => {}
432                    }
433                }
434            }
435        }
436        Ok((kept, sources))
437    })
438}