feat(0082): cost-graph composition — VolSlippageCost + CostSum net-R aggregate
Cycle 2 of the "Cost-model graph (in R)" milestone (#148): the milestone's real architectural claim — the cost graph composes. Two cost nodes now sum into one net-R curve while summarize_r and the net_r_equity tap stay structurally unchanged. - VolSlippageCost (aura-std): a second, STATE-DEPENDENT cost node; per-trade charge = slip_vol_mult * volatility / |entry-stop|, in R. The vol is an independent short-horizon realized range (SLIP_VOL_LENGTH=5, distinct from the stop's EWMA-3) — scaling slippage by the stop's own vol would collapse cost-in-R to a constant (indistinguishable from ConstantCost). - CostSum (aura-std): the cost-graph OUTPUT node — sums N cost nodes' 3-field cost-in-R records per-field into one aggregate. summarize_r and net_r_equity read the aggregate (one home for cost; n=1 is the identity, so the cost path is uniform). A future CostNode trait (deferred) will unify the cost-triple the two producer nodes currently restate by-convention. - Run path: --cost-per-trade and --slip-vol-mult combine, their costs summing into the net-R curve; a hoisted vol proxy (RollingMax-RollingMin) keeps the single feed. Run-path-scoped; sweep/walkforward/mc pass None. Co-temporality contract (the load-bearing design decision; corrected from the signed spec after the implement-loop correctly BLOCKED Task 3 on it). summarize_r positional-joins cost[i] <-> record[i], so the cost stream must be co-temporal 1:1 with the PM record. A cost node is therefore gated ONLY by the PM geometry (closed/open/entry/stop); a not-yet-warm state input (the vol proxy warms later than PM) contributes 0 cost that cycle rather than withholding and desyncing the stream. This makes co-temporality structural + warmup-independent, preserves the C18 golden, and generalizes to any future cost factor. The rejected alternative (a key-join in summarize_r) would have moved the golden and pushed cost-graph logic into the post-run fold. Recorded on #148. Tests: VolSlippageCost + CostSum unit sets (incl. the co-temporal-zero-during- warmup case); the CLI composition run (both flags -> net_both < net_flat, net_r_equity persisted); node-level EXACT additive composition (net_both == net_flat + net_vol - gross over the real nodes), the aggregate net_r_equity == post-run net total, and CostSum(1) identity. Full workspace suite green; clippy -D warnings clean; the no-cost C18 golden byte-identical (the regression floor). Deferred (later cycles of this milestone): the general CostNode trait + cost-graph composite-builder (now justified by two concrete nodes), the conviction-weighting R-aggregation axis, and cost on the reduce-mode sweep path. Spec + plan amended to the corrected contract; both are cycle ephemera (git rm at cycle close). refs #148
This commit is contained in:
+132
-34
@@ -29,9 +29,9 @@ use aura_registry::{
|
||||
FamilyMember, Generalization, NameKind, PlateauMode, Registry, RunTraces, TraceStore, WriteKind,
|
||||
};
|
||||
use aura_std::{
|
||||
Add, Bias, ConstantCost, Delay, Ema, GatedRecorder, Gt, Latch, LinComb, LongOnly, Mul, Recorder,
|
||||
RollingMax, RollingMin, SeriesReducer, SimBroker, Sma, Sqrt, Sub, PM_FIELD_NAMES,
|
||||
PM_RECORD_KINDS,
|
||||
Add, Bias, ConstantCost, CostSum, Delay, Ema, GatedRecorder, Gt, Latch, LinComb, LongOnly, Mul,
|
||||
Recorder, RollingMax, RollingMin, SeriesReducer, SimBroker, Sma, Sqrt, Sub, VolSlippageCost,
|
||||
PM_FIELD_NAMES, PM_RECORD_KINDS,
|
||||
};
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::sync::LazyLock;
|
||||
@@ -2565,6 +2565,41 @@ const STAGE1_R_STOP_LENGTH: i64 = 3;
|
||||
/// `StopRule::Vol` and its manifest record, like [`STAGE1_R_STOP_LENGTH`].
|
||||
const STAGE1_R_STOP_K: f64 = 2.0;
|
||||
|
||||
/// Short-horizon realized-range window for vol-scaled slippage. Deliberately
|
||||
/// distinct from `STAGE1_R_STOP_LENGTH` (3): scaling slippage by the stop's own
|
||||
/// vol would collapse cost-in-R to a constant (spec 0082). Short enough to warm
|
||||
/// within the synthetic smoke fixture so the run path exercises non-zero slippage.
|
||||
const SLIP_VOL_LENGTH: i64 = 5;
|
||||
|
||||
/// The maximum number of cost nodes the run-path cost graph wires (flat cost +
|
||||
/// vol slippage). Sizes the interned `CostSum` input-port names below.
|
||||
const MAX_RUN_COST_NODES: usize = 2;
|
||||
|
||||
/// The 3-field cost-in-R record order — a lockstep contract with each cost node's
|
||||
/// output schema and `CostSum`'s inputs (and `summarize_r`'s `cost_col`).
|
||||
const COST_FIELDS: [&str; 3] = ["cost_in_r", "cum_cost_in_r", "open_cost_in_r"];
|
||||
|
||||
/// Interned `cost[k].<field>` `CostSum` input-port names, built once. Same
|
||||
/// `&'static str`-from-a-static rationale as `COL_PORTS`: `GraphBuilder::input`
|
||||
/// wants `&'static str`, so the names live in a `static` rather than being
|
||||
/// `format!(...).leak()`ed per build.
|
||||
static COST_SUM_PORTS: LazyLock<Vec<String>> = LazyLock::new(|| {
|
||||
let mut v = Vec::with_capacity(MAX_RUN_COST_NODES * 3);
|
||||
for k in 0..MAX_RUN_COST_NODES {
|
||||
for field in COST_FIELDS {
|
||||
v.push(format!("cost[{k}].{field}"));
|
||||
}
|
||||
}
|
||||
v
|
||||
});
|
||||
|
||||
/// Which cost nodes the run-path cost graph builds. At least one field is `Some`
|
||||
/// (the carrier `Option` is `None` when no cost flag was given).
|
||||
struct CostConfig {
|
||||
const_cost: Option<f64>, // --cost-per-trade
|
||||
slip_vol_mult: Option<f64>, // --slip-vol-mult
|
||||
}
|
||||
|
||||
/// Which data a `run` drives a harness on: the built-in synthetic stream, or real M1
|
||||
/// close bars for a vetted symbol over an optional window.
|
||||
enum RunData {
|
||||
@@ -2621,7 +2656,7 @@ fn stage1_r_graph(
|
||||
slow_len: Option<i64>,
|
||||
stop_open: bool,
|
||||
reduce: bool,
|
||||
cost: Option<(f64, mpsc::Sender<(Timestamp, Vec<Scalar>)>, mpsc::Sender<(Timestamp, Vec<Scalar>)>)>,
|
||||
cost: Option<(CostConfig, mpsc::Sender<(Timestamp, Vec<Scalar>)>, mpsc::Sender<(Timestamp, Vec<Scalar>)>)>,
|
||||
) -> Composite {
|
||||
let mut g = GraphBuilder::new("stage1_r");
|
||||
// SMA-cross signal → Bias (the same signal the pip sample uses).
|
||||
@@ -2669,11 +2704,32 @@ fn stage1_r_graph(
|
||||
} else {
|
||||
g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx_r))
|
||||
};
|
||||
// Hoisted above the single main feed: the short-horizon vol proxy iff a
|
||||
// vol-slippage cost is actually wired (run path, non-reduce), so its `price`
|
||||
// inputs join the one `price_targets` array (no second feed call).
|
||||
let vol_proxy = match &cost {
|
||||
Some((cfg, _, _)) if !reduce && cfg.slip_vol_mult.is_some() => {
|
||||
let vhi = g.add(RollingMax::builder().named("slip_vol_hi").bind("length", Scalar::i64(SLIP_VOL_LENGTH)));
|
||||
let vlo = g.add(RollingMin::builder().named("slip_vol_lo").bind("length", Scalar::i64(SLIP_VOL_LENGTH)));
|
||||
let vrange = g.add(Sub::builder().named("slip_vol_range"));
|
||||
g.connect(vhi.output("value"), vrange.input("lhs"));
|
||||
g.connect(vlo.output("value"), vrange.input("rhs"));
|
||||
Some((vhi, vlo, vrange))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let price = g.source_role("price", ScalarKind::F64);
|
||||
g.feed(
|
||||
price,
|
||||
[fast.input("series"), slow.input("series"), broker.input("price"), exec.input("price")],
|
||||
);
|
||||
let mut price_targets = vec![
|
||||
fast.input("series"),
|
||||
slow.input("series"),
|
||||
broker.input("price"),
|
||||
exec.input("price"),
|
||||
];
|
||||
if let Some((vhi, vlo, _)) = vol_proxy {
|
||||
price_targets.push(vhi.input("series"));
|
||||
price_targets.push(vlo.input("series"));
|
||||
}
|
||||
g.feed(price, price_targets);
|
||||
g.connect(fast.output("value"), spread.input("lhs"));
|
||||
g.connect(slow.output("value"), spread.input("rhs"));
|
||||
g.connect(spread.output("value"), exposure.input("signal"));
|
||||
@@ -2695,15 +2751,39 @@ fn stage1_r_graph(
|
||||
g.connect(exec.output("cum_realized_r"), r_equity.input("term[0]"));
|
||||
g.connect(exec.output("unrealized_r"), r_equity.input("term[1]"));
|
||||
g.connect(r_equity.output("value"), req.input("col[0]"));
|
||||
if let Some((cost_per_trade, tx_net, tx_cost)) = cost {
|
||||
let cost_node = g.add(
|
||||
ConstantCost::builder().bind("cost_per_trade", Scalar::f64(cost_per_trade)),
|
||||
);
|
||||
g.connect(exec.output("closed_this_cycle"), cost_node.input("closed"));
|
||||
g.connect(exec.output("open"), cost_node.input("open"));
|
||||
g.connect(exec.output("entry_price"), cost_node.input("entry_price"));
|
||||
g.connect(exec.output("stop_price"), cost_node.input("stop_price"));
|
||||
// net_r_equity = cum_realized_r + unrealized_r - cum_cost_in_r - open_cost_in_r
|
||||
if let Some((cfg, tx_net, tx_cost)) = cost {
|
||||
let n = cfg.const_cost.is_some() as usize + cfg.slip_vol_mult.is_some() as usize;
|
||||
let agg = g.add(CostSum::builder(n));
|
||||
let mut slot = 0usize;
|
||||
|
||||
if let Some(cpt) = cfg.const_cost {
|
||||
let cc = g.add(ConstantCost::builder().bind("cost_per_trade", Scalar::f64(cpt)));
|
||||
g.connect(exec.output("closed_this_cycle"), cc.input("closed"));
|
||||
g.connect(exec.output("open"), cc.input("open"));
|
||||
g.connect(exec.output("entry_price"), cc.input("entry_price"));
|
||||
g.connect(exec.output("stop_price"), cc.input("stop_price"));
|
||||
for (f, field) in COST_FIELDS.iter().copied().enumerate() {
|
||||
g.connect(cc.output(field), agg.input(COST_SUM_PORTS[slot * 3 + f].as_str()));
|
||||
}
|
||||
slot += 1;
|
||||
}
|
||||
|
||||
if let Some(svm) = cfg.slip_vol_mult {
|
||||
let (_, _, vrange) = vol_proxy.expect("vol proxy is built whenever slip_vol_mult is set");
|
||||
let vs = g.add(VolSlippageCost::builder().bind("slip_vol_mult", Scalar::f64(svm)));
|
||||
g.connect(exec.output("closed_this_cycle"), vs.input("closed"));
|
||||
g.connect(exec.output("open"), vs.input("open"));
|
||||
g.connect(exec.output("entry_price"), vs.input("entry_price"));
|
||||
g.connect(exec.output("stop_price"), vs.input("stop_price"));
|
||||
g.connect(vrange.output("value"), vs.input("volatility"));
|
||||
for (f, field) in COST_FIELDS.iter().copied().enumerate() {
|
||||
g.connect(vs.output(field), agg.input(COST_SUM_PORTS[slot * 3 + f].as_str()));
|
||||
}
|
||||
slot += 1;
|
||||
}
|
||||
debug_assert_eq!(slot, n);
|
||||
|
||||
// net_r_equity = cum_realized_r + unrealized_r - Σcum_cost_in_r - Σopen_cost_in_r
|
||||
let net_eq = g.add(
|
||||
LinComb::builder(4)
|
||||
.bind("weights[0]", Scalar::f64(1.0))
|
||||
@@ -2713,22 +2793,20 @@ fn stage1_r_graph(
|
||||
);
|
||||
g.connect(exec.output("cum_realized_r"), net_eq.input("term[0]"));
|
||||
g.connect(exec.output("unrealized_r"), net_eq.input("term[1]"));
|
||||
g.connect(cost_node.output("cum_cost_in_r"), net_eq.input("term[2]"));
|
||||
g.connect(cost_node.output("open_cost_in_r"), net_eq.input("term[3]"));
|
||||
g.connect(agg.output("cum_cost_in_r"), net_eq.input("term[2]"));
|
||||
g.connect(agg.output("open_cost_in_r"), net_eq.input("term[3]"));
|
||||
let net_rec = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_net));
|
||||
g.connect(net_eq.output("value"), net_rec.input("col[0]"));
|
||||
// The cost stream `summarize_r` folds: the `ConstantCost` node's full
|
||||
// [cost_in_r, cum_cost_in_r, open_cost_in_r] schema. `summarize_r`'s `cost_col`
|
||||
// reads col 0 (per-close) and col 2 (window-end open) — the lockstep contract
|
||||
// with aura-std's ConstantCost output — so the stream is recorded 3-wide.
|
||||
|
||||
// The aggregate cost record summarize_r folds (col 0 per-close, col 2 window-end).
|
||||
let cost_rec = g.add(Recorder::builder(
|
||||
vec![ScalarKind::F64, ScalarKind::F64, ScalarKind::F64],
|
||||
Firing::Any,
|
||||
tx_cost,
|
||||
));
|
||||
g.connect(cost_node.output("cost_in_r"), cost_rec.input("col[0]"));
|
||||
g.connect(cost_node.output("cum_cost_in_r"), cost_rec.input("col[1]"));
|
||||
g.connect(cost_node.output("open_cost_in_r"), cost_rec.input("col[2]"));
|
||||
g.connect(agg.output("cost_in_r"), cost_rec.input("col[0]"));
|
||||
g.connect(agg.output("cum_cost_in_r"), cost_rec.input("col[1]"));
|
||||
g.connect(agg.output("open_cost_in_r"), cost_rec.input("col[2]"));
|
||||
}
|
||||
}
|
||||
g.build().expect("stage1_r wiring resolves")
|
||||
@@ -2953,7 +3031,12 @@ fn stage1_meanrev_graph(
|
||||
/// round-trip cost per trade (price units): `Some(c)` wires the `ConstantCost` node and
|
||||
/// the `net_r_equity` tap and folds the cost stream into `net_expectancy_r`; `None` is the
|
||||
/// frictionless gross-R baseline (net == gross, no extra tap, byte-unchanged).
|
||||
fn run_stage1_r(data: RunData, trace: Option<&str>, cost: Option<f64>) -> RunReport {
|
||||
fn run_stage1_r(
|
||||
data: RunData,
|
||||
trace: Option<&str>,
|
||||
const_cost: Option<f64>,
|
||||
slip_vol_mult: Option<f64>,
|
||||
) -> RunReport {
|
||||
if let Some(n) = trace
|
||||
&& let Err(e) = TraceStore::open("runs").ensure_name_free(n, WriteKind::Run)
|
||||
{
|
||||
@@ -2966,7 +3049,11 @@ fn run_stage1_r(data: RunData, trace: Option<&str>, cost: Option<f64>) -> RunRep
|
||||
let (tx_req, rx_req) = mpsc::channel();
|
||||
let (tx_net, rx_net) = mpsc::channel();
|
||||
let (tx_cost, rx_cost) = mpsc::channel();
|
||||
let cost_bundle = cost.map(|c| (c, tx_net, tx_cost));
|
||||
let cost_bundle = if const_cost.is_some() || slip_vol_mult.is_some() {
|
||||
Some((CostConfig { const_cost, slip_vol_mult }, tx_net, tx_cost))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let flat = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, Some(2), Some(4), false, false, cost_bundle)
|
||||
.compile_with_params(&[])
|
||||
.expect("valid stage1-r blueprint");
|
||||
@@ -3061,16 +3148,17 @@ struct RunArgs {
|
||||
data: RunData,
|
||||
trace: Option<String>,
|
||||
cost: Option<f64>,
|
||||
slip_vol_mult: Option<f64>,
|
||||
}
|
||||
|
||||
/// Parse the `run` tail: `[--harness <name>] [--real <SYM> [--from <ms>] [--to <ms>]]
|
||||
/// [--trace <name>] [--cost-per-trade <f64>]` in any order, each flag at most once.
|
||||
/// [--trace <name>] [--cost-per-trade <f64>] [--slip-vol-mult <f64>]` in any order, each flag at most once.
|
||||
/// `--harness sma` is the default (`--harness macd` / `--harness stage1-r` select the
|
||||
/// others); `--from`/`--to` are legal only with `--real`. Pure (no I/O, no exit) so the
|
||||
/// grammar is unit-testable; `main` does the side effects.
|
||||
fn parse_run_args(rest: &[&str]) -> Result<RunArgs, String> {
|
||||
let usage = || {
|
||||
"usage: aura run [--harness <sma|macd|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>] [--cost-per-trade <f64>]"
|
||||
"usage: aura run [--harness <sma|macd|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>] [--cost-per-trade <f64>] [--slip-vol-mult <f64>]"
|
||||
.to_string()
|
||||
};
|
||||
let mut harness: Option<HarnessKind> = None;
|
||||
@@ -3079,6 +3167,7 @@ fn parse_run_args(rest: &[&str]) -> Result<RunArgs, String> {
|
||||
let mut to: Option<i64> = None;
|
||||
let mut trace: Option<String> = None;
|
||||
let mut cost: Option<f64> = None;
|
||||
let mut slip_vol_mult: Option<f64> = None;
|
||||
let mut tail = rest;
|
||||
while let Some((flag, t)) = tail.split_first() {
|
||||
match *flag {
|
||||
@@ -3120,6 +3209,15 @@ fn parse_run_args(rest: &[&str]) -> Result<RunArgs, String> {
|
||||
cost = Some(value.parse().map_err(|_| usage())?);
|
||||
tail = t;
|
||||
}
|
||||
"--slip-vol-mult" if slip_vol_mult.is_none() => {
|
||||
let (value, t) = t.split_first().ok_or_else(usage)?;
|
||||
let v: f64 = value.parse().map_err(|_| usage())?;
|
||||
if v < 0.0 {
|
||||
return Err(usage());
|
||||
}
|
||||
slip_vol_mult = Some(v);
|
||||
tail = t;
|
||||
}
|
||||
_ => return Err(usage()),
|
||||
}
|
||||
}
|
||||
@@ -3132,7 +3230,7 @@ fn parse_run_args(rest: &[&str]) -> Result<RunArgs, String> {
|
||||
Some(s) => RunData::Real { symbol: s, from, to },
|
||||
None => RunData::Synthetic,
|
||||
};
|
||||
Ok(RunArgs { harness, data, trace, cost })
|
||||
Ok(RunArgs { harness, data, trace, cost, slip_vol_mult })
|
||||
}
|
||||
|
||||
/// Route a parsed `run` invocation to its harness handler. The compile-time `match` IS
|
||||
@@ -3142,7 +3240,7 @@ fn run_dispatch(args: RunArgs) -> Result<RunReport, String> {
|
||||
Ok(match (args.harness, args.data) {
|
||||
(HarnessKind::Sma, RunData::Synthetic) => run_sample(trace),
|
||||
(HarnessKind::Macd, RunData::Synthetic) => run_macd(trace),
|
||||
(HarnessKind::Stage1R, data) => run_stage1_r(data, trace, args.cost),
|
||||
(HarnessKind::Stage1R, data) => run_stage1_r(data, trace, args.cost, args.slip_vol_mult),
|
||||
(HarnessKind::Sma, RunData::Real { symbol, from, to }) => {
|
||||
run_sample_real(&symbol, from, to, trace)
|
||||
}
|
||||
@@ -3153,7 +3251,7 @@ fn run_dispatch(args: RunArgs) -> Result<RunReport, String> {
|
||||
}
|
||||
|
||||
const USAGE: &str =
|
||||
"usage: aura run [--harness <sma|macd|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>] [--cost-per-trade <f64>] | aura chart <name> [--tap <t>] [--panels] | aura graph | aura sweep [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura mc [--name <n>|--trace <n>] | aura mc --strategy stage1-r [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--block-len <n>] [--resamples <n>] [--seed <n>] | aura walkforward [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--select <argmax|plateau:mean|plateau:worst>] | aura generalize [--strategy stage1-r] --real <SYM1,SYM2,...> --fast <n> --slow <n> --stop-length <n> --stop-k <f> [--from <ms>] [--to <ms>] [--metric <r-metric>] [--name <n>] | aura runs families | aura runs family <id> [rank <metric>]";
|
||||
"usage: aura run [--harness <sma|macd|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>] [--cost-per-trade <f64>] [--slip-vol-mult <f64>] | aura chart <name> [--tap <t>] [--panels] | aura graph | aura sweep [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura mc [--name <n>|--trace <n>] | aura mc --strategy stage1-r [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--block-len <n>] [--resamples <n>] [--seed <n>] | aura walkforward [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--select <argmax|plateau:mean|plateau:worst>] | aura generalize [--strategy stage1-r] --real <SYM1,SYM2,...> --fast <n> --slow <n> --stop-length <n> --stop-k <f> [--from <ms>] [--to <ms>] [--metric <r-metric>] [--name <n>] | aura runs families | aura runs family <id> [rank <metric>]";
|
||||
|
||||
fn main() {
|
||||
// Restore the default SIGPIPE disposition. Rust's runtime sets SIGPIPE to SIG_IGN
|
||||
@@ -4370,7 +4468,7 @@ mod tests {
|
||||
fn run_stage1_r_synthetic_folds_an_r_block() {
|
||||
// the stage1-r harness scores the SMA-cross signal in R: one shell-callable run
|
||||
// yields a RunReport whose metrics.r is Some with a finite SQN and >= 1 trade.
|
||||
let report = run_stage1_r(RunData::Synthetic, None, None);
|
||||
let report = run_stage1_r(RunData::Synthetic, None, None, None);
|
||||
let r = report.metrics.r.as_ref().expect("stage1-r run must populate metrics.r");
|
||||
assert!(r.n_trades >= 1, "expected >= 1 trade, got {}", r.n_trades);
|
||||
assert!(r.sqn.is_finite(), "SQN must be finite, got {}", r.sqn);
|
||||
|
||||
Reference in New Issue
Block a user