use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use super::authority::CURATED_TOOL;
use super::{
BoundEntry, CuratedRef, HandoffLedgerV1, KnowledgeExcerpt, KnowledgeFactMini, MAX_CURATED_REFS,
MAX_KNOWLEDGE_FACTS, MAX_LEDGER_FINDINGS, PartKind, SCHEMA_VERSION, SessionExcerpt,
ToolCallsSummary, load_served, manifest_md5, persist_ledger,
};
use crate::core::context_admission::carrier::Withheld;
use crate::core::context_admission::stored::SourceRef;
pub const MAX_MERGE_INPUTS: usize = 8;
const MAX_DECISIONS: usize = 10;
const MAX_NEXT_STEPS: usize = 20;
const SOURCE_CHANGED: &str = "store.source_changed";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FactConflict {
pub category: String,
pub key: String,
pub values: usize,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Dropped {
pub findings: usize,
pub facts: usize,
pub curated_refs: usize,
pub unbound_curated: usize,
pub decisions: usize,
pub next_steps: usize,
}
#[derive(Debug)]
pub struct MergeOutcome {
pub ledger: HandoffLedgerV1,
pub path: PathBuf,
pub conflicts: Vec<FactConflict>,
pub distinct_tasks: usize,
pub dropped: Dropped,
}
struct Input {
ledger: HandoffLedgerV1,
digest: String,
bound: Vec<BoundEntry>,
files: Option<(String, Vec<(String, String, String)>)>,
}
impl Input {
fn bound(&self, kind: PartKind, index: usize) -> bool {
self.bound
.iter()
.any(|entry| entry.kind == kind && entry.index == index)
}
}
pub fn merge_ledgers(paths: &[PathBuf]) -> Result<MergeOutcome, String> {
let unique: BTreeSet<&Path> = paths.iter().map(PathBuf::as_path).collect();
if unique.len() > MAX_MERGE_INPUTS {
return Err(format!("merge reads at most {MAX_MERGE_INPUTS} ledgers"));
}
let mut inputs = Vec::with_capacity(unique.len());
for path in unique {
let served = load_served(path)?;
inputs.push(Input {
digest: super::ledger_content_md5(&served.ledger),
ledger: served.ledger,
bound: served
.lineage
.map(|lineage| lineage.bound)
.unwrap_or_default(),
files: served.files,
});
}
inputs
.sort_by(|a, b| (&a.ledger.created_at, &a.digest).cmp(&(&b.ledger.created_at, &b.digest)));
inputs.dedup_by(|a, b| a.digest == b.digest);
if inputs.len() < 2 {
return Err("merge needs at least two different ledgers".to_string());
}
let project_root = inputs[0].ledger.project_root.clone();
if inputs
.iter()
.any(|input| input.ledger.project_root != project_root)
{
return Err("merge refused: the ledgers belong to different projects".to_string());
}
let mut dropped = Dropped::default();
let (findings, finding_bound) = merge_findings(&inputs, &mut dropped);
let (facts, fact_bound, conflicts) = merge_facts(&inputs, &mut dropped);
let curated = merge_curated(&inputs, &mut dropped);
let any_bound = finding_bound.iter().chain(&fact_bound).any(|bound| *bound);
let (curated_refs, sources) = rebind(&inputs, curated, any_bound, &mut dropped)?;
let tasks: BTreeSet<&str> = inputs
.iter()
.filter_map(|input| input.ledger.session.task.as_deref())
.collect();
let distinct_tasks = tasks.len();
let merged_from: Vec<String> = inputs.iter().map(|input| input.digest.clone()).collect();
let mut ledger = HandoffLedgerV1 {
schema_version: SCHEMA_VERSION,
created_at: inputs
.iter()
.map(|input| input.ledger.created_at.clone())
.max()
.unwrap_or_default(),
content_md5: String::new(),
manifest_md5: manifest_md5(),
project_root: project_root.clone(),
agent_id: None,
client_name: None,
workflow: common(&inputs, |ledger| ledger.workflow.as_ref()),
session_snapshot: String::new(),
session: SessionExcerpt {
id: format!("merge-{}", short_digest(&merged_from)),
task: (distinct_tasks == 1)
.then(|| tasks.first().map(|task| (*task).to_owned()))
.flatten(),
decisions: keep_last(
union(&inputs, |ledger| &ledger.session.decisions),
MAX_DECISIONS,
&mut dropped.decisions,
),
findings,
next_steps: keep_first(
union(&inputs, |ledger| &ledger.session.next_steps),
MAX_NEXT_STEPS,
&mut dropped.next_steps,
),
},
tool_calls: sum_tool_calls(&inputs),
evidence_keys: inputs
.iter()
.flat_map(|input| input.ledger.evidence_keys.iter().cloned())
.collect::<BTreeSet<_>>()
.into_iter()
.collect(),
knowledge: KnowledgeExcerpt {
project_hash: inputs
.iter()
.find_map(|input| input.ledger.knowledge.project_hash.clone()),
facts,
},
curated_refs,
active_overlays: merge_overlays(&inputs),
merged_from,
request_id: None,
request_digest: None,
};
ledger.content_md5 = super::ledger_content_md5(&ledger);
let path = persist_ledger(&ledger, sources, |kind, index| {
let flags = match kind {
PartKind::Finding => &finding_bound,
PartKind::Fact => &fact_bound,
};
flags.get(index).copied().unwrap_or(false)
})?;
Ok(MergeOutcome {
ledger,
path,
conflicts,
distinct_tasks,
dropped,
})
}
fn merge_findings(inputs: &[Input], dropped: &mut Dropped) -> (Vec<String>, Vec<bool>) {
let mut order: Vec<String> = Vec::new();
let mut bound: BTreeMap<String, bool> = BTreeMap::new();
for input in inputs {
for (index, finding) in input.ledger.session.findings.iter().enumerate() {
let is_bound = input.bound(PartKind::Finding, index);
if let Some(flag) = bound.get_mut(finding) {
*flag |= is_bound;
} else {
order.push(finding.clone());
bound.insert(finding.clone(), is_bound);
}
}
}
let kept = keep_last(order, MAX_LEDGER_FINDINGS, &mut dropped.findings);
let flags = kept.iter().map(|finding| bound[finding]).collect();
(kept, flags)
}
fn merge_facts(
inputs: &[Input],
dropped: &mut Dropped,
) -> (Vec<KnowledgeFactMini>, Vec<bool>, Vec<FactConflict>) {
let mut facts: Vec<(KnowledgeFactMini, bool)> = Vec::new();
for input in inputs {
for (index, fact) in input.ledger.knowledge.facts.iter().enumerate() {
let is_bound = input.bound(PartKind::Fact, index);
let same = facts.iter_mut().find(|(kept, _)| {
kept.category == fact.category && kept.key == fact.key && kept.value == fact.value
});
match same {
Some((kept, flag)) => {
kept.confidence = kept.confidence.max(fact.confidence);
*flag |= is_bound;
}
None => facts.push((fact.clone(), is_bound)),
}
}
}
let mut values: BTreeMap<(&str, &str), usize> = BTreeMap::new();
for (fact, _) in &facts {
*values.entry((&fact.category, &fact.key)).or_insert(0) += 1;
}
let conflicts = values
.into_iter()
.filter(|(_, count)| *count > 1)
.map(|((category, key), values)| FactConflict {
category: category.to_owned(),
key: key.to_owned(),
values,
})
.collect();
let kept = keep_first(facts, MAX_KNOWLEDGE_FACTS, &mut dropped.facts);
let (facts, flags): (Vec<_>, Vec<_>) = kept.into_iter().unzip();
(facts, flags, conflicts)
}
fn merge_curated(inputs: &[Input], dropped: &mut Dropped) -> Vec<CuratedRef> {
let mut seen = BTreeSet::new();
let refs = inputs
.iter()
.flat_map(|input| input.ledger.curated_refs.iter())
.filter(|excerpt| {
let digest = crate::core::hasher::hash_hex(excerpt.content.as_bytes());
seen.insert((excerpt.path.clone(), digest))
})
.cloned()
.collect();
keep_first(refs, MAX_CURATED_REFS, &mut dropped.curated_refs)
}
fn merge_overlays(inputs: &[Input]) -> Vec<serde_json::Value> {
let mut seen = BTreeSet::new();
inputs
.iter()
.flat_map(|input| input.ledger.active_overlays.iter())
.filter(|overlay| seen.insert(overlay.to_string()))
.cloned()
.collect()
}
fn sum_tool_calls(inputs: &[Input]) -> ToolCallsSummary {
let mut sum = ToolCallsSummary::default();
for calls in inputs.iter().map(|input| &input.ledger.tool_calls) {
sum.total += calls.total;
for (tool, count) in &calls.by_tool {
*sum.by_tool.entry(tool.clone()).or_insert(0) += count;
}
for (mode, count) in &calls.by_ctx_read_mode {
*sum.by_ctx_read_mode.entry(mode.clone()).or_insert(0) += count;
}
}
sum
}
fn common<T: Clone + serde::Serialize>(
inputs: &[Input],
value: impl Fn(&HandoffLedgerV1) -> Option<&T>,
) -> Option<T> {
let first = value(&inputs.first()?.ledger)?;
let first_json = serde_json::to_value(first).ok()?;
inputs
.iter()
.all(|input| {
value(&input.ledger)
.and_then(|other| serde_json::to_value(other).ok())
.is_some_and(|other| other == first_json)
})
.then(|| first.clone())
}
fn union(inputs: &[Input], texts: impl Fn(&HandoffLedgerV1) -> &Vec<String>) -> Vec<String> {
let mut seen = BTreeSet::new();
inputs
.iter()
.flat_map(|input| texts(&input.ledger).iter())
.filter(|text| seen.insert(text.as_str()))
.cloned()
.collect()
}
fn keep_first<T>(mut items: Vec<T>, bound: usize, dropped: &mut usize) -> Vec<T> {
*dropped = items.len().saturating_sub(bound);
items.truncate(bound);
items
}
fn keep_last<T>(mut items: Vec<T>, bound: usize, dropped: &mut usize) -> Vec<T> {
let cut = items.len().saturating_sub(bound);
*dropped = cut;
items.drain(..cut);
items
}
fn short_digest(inputs: &[String]) -> String {
let joined = inputs.join("\n");
crate::core::hasher::hash_hex(joined.as_bytes())
.chars()
.take(16)
.collect()
}
fn rebind(
inputs: &[Input],
curated: Vec<CuratedRef>,
any_bound: bool,
dropped: &mut Dropped,
) -> Result<(Vec<CuratedRef>, Vec<SourceRef>), String> {
let required = crate::core::context_admission::stored::source_authority_required();
let refused = || Withheld::new(&[SOURCE_CHANGED]).message("handoff merge");
let mut remaining = crate::core::limits::max_read_bytes();
crate::core::archive::authority::without_capture(|| {
let mut sources = Vec::new();
let mut kept = Vec::new();
for excerpt in curated {
let named = inputs.iter().find_map(|input| {
let (project, files) = input.files.as_ref()?;
files
.iter()
.find(|(path, tool, _)| *path == excerpt.path && tool == CURATED_TOOL)
.map(|(path, tool, digest)| (project, path, tool, digest))
});
let Some((project, path, tool, digest)) = named else {
dropped.unbound_curated += 1;
continue;
};
match SourceRef::read_as_admitted_within(path, project, tool, digest, &mut remaining) {
Some(source) => {
sources.push(source);
kept.push(excerpt);
}
None if required => return Err(refused()),
None => dropped.unbound_curated += 1,
}
}
if any_bound {
for input in inputs.iter().filter(|input| !input.bound.is_empty()) {
let Some((project, files)) = input.files.as_ref() else {
continue;
};
for (path, tool, digest) in files.iter().filter(|(_, tool, _)| tool != CURATED_TOOL)
{
match SourceRef::read_as_admitted_within(
path,
project,
tool,
digest,
&mut remaining,
) {
Some(source) => sources.push(source),
None if required => return Err(refused()),
None => {}
}
}
}
}
Ok((kept, sources))
})
}