Skip to main content

supercode_interchange/orchestration/codec/
loss.rs

1//! What an export to a lossy format gives up (mirrored ontology C8, the codec model's points 3–5).
2//!
3//! Each codec declares, per IR feature, whether it carries it **exact**, **folded** (merged into
4//! something coarser) or **dropped**. Before writing, an export lists every folded and dropped item
5//! in the model being exported, as GIMP warns before flattening layers; it then proceeds and keeps
6//! the report beside the output. The native folder is never the export's destination, so what was
7//! folded or dropped still lives there. A native save has no report: it loses nothing.
8
9use std::fs;
10use std::path::Path;
11
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14
15use crate::orchestration::{Orchestration, SpeakerPolicy};
16use crate::Result;
17
18/// The report's file name in the destination folder.
19pub const LOSS_REPORT_FILE: &str = "supercode-export-report.json";
20
21/// How a format carries one IR feature.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
23#[serde(rename_all = "snake_case")]
24pub enum Carry {
25    /// Written as it is.
26    Exact,
27    /// Merged into something coarser.
28    Folded,
29    /// Not written.
30    Dropped,
31}
32
33/// One feature's declaration.
34#[derive(Debug, Clone, Copy)]
35pub struct FeatureCarry {
36    /// The IR feature.
37    pub feature: &'static str,
38    /// How the format carries it.
39    pub carry: Carry,
40    /// What happens to it, in words.
41    pub how: &'static str,
42}
43
44/// Hermes 0.21: one doer per profile, profile routes, no conversation records.
45pub const HERMES_FEATURES: &[FeatureCarry] = &[
46    FeatureCarry {
47        feature: "agents",
48        carry: Carry::Folded,
49        how: "every agent folds into its profile; Hermes sees one doer per profile",
50    },
51    FeatureCarry {
52        feature: "routes.agent",
53        carry: Carry::Folded,
54        how: "a route names its agent's profile",
55    },
56    FeatureCarry {
57        feature: "bindings.agent",
58        carry: Carry::Folded,
59        how: "a session is keyed by its profile (agent:<profile>:…)",
60    },
61    FeatureCarry {
62        feature: "conversations.speaker",
63        carry: Carry::Dropped,
64        how: "Hermes has one agent per surface; a multi-agent speaker policy is not written",
65    },
66    FeatureCarry {
67        feature: "conversations.participants",
68        carry: Carry::Dropped,
69        how: "Hermes keeps no participant list",
70    },
71    FeatureCarry {
72        feature: "transfers",
73        carry: Carry::Dropped,
74        how: "Hermes has no transfer between agents",
75    },
76    FeatureCarry {
77        feature: "handoffs",
78        carry: Carry::Exact,
79        how: "the session row's handoff_* columns",
80    },
81    FeatureCarry {
82        feature: "usage",
83        carry: Carry::Dropped,
84        how: "usage stays in the native folder; Hermes keeps its own token counts",
85    },
86    FeatureCarry {
87        feature: "budgets",
88        carry: Carry::Dropped,
89        how: "Hermes has no budget gate",
90    },
91    FeatureCarry {
92        feature: "jobs.session_target",
93        carry: Carry::Dropped,
94        how: "Hermes runs each fire in its own session; the key rides unread in jobs.json",
95    },
96];
97
98/// OpenClaw v2026.7: agents, bindings and sessionTarget are its own.
99pub const OPENCLAW_FEATURES: &[FeatureCarry] = &[
100    FeatureCarry {
101        feature: "agents",
102        carry: Carry::Exact,
103        how: "agents.* entries",
104    },
105    FeatureCarry {
106        feature: "routes.agent",
107        carry: Carry::Exact,
108        how: "bindings[].agentId",
109    },
110    FeatureCarry {
111        feature: "bindings.agent",
112        carry: Carry::Exact,
113        how: "the session key's agent segment",
114    },
115    FeatureCarry {
116        feature: "conversations.speaker",
117        carry: Carry::Dropped,
118        how: "OpenClaw routes a surface to one agent; a multi-agent speaker policy is not written",
119    },
120    FeatureCarry {
121        feature: "conversations.participants",
122        carry: Carry::Dropped,
123        how: "OpenClaw keeps no participant list",
124    },
125    FeatureCarry {
126        feature: "transfers",
127        carry: Carry::Dropped,
128        how: "OpenClaw has no transfer between agents",
129    },
130    FeatureCarry {
131        feature: "handoffs",
132        carry: Carry::Dropped,
133        how: "OpenClaw has no session handoff",
134    },
135    FeatureCarry {
136        feature: "usage",
137        carry: Carry::Dropped,
138        how: "usage stays in the native folder",
139    },
140    FeatureCarry {
141        feature: "budgets",
142        carry: Carry::Dropped,
143        how: "OpenClaw has no budget gate",
144    },
145    FeatureCarry {
146        feature: "jobs.session_target",
147        carry: Carry::Exact,
148        how: "cron jobs' sessionTarget",
149    },
150];
151
152/// One folded or dropped item of the model being exported.
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
154pub struct LossItem {
155    /// The IR feature.
156    pub feature: String,
157    /// Folded or dropped.
158    pub carry: Carry,
159    /// Which item (an agent, a conversation, a job …).
160    pub item: String,
161    /// What happens to it.
162    pub how: String,
163}
164
165/// Everything an export folds or drops, listed before it writes.
166#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
167pub struct LossReport {
168    /// The format exported to.
169    pub format: String,
170    /// The folded and dropped items.
171    pub items: Vec<LossItem>,
172}
173
174/// List every folded and dropped item of `o` under a format's declarations.
175pub fn loss_report(format: &str, o: &Orchestration, table: &[FeatureCarry]) -> LossReport {
176    let mut items = Vec::new();
177    let mut push = |feature: &str, item: String| {
178        if let Some(f) = table.iter().find(|f| f.feature == feature) {
179            if f.carry != Carry::Exact {
180                items.push(LossItem {
181                    feature: feature.into(),
182                    carry: f.carry,
183                    item,
184                    how: f.how.into(),
185                });
186            }
187        }
188    };
189    for (name, decl) in &o.agents {
190        push("agents", format!("{name} (profile {})", decl.profile));
191    }
192    for (pname, profile) in &o.profiles {
193        for route in &profile.routes {
194            if route.agent != *pname && !o.profiles.contains_key(&route.agent) {
195                push(
196                    "routes.agent",
197                    format!("route to {} in {pname}", route.agent),
198                );
199            }
200        }
201        for (slot, binding) in &profile.bindings {
202            if binding
203                .agent
204                .as_deref()
205                .is_some_and(|a| a != pname.as_str())
206            {
207                push("bindings.agent", format!("{pname} {slot}"));
208            }
209        }
210        if !profile.budgets.is_empty() {
211            push(
212                "budgets",
213                format!("{} budget(s) on {pname}", profile.budgets.len()),
214            );
215        }
216        for (id, job) in &profile.jobs {
217            if let Some(target) = &job.session_target {
218                push("jobs.session_target", format!("{pname} job {id}: {target}"));
219            }
220        }
221    }
222    for (id, conversation) in &o.conversations {
223        if !matches!(conversation.speaker, SpeakerPolicy::Addressed { .. }) {
224            push("conversations.speaker", id.clone());
225        }
226        if !conversation.participants.is_empty() {
227            push(
228                "conversations.participants",
229                format!("{id}: {} participant(s)", conversation.participants.len()),
230            );
231        }
232    }
233    for t in &o.transfers {
234        push(
235            "transfers",
236            format!("{} → {} in {}", t.from, t.to, t.conversation),
237        );
238    }
239    for h in &o.handoffs {
240        push("handoffs", format!("{} → {}", h.session, h.to));
241    }
242    for (session, usage) in &o.usage {
243        push(
244            "usage",
245            format!("{session}: {} record(s)", usage.usage.len()),
246        );
247    }
248    LossReport {
249        format: format.into(),
250        items,
251    }
252}
253
254/// Keep the report beside the output (before the export writes anything else).
255pub fn write_loss_report(dest: &Path, report: &LossReport) -> Result<()> {
256    fs::create_dir_all(dest)?;
257    fs::write(
258        dest.join(LOSS_REPORT_FILE),
259        serde_json::to_string_pretty(report).unwrap() + "\n",
260    )?;
261    Ok(())
262}