Skip to main content

tx3_resolver/
job.rs

1use std::collections::{BTreeMap, HashMap};
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use tx3_tir::compile::{CompiledTx, Compiler};
7use tx3_tir::encoding::AnyTir;
8use tx3_tir::model::core::{Utxo, UtxoRef, UtxoSet};
9use tx3_tir::model::v1beta0 as tir;
10use tx3_tir::reduce::{Apply as _, ArgMap};
11use tx3_tir::Node as _;
12
13use crate::inputs::CanonicalQuery;
14use crate::{Error, UtxoStore};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub enum ResolveLog {
18    ArgsApplied(AnyTir),
19    FeesApplied(AnyTir),
20    CompilerApplied(AnyTir),
21    Reduced(AnyTir),
22    InputsResolved(AnyTir),
23    FinalReduced(AnyTir),
24    Compiled(CompiledTx),
25    Converged,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ResolveLogEntry {
30    pub round: usize,
31    pub event: ResolveLog,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct ResolveJob {
36    // Inputs (set once at creation)
37    pub original_tir: AnyTir,
38    pub args: ArgMap,
39    pub compiler: Value,
40
41    // Pipeline state
42    pub round: usize,
43    pub last_eval: Option<CompiledTx>,
44    pub converged: bool,
45
46    // Timeline of state changes across all rounds
47    pub log: Vec<ResolveLogEntry>,
48
49    // Input resolution state (overwritten each eval pass)
50    pub input_queries: Vec<QueryResolution>,
51    pub input_pool: Option<HashMap<UtxoRef, Utxo>>,
52}
53
54/// Tracks a single input query as it progresses through the resolution
55/// pipeline: narrow → approximate (candidates) → assign (selection).
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct QueryResolution {
58    pub name: String,
59    pub query: CanonicalQuery,
60    /// Ranked candidate UTxOs, set by the approximate stage.
61    #[serde(skip)]
62    pub candidates: Vec<Utxo>,
63    /// Final UTxO selection, set by the assign stage.
64    pub selection: Option<UtxoSet>,
65}
66
67impl QueryResolution {
68    pub fn ensure_resolved(&self, pool_refs: &[UtxoRef]) -> Result<(), Error> {
69        match &self.selection {
70            Some(sel) if !sel.is_empty() => Ok(()),
71            _ => Err(Error::InputNotResolved(
72                self.name.clone(),
73                self.query.clone(),
74                pool_refs.to_vec(),
75            )),
76        }
77    }
78}
79
80impl ResolveJob {
81    pub fn new(tx: AnyTir, args: ArgMap) -> Self {
82        Self {
83            original_tir: tx,
84            args,
85            compiler: Value::Null,
86            round: 0,
87            last_eval: None,
88            converged: false,
89            log: Vec::new(),
90            input_queries: Vec::new(),
91            input_pool: None,
92        }
93    }
94
95    pub fn record(&mut self, event: ResolveLog) {
96        self.log.push(ResolveLogEntry {
97            round: self.round,
98            event,
99        });
100    }
101
102    pub fn resolved_tir(&self) -> &AnyTir {
103        self.log
104            .iter()
105            .rev()
106            .find_map(|entry| match &entry.event {
107                ResolveLog::ArgsApplied(tir) => Some(tir),
108                _ => None,
109            })
110            .expect("apply_args must be called before eval_pass")
111    }
112
113    pub fn set_input_queries(&mut self, queries: Vec<(String, CanonicalQuery)>) {
114        self.input_queries = queries
115            .into_iter()
116            .map(|(name, query)| QueryResolution {
117                name,
118                query,
119                candidates: Vec::new(),
120                selection: None,
121            })
122            .collect();
123    }
124
125    pub fn pool_refs(&self) -> Vec<UtxoRef> {
126        self.input_pool
127            .as_ref()
128            .map(|p| p.keys().cloned().collect())
129            .unwrap_or_default()
130    }
131
132    pub fn to_input_map(&self) -> BTreeMap<String, UtxoSet> {
133        self.input_queries
134            .iter()
135            .filter_map(|qr| {
136                qr.selection
137                    .as_ref()
138                    .map(|sel| (qr.name.clone(), sel.clone()))
139            })
140            .collect()
141    }
142
143    fn apply_args(&mut self) -> Result<(), Error> {
144        let params = tx3_tir::reduce::find_params(&self.original_tir);
145
146        for (key, ty) in params.iter() {
147            if !self.args.contains_key(key) {
148                return Err(Error::MissingTxArg {
149                    key: key.to_string(),
150                    ty: ty.clone(),
151                });
152            };
153        }
154
155        let tir = tx3_tir::reduce::apply_args(self.original_tir.clone(), &self.args)?;
156        self.record(ResolveLog::ArgsApplied(tir));
157
158        Ok(())
159    }
160
161    async fn eval_pass<C, S>(&mut self, compiler: &mut C, utxos: &S) -> Result<(), Error>
162    where
163        C: Compiler<Expression = tir::Expression, CompilerOp = tir::CompilerOp>,
164        S: UtxoStore,
165    {
166        let base_tir = self.resolved_tir().clone();
167        let fees = self.last_eval.as_ref().map(|e| e.fee).unwrap_or(0);
168
169        let attempt = tx3_tir::reduce::apply_fees(base_tir, fees)?;
170        self.record(ResolveLog::FeesApplied(attempt.clone()));
171
172        let attempt = attempt.apply(compiler)?;
173        self.record(ResolveLog::CompilerApplied(attempt.clone()));
174
175        let attempt = tx3_tir::reduce::reduce(attempt)?;
176        self.record(ResolveLog::Reduced(attempt.clone()));
177
178        let attempt = self.resolve_inputs(attempt, utxos).await?;
179        self.record(ResolveLog::InputsResolved(attempt.clone()));
180
181        let attempt = tx3_tir::reduce::reduce(attempt)?;
182        self.record(ResolveLog::FinalReduced(attempt.clone()));
183
184        if !attempt.is_constant() {
185            return Err(Error::CantCompileNonConstantTir);
186        }
187
188        let eval = compiler.compile(&attempt)?;
189
190        let converged = self.last_eval.as_ref().map_or(false, |prev| eval == *prev);
191
192        self.record(ResolveLog::Compiled(eval.clone()));
193        self.last_eval = Some(eval);
194
195        if converged {
196            self.converged = true;
197            self.record(ResolveLog::Converged);
198        }
199
200        self.round += 1;
201
202        Ok(())
203    }
204
205    pub async fn execute<C, S>(
206        &mut self,
207        compiler: &mut C,
208        utxos: &S,
209        max_optimize_rounds: usize,
210    ) -> Result<CompiledTx, Error>
211    where
212        C: Compiler<Expression = tir::Expression, CompilerOp = tir::CompilerOp>,
213        S: UtxoStore,
214    {
215        let max_optimize_rounds = max_optimize_rounds.max(3);
216
217        self.compiler = match serde_json::to_value(&*compiler) {
218            Ok(value) => value,
219            Err(err) => {
220                tracing::warn!(error = %err, "failed to serialize compiler for diagnostic dump");
221                Value::Null
222            }
223        };
224
225        self.apply_args()?;
226
227        loop {
228            self.eval_pass(compiler, utxos).await?;
229
230            if self.converged || self.round > max_optimize_rounds {
231                break;
232            }
233        }
234
235        Ok(self.last_eval.clone().unwrap())
236    }
237}
238
239pub async fn resolve_tx<C, S>(
240    tx: AnyTir,
241    args: &ArgMap,
242    compiler: &mut C,
243    utxos: &S,
244    max_optimize_rounds: usize,
245) -> Result<CompiledTx, Error>
246where
247    C: Compiler<Expression = tir::Expression, CompilerOp = tir::CompilerOp>,
248    S: UtxoStore,
249{
250    let mut job = ResolveJob::new(tx, args.clone());
251
252    let result = job.execute(compiler, utxos, max_optimize_rounds).await;
253
254    if let Ok(dir) = std::env::var("TX3_DIAGNOSTIC_DUMP") {
255        let _ = crate::dump::dump_to_dir(&job, Path::new(&dir));
256    }
257
258    result
259}