Skip to main content

sim_lib_forge/
route.rs

1use sim_codec_bridge::{
2    BridgeCallArgument, BridgeCallPayload, BridgePacket, CallArgumentMedia, content_id_string,
3    packet_content_id, stamp_packet_cid,
4};
5use sim_kernel::{CapabilityName, Cx, Datum, DatumStore, Error, EvalFabric, Expr, Result, Symbol};
6use sim_lib_agent_runner_core::InjectionFence;
7use sim_lib_bridge::{RepairPolicy, effective_caps, run_ask_with_policy};
8
9use crate::{CompiledIntent, VerifyCatalog, packet_artifact::resolve_packet};
10
11/// One eval target in a cost-ordered route ladder.
12pub struct RouteTarget<'a> {
13    /// Stable route id recorded in provenance and attempt reports.
14    pub id: String,
15    /// Eval fabric used for this target.
16    pub fabric: &'a dyn EvalFabric,
17    /// Capabilities this concrete target requires in addition to the packet
18    /// request capabilities.
19    pub required_capabilities: Vec<Symbol>,
20    /// Optional model or placement card recorded when this target answers.
21    pub card: Option<String>,
22}
23
24impl<'a> RouteTarget<'a> {
25    /// Builds a route target with no extra target capability requirement.
26    pub fn new(id: impl Into<String>, fabric: &'a dyn EvalFabric) -> Self {
27        Self {
28            id: id.into(),
29            fabric,
30            required_capabilities: Vec::new(),
31            card: None,
32        }
33    }
34
35    /// Returns this target with explicit capability requirements.
36    pub fn requiring(mut self, required_capabilities: Vec<Symbol>) -> Self {
37        self.required_capabilities = required_capabilities;
38        self
39    }
40
41    /// Returns this target with a model or placement card descriptor.
42    pub fn with_card(mut self, card: impl Into<String>) -> Self {
43        self.card = Some(card.into());
44        self
45    }
46}
47
48/// Cost-aware route policy for a compiled intent call.
49pub struct RoutePolicy<'a> {
50    /// Cheap-first ordered ladder of eval targets.
51    pub ladder: Vec<RouteTarget<'a>>,
52    /// Number of terminal structural or semantic failures allowed on a target
53    /// before the router escalates to the next target.
54    pub escalate_after: u8,
55    /// BRIDGE bounded-repair retry count used inside one target before a
56    /// structural failure is counted.
57    pub repair_retries: u8,
58    /// Semantic verifier catalog that checks decoded answers.
59    pub verify_catalog: VerifyCatalog,
60}
61
62impl<'a> RoutePolicy<'a> {
63    /// Builds a route policy with default one-shot escalation and BRIDGE repair.
64    pub fn new(ladder: Vec<RouteTarget<'a>>, escalate_after: u8) -> Self {
65        Self {
66            ladder,
67            escalate_after: escalate_after.max(1),
68            repair_retries: RepairPolicy::default().max_retries,
69            verify_catalog: VerifyCatalog::new(),
70        }
71    }
72
73    /// Returns this policy with an explicit repair retry count.
74    pub fn with_repair_retries(mut self, repair_retries: u8) -> Self {
75        self.repair_retries = repair_retries.min(2);
76        self
77    }
78
79    /// Returns this policy with an explicit semantic verifier catalog.
80    pub fn with_verify_catalog(mut self, verify_catalog: VerifyCatalog) -> Self {
81        self.verify_catalog = verify_catalog;
82        self
83    }
84
85    fn repair_policy(&self) -> RepairPolicy {
86        RepairPolicy::new(self.repair_retries)
87    }
88}
89
90/// Status for one route attempt.
91#[derive(Clone, Debug, PartialEq, Eq)]
92pub enum RouteAttemptStatus {
93    /// The target was skipped before any model call.
94    Skipped,
95    /// The target produced a structural or semantic failure.
96    Failed,
97    /// The target answered and passed every required check.
98    Accepted,
99}
100
101/// One target attempt made by the router.
102#[derive(Clone, Debug, PartialEq, Eq)]
103pub struct RouteAttempt {
104    /// Target id attempted.
105    pub target: String,
106    /// Attempt status.
107    pub status: RouteAttemptStatus,
108    /// Structural or semantic reason, when the attempt did not answer.
109    pub reason: Option<String>,
110}
111
112/// Provenance for the routed answer.
113#[derive(Clone, Debug, PartialEq, Eq)]
114pub struct RouteProvenance {
115    /// Target id that produced the accepted answer.
116    pub target: String,
117    /// Optional model or placement card descriptor for the answering target.
118    pub card: Option<String>,
119    /// Content id string of the checked answer packet.
120    pub answer_packet: String,
121}
122
123/// Answer plus route provenance and attempt details.
124#[derive(Clone, Debug, PartialEq, Eq)]
125pub struct RoutedAnswer {
126    /// Decoded answer expression from the accepted BRIDGE reply packet.
127    pub answer: Expr,
128    /// Provenance for the accepted answer.
129    pub provenance: RouteProvenance,
130    /// Ordered attempts made before acceptance.
131    pub attempts: Vec<RouteAttempt>,
132}
133
134/// Runs a compiled intent through a cost-aware route ladder and returns only
135/// the accepted decoded answer.
136pub fn run_intent_routed(
137    cx: &mut Cx,
138    intent: &CompiledIntent,
139    args: &Expr,
140    policy: &RoutePolicy<'_>,
141) -> Result<Expr> {
142    Ok(run_intent_routed_report(cx, intent, args, policy)?.answer)
143}
144
145/// Runs a compiled intent through a cost-aware route ladder and returns route
146/// provenance with the accepted decoded answer.
147pub fn run_intent_routed_report(
148    cx: &mut Cx,
149    intent: &CompiledIntent,
150    args: &Expr,
151    policy: &RoutePolicy<'_>,
152) -> Result<RoutedAnswer> {
153    if policy.ladder.is_empty() {
154        return Err(Error::Eval("route policy ladder is empty".to_owned()));
155    }
156
157    let packet = resolve_packet(cx, &intent.packet)?;
158    let executable = bind_args(cx, &packet, args)?;
159    let mut attempts = Vec::new();
160
161    for target in &policy.ladder {
162        if let Some(reason) = skip_reason(cx, &executable, target)? {
163            attempts.push(RouteAttempt {
164                target: target.id.clone(),
165                status: RouteAttemptStatus::Skipped,
166                reason: Some(reason),
167            });
168            continue;
169        }
170
171        let mut failures = 0u8;
172        while failures < policy.escalate_after {
173            match run_target_once(cx, intent, &executable, target, policy) {
174                Ok((answer, answer_packet)) => {
175                    let packet_cid = answer_packet.header.cid.clone().unwrap_or_else(|| {
176                        packet_content_id(&answer_packet)
177                            .map(|id| content_id_string(&id))
178                            .unwrap_or_else(|_| "unhashable".to_owned())
179                    });
180                    attempts.push(RouteAttempt {
181                        target: target.id.clone(),
182                        status: RouteAttemptStatus::Accepted,
183                        reason: None,
184                    });
185                    return Ok(RoutedAnswer {
186                        answer,
187                        provenance: RouteProvenance {
188                            target: target.id.clone(),
189                            card: target
190                                .card
191                                .clone()
192                                .or_else(|| answer_packet.header.provenance.card.clone()),
193                            answer_packet: packet_cid,
194                        },
195                        attempts,
196                    });
197                }
198                Err(reason) => {
199                    failures = failures.saturating_add(1);
200                    attempts.push(RouteAttempt {
201                        target: target.id.clone(),
202                        status: RouteAttemptStatus::Failed,
203                        reason: Some(reason),
204                    });
205                }
206            }
207        }
208    }
209
210    Err(Error::Eval(format!(
211        "routed intent {} exhausted {} target(s): {}",
212        intent.name,
213        policy.ladder.len(),
214        attempt_summary(&attempts)
215    )))
216}
217
218fn run_target_once(
219    cx: &mut Cx,
220    intent: &CompiledIntent,
221    packet: &BridgePacket,
222    target: &RouteTarget<'_>,
223    policy: &RoutePolicy<'_>,
224) -> std::result::Result<(Expr, BridgePacket), String> {
225    let answer_packet =
226        run_ask_with_policy(cx, target.fabric, packet.clone(), policy.repair_policy())
227            .map_err(|err| format!("structural check failed: {err}"))?;
228    let answer = answer_from_packet(&answer_packet).map_err(|err| err.to_string())?;
229    let verify_report = policy
230        .verify_catalog
231        .verify_answer(cx, intent, &answer)
232        .map_err(|err| err.to_string())?;
233    if verify_report.accepted() {
234        Ok((answer, answer_packet))
235    } else {
236        Err(format!(
237            "semantic verification failed: {:?}",
238            verify_report.failed
239        ))
240    }
241}
242
243fn answer_from_packet(packet: &BridgePacket) -> Result<Expr> {
244    packet
245        .body
246        .iter()
247        .find(|part| part.id == packet.header.output)
248        .map(|part| part.payload.clone())
249        .ok_or_else(|| {
250            Error::Eval(format!(
251                "answer packet output part {} is missing",
252                packet.header.output
253            ))
254        })
255}
256
257fn skip_reason(
258    cx: &mut Cx,
259    packet: &BridgePacket,
260    target: &RouteTarget<'_>,
261) -> Result<Option<String>> {
262    let allowed = effective_caps(cx, packet)?;
263    for required in &target.required_capabilities {
264        let capability = capability_name(required);
265        if !allowed.contains(&capability) {
266            return Ok(Some(format!(
267                "target requires capability {capability}, outside packet ceiling"
268            )));
269        }
270    }
271    Ok(None)
272}
273
274fn bind_args(cx: &mut Cx, packet: &BridgePacket, args: &Expr) -> Result<BridgePacket> {
275    let mut bound = packet.canonicalized();
276    for part in &mut bound.body {
277        if part.kind != Symbol::qualified("bridge", "Call") {
278            continue;
279        }
280        let payload = BridgeCallPayload::from_expr(&part.payload)?.with_arg(fenced_arg(cx, args)?);
281        part.payload = payload.to_expr();
282        return stamp_packet_cid(&bound);
283    }
284    Err(Error::Eval(
285        "routed intent packet requires a bridge/Call part".to_owned(),
286    ))
287}
288
289fn fenced_arg(cx: &mut Cx, expr: &Expr) -> Result<BridgeCallArgument> {
290    let text = sim_codec_json::expr_to_json(expr).to_string();
291    let datum = Datum::String(text.clone());
292    let content_id = cx.datum_store_mut().intern(datum)?;
293    let fence = InjectionFence::for_content(&content_id);
294    Ok(BridgeCallArgument::new(
295        Symbol::qualified("forge", "args"),
296        Symbol::qualified("codec", "json"),
297        CallArgumentMedia::Text,
298        content_id_string(&content_id),
299        fence.wrap("forge-args", &text),
300    ))
301}
302
303fn capability_name(symbol: &Symbol) -> CapabilityName {
304    match symbol.namespace.as_deref() {
305        Some("capability") => CapabilityName::new(symbol.name.to_string()),
306        _ => CapabilityName::new(symbol.as_qualified_str()),
307    }
308}
309
310fn attempt_summary(attempts: &[RouteAttempt]) -> String {
311    if attempts.is_empty() {
312        return "no attempts".to_owned();
313    }
314    attempts
315        .iter()
316        .map(|attempt| {
317            let status = match attempt.status {
318                RouteAttemptStatus::Skipped => "skipped",
319                RouteAttemptStatus::Failed => "failed",
320                RouteAttemptStatus::Accepted => "accepted",
321            };
322            match &attempt.reason {
323                Some(reason) => format!("{} {status}: {reason}", attempt.target),
324                None => format!("{} {status}", attempt.target),
325            }
326        })
327        .collect::<Vec<_>>()
328        .join("; ")
329}