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: "org_layer",
48        carry: Carry::Folded,
49        how: "the org layer (the root, which nobody runs as) is written as Hermes's default profile, which Hermes runs",
50    },
51    FeatureCarry {
52        feature: "agents",
53        carry: Carry::Folded,
54        how: "every agent folds into its profile; Hermes sees one doer per profile",
55    },
56    FeatureCarry {
57        feature: "routes.agent",
58        carry: Carry::Folded,
59        how: "a route names its agent's profile",
60    },
61    FeatureCarry {
62        feature: "bindings.agent",
63        carry: Carry::Folded,
64        how: "a session is keyed by its profile (agent:<profile>:…)",
65    },
66    FeatureCarry {
67        feature: "conversations.speaker",
68        carry: Carry::Dropped,
69        how: "Hermes has one agent per surface; a multi-agent speaker policy is not written",
70    },
71    FeatureCarry {
72        feature: "conversations.participants",
73        carry: Carry::Dropped,
74        how: "Hermes keeps no participant list",
75    },
76    FeatureCarry {
77        feature: "transfers",
78        carry: Carry::Dropped,
79        how: "Hermes has no transfer between agents",
80    },
81    FeatureCarry {
82        feature: "handoffs",
83        carry: Carry::Exact,
84        how: "the session row's handoff_* columns",
85    },
86    FeatureCarry {
87        feature: "usage",
88        carry: Carry::Dropped,
89        how: "usage stays in the native folder; Hermes keeps its own token counts",
90    },
91    FeatureCarry {
92        feature: "budgets",
93        carry: Carry::Dropped,
94        how: "Hermes has no budget gate",
95    },
96    FeatureCarry {
97        feature: "jobs.session_target",
98        carry: Carry::Dropped,
99        how: "Hermes runs each fire in its own session; the key rides unread in jobs.json",
100    },
101];
102
103/// OpenClaw v2026.7: agents, bindings and sessionTarget are its own.
104pub const OPENCLAW_FEATURES: &[FeatureCarry] = &[
105    FeatureCarry {
106        feature: "agents",
107        carry: Carry::Exact,
108        how: "agents.* entries",
109    },
110    FeatureCarry {
111        feature: "routes.agent",
112        carry: Carry::Exact,
113        how: "bindings[].agentId",
114    },
115    FeatureCarry {
116        feature: "bindings.agent",
117        carry: Carry::Exact,
118        how: "the session key's agent segment",
119    },
120    FeatureCarry {
121        feature: "conversations.speaker",
122        carry: Carry::Dropped,
123        how: "OpenClaw routes a surface to one agent; a multi-agent speaker policy is not written",
124    },
125    FeatureCarry {
126        feature: "conversations.participants",
127        carry: Carry::Dropped,
128        how: "OpenClaw keeps no participant list",
129    },
130    FeatureCarry {
131        feature: "transfers",
132        carry: Carry::Dropped,
133        how: "OpenClaw has no transfer between agents",
134    },
135    FeatureCarry {
136        feature: "handoffs",
137        carry: Carry::Dropped,
138        how: "OpenClaw has no session handoff",
139    },
140    FeatureCarry {
141        feature: "usage",
142        carry: Carry::Dropped,
143        how: "usage stays in the native folder",
144    },
145    FeatureCarry {
146        feature: "budgets",
147        carry: Carry::Dropped,
148        how: "OpenClaw has no budget gate",
149    },
150    FeatureCarry {
151        feature: "jobs.session_target",
152        carry: Carry::Exact,
153        how: "cron jobs' sessionTarget",
154    },
155];
156
157/// One folded or dropped item of the model being exported.
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
159pub struct LossItem {
160    /// The IR feature.
161    pub feature: String,
162    /// Folded or dropped.
163    pub carry: Carry,
164    /// Which item (an agent, a conversation, a job …).
165    pub item: String,
166    /// What happens to it.
167    pub how: String,
168}
169
170/// Everything an export folds or drops, listed before it writes.
171#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
172pub struct LossReport {
173    /// The format exported to.
174    pub format: String,
175    /// The folded and dropped items.
176    pub items: Vec<LossItem>,
177}
178
179/// List every folded and dropped item of `o` under a format's declarations.
180pub fn loss_report(format: &str, o: &Orchestration, table: &[FeatureCarry]) -> LossReport {
181    let mut items = Vec::new();
182    let mut push = |feature: &str, item: String| {
183        if let Some(f) = table.iter().find(|f| f.feature == feature) {
184            if f.carry != Carry::Exact {
185                items.push(LossItem {
186                    feature: feature.into(),
187                    carry: f.carry,
188                    item,
189                    how: f.how.into(),
190                });
191            }
192        }
193    };
194    if o.profiles.contains_key("default") {
195        push("org_layer", "the root, as default".into());
196    }
197    for (name, decl) in &o.agents {
198        push("agents", format!("{name} (profile {})", decl.profile));
199    }
200    for (pname, profile) in &o.profiles {
201        for route in &profile.routes {
202            if route.agent != *pname && !o.profiles.contains_key(&route.agent) {
203                push(
204                    "routes.agent",
205                    format!("route to {} in {pname}", route.agent),
206                );
207            }
208        }
209        for (slot, binding) in &profile.bindings {
210            if binding
211                .agent
212                .as_deref()
213                .is_some_and(|a| a != pname.as_str())
214            {
215                push("bindings.agent", format!("{pname} {slot}"));
216            }
217        }
218        if !profile.budgets.is_empty() {
219            push(
220                "budgets",
221                format!("{} budget(s) on {pname}", profile.budgets.len()),
222            );
223        }
224        for (id, job) in &profile.jobs {
225            if let Some(target) = &job.session_target {
226                push("jobs.session_target", format!("{pname} job {id}: {target}"));
227            }
228        }
229    }
230    for (id, conversation) in &o.conversations {
231        if !matches!(conversation.speaker, SpeakerPolicy::Addressed { .. }) {
232            push("conversations.speaker", id.clone());
233        }
234        if !conversation.participants.is_empty() {
235            push(
236                "conversations.participants",
237                format!("{id}: {} participant(s)", conversation.participants.len()),
238            );
239        }
240    }
241    for t in &o.transfers {
242        push(
243            "transfers",
244            format!("{} → {} in {}", t.from, t.to, t.conversation),
245        );
246    }
247    for h in &o.handoffs {
248        push("handoffs", format!("{} → {}", h.session, h.to));
249    }
250    for (session, usage) in &o.usage {
251        push(
252            "usage",
253            format!("{session}: {} record(s)", usage.usage.len()),
254        );
255    }
256    LossReport {
257        format: format.into(),
258        items,
259    }
260}
261
262/// Keep the report beside the output (before the export writes anything else).
263pub fn write_loss_report(dest: &Path, report: &LossReport) -> Result<()> {
264    fs::create_dir_all(dest)?;
265    fs::write(
266        dest.join(LOSS_REPORT_FILE),
267        serde_json::to_string_pretty(report).unwrap() + "\n",
268    )?;
269    Ok(())
270}