use crate::ir::graph::*;
#[derive(Debug, Clone)]
pub struct ParseError {
pub line: usize,
pub message: String,
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "line {}: {}", self.line, self.message)
}
}
pub fn parse(text: &str) -> Result<ScGraph, ParseError> {
let lines: Vec<&str> = text.lines().collect();
if lines.is_empty() {
return Err(ParseError {
line: 0,
message: "empty input".to_string(),
});
}
let first = lines[0].trim();
let name = first
.strip_prefix("sc.graph @")
.and_then(|s| s.strip_suffix(" {"))
.ok_or_else(|| ParseError {
line: 1,
message: "expected 'sc.graph @name {'".to_string(),
})?
.to_string();
let mut graph = ScGraph::new(name);
for (line_idx, line) in lines.iter().enumerate().skip(1) {
let trimmed = line.trim();
if trimmed == "}" || trimmed.is_empty() {
continue;
}
if trimmed.contains("= sc.input") {
parse_input(trimmed, &mut graph, line_idx + 1)?;
} else if trimmed.starts_with("sc.output") {
parse_output(trimmed, &mut graph, line_idx + 1)?;
} else if trimmed.contains("= sc.constant") {
parse_constant(trimmed, &mut graph, line_idx + 1)?;
} else if trimmed.contains("= sc.encode") {
parse_encode(trimmed, &mut graph, line_idx + 1)?;
} else if trimmed.contains("= sc.xor") {
parse_xor(trimmed, &mut graph, line_idx + 1)?;
} else if trimmed.contains("= sc.and") {
parse_and(trimmed, &mut graph, line_idx + 1)?;
} else if trimmed.contains("= sc.reduce") {
parse_reduce(trimmed, &mut graph, line_idx + 1)?;
} else if trimmed.contains("= sc.popcount") {
parse_popcount(trimmed, &mut graph, line_idx + 1)?;
} else if trimmed.contains("= sc.dense_forward") {
parse_dense_forward(trimmed, &mut graph, line_idx + 1)?;
} else if trimmed.contains("= sc.graph_forward") {
parse_graph_forward(trimmed, &mut graph, line_idx + 1)?;
} else if trimmed.contains("= sc.softmax_attention") {
parse_softmax_attention(trimmed, &mut graph, line_idx + 1)?;
} else if trimmed.contains("= sc.kuramoto_step") {
parse_kuramoto_step(trimmed, &mut graph, line_idx + 1)?;
} else if trimmed.contains("= sc.lif_step") {
parse_lif_step(trimmed, &mut graph, line_idx + 1)?;
} else if trimmed.contains("= sc.scale") {
parse_scale(trimmed, &mut graph, line_idx + 1)?;
} else if trimmed.contains("= sc.offset") {
parse_offset(trimmed, &mut graph, line_idx + 1)?;
} else if trimmed.contains("= sc.div_const") {
parse_div_const(trimmed, &mut graph, line_idx + 1)?;
} else {
return Err(ParseError {
line: line_idx + 1,
message: format!("unrecognised op: {}", trimmed),
});
}
}
Ok(graph)
}
fn parse_value_id(s: &str) -> Result<ValueId, String> {
let s = s.trim().trim_matches(',');
s.strip_prefix('%')
.and_then(|n| n.parse::<u32>().ok())
.map(ValueId)
.ok_or_else(|| format!("invalid ValueId: '{}'", s))
}
fn parse_type(s: &str) -> Result<ScType, String> {
let s = s.trim();
if s == "rate" {
return Ok(ScType::Rate);
}
if s == "bool" {
return Ok(ScType::Bool);
}
if s == "u64" {
return Ok(ScType::UInt { width: 64 });
}
if let Some(w) = s.strip_prefix('u') {
if let Ok(width) = w.parse::<u32>() {
return Ok(ScType::UInt { width });
}
}
if let Some(w) = s.strip_prefix('i') {
if let Ok(width) = w.parse::<u32>() {
return Ok(ScType::SInt { width });
}
}
if let Some(inner) = s
.strip_prefix("bitstream<")
.and_then(|r| r.strip_suffix('>'))
{
let length = inner.parse::<usize>().map_err(|e| e.to_string())?;
return Ok(ScType::Bitstream { length });
}
if s == "bitstream" {
return Ok(ScType::Bitstream { length: 0 }); }
if let Some(inner) = s.strip_prefix("fixed<").and_then(|r| r.strip_suffix('>')) {
let parts: Vec<&str> = inner.split(',').collect();
if parts.len() == 2 {
let width = parts[0].trim().parse::<u32>().map_err(|e| e.to_string())?;
let frac = parts[1].trim().parse::<u32>().map_err(|e| e.to_string())?;
return Ok(ScType::FixedPoint { width, frac });
}
}
if let Some(inner) = s.strip_prefix("vec<").and_then(|r| r.strip_suffix('>')) {
if let Some(comma_pos) = inner.rfind(',') {
let elem_str = &inner[..comma_pos];
let count_str = inner[comma_pos + 1..].trim();
let element = parse_type(elem_str)?;
let count = count_str.parse::<usize>().map_err(|e| e.to_string())?;
return Ok(ScType::Vec {
element: Box::new(element),
count,
});
}
}
Err(format!("unrecognised type: '{}'", s))
}
fn extract_kv(text: &str, key: &str) -> Option<String> {
text.find(&format!("{}=", key)).map(|start| {
let rest = &text[start + key.len() + 1..];
let end = rest.find([',', ' ', ':']).unwrap_or(rest.len());
rest[..end].to_string()
})
}
fn make_err(line: usize, msg: impl Into<String>) -> ParseError {
ParseError {
line,
message: msg.into(),
}
}
fn parse_scalar_constant(val_str: &str, ty: &ScType, line: usize) -> Result<ScConst, ParseError> {
if val_str.contains('.') || matches!(ty, ScType::Rate) {
return val_str
.parse::<f64>()
.map(ScConst::F64)
.map_err(|e| make_err(line, e.to_string()));
}
match ty {
ScType::FixedPoint { .. } | ScType::SInt { .. } => val_str
.parse::<i64>()
.map(ScConst::I64)
.map_err(|e| make_err(line, e.to_string())),
_ => val_str
.parse::<u64>()
.map(ScConst::U64)
.map_err(|e| make_err(line, e.to_string())),
}
}
fn parse_vector_constant(val_str: &str, line: usize) -> Result<ScConst, ParseError> {
let inner = val_str
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.ok_or_else(|| make_err(line, "malformed vector constant"))?;
if inner.trim().is_empty() {
return Ok(ScConst::I64Vec(Vec::new()));
}
let is_float = inner.split(',').any(|part| part.trim().contains('.'));
if is_float {
let mut out = Vec::new();
for token in inner.split(',') {
out.push(
token
.trim()
.parse::<f64>()
.map_err(|e| make_err(line, e.to_string()))?,
);
}
Ok(ScConst::F64Vec(out))
} else {
let mut out = Vec::new();
for token in inner.split(',') {
out.push(
token
.trim()
.parse::<i64>()
.map_err(|e| make_err(line, e.to_string()))?,
);
}
Ok(ScConst::I64Vec(out))
}
}
fn parse_input(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
let parts: Vec<&str> = text.splitn(2, "= sc.input").collect();
if parts.len() != 2 {
return Err(make_err(line, "malformed sc.input"));
}
let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
let rest = parts[1].trim();
let name_start = rest
.find('"')
.ok_or_else(|| make_err(line, "missing name"))?;
let name_end = rest[name_start + 1..]
.find('"')
.ok_or_else(|| make_err(line, "unterminated name"))?;
let name = rest[name_start + 1..name_start + 1 + name_end].to_string();
let colon_pos = rest
.rfind(':')
.ok_or_else(|| make_err(line, "missing type"))?;
let ty = parse_type(&rest[colon_pos + 1..]).map_err(|e| make_err(line, e))?;
graph.next_id = graph.next_id.max(id.0 + 1);
graph.push(ScOp::Input { id, name, ty });
Ok(())
}
fn parse_output(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
let rest = text.strip_prefix("sc.output").unwrap_or(text).trim();
let name_start = rest
.find('"')
.ok_or_else(|| make_err(line, "missing name"))?;
let name_end = rest[name_start + 1..]
.find('"')
.ok_or_else(|| make_err(line, "unterminated name"))?;
let name = rest[name_start + 1..name_start + 1 + name_end].to_string();
let after_name = rest[name_start + 1 + name_end + 1..].trim();
let source = parse_value_id(after_name).map_err(|e| make_err(line, e))?;
let id = ValueId(graph.next_id);
graph.next_id += 1;
graph.push(ScOp::Output { id, name, source });
Ok(())
}
fn parse_constant(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
let parts: Vec<&str> = text.splitn(2, "= sc.constant").collect();
if parts.len() != 2 {
return Err(make_err(line, "malformed sc.constant"));
}
let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
let rest = parts[1].trim();
let colon_pos = rest
.rfind(':')
.ok_or_else(|| make_err(line, "missing type"))?;
let val_str = rest[..colon_pos].trim();
let ty = parse_type(&rest[colon_pos + 1..]).map_err(|e| make_err(line, e))?;
let value = if val_str.starts_with('[') {
parse_vector_constant(val_str, line)?
} else {
parse_scalar_constant(val_str, &ty, line)?
};
graph.next_id = graph.next_id.max(id.0 + 1);
graph.push(ScOp::Constant { id, value, ty });
Ok(())
}
fn parse_encode(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
let parts: Vec<&str> = text.splitn(2, "= sc.encode").collect();
if parts.len() != 2 {
return Err(make_err(line, "malformed sc.encode"));
}
let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
let rest = parts[1].trim();
let tokens: Vec<&str> = rest.split_whitespace().collect();
let prob = parse_value_id(
tokens
.first()
.ok_or_else(|| make_err(line, "missing prob"))?,
)
.map_err(|e| make_err(line, e))?;
let length_str = extract_kv(rest, "length").ok_or_else(|| make_err(line, "missing length"))?;
let length = length_str
.parse::<usize>()
.map_err(|e| make_err(line, e.to_string()))?;
let seed_str = extract_kv(rest, "seed").ok_or_else(|| make_err(line, "missing seed"))?;
let seed = if seed_str.starts_with("0x") || seed_str.starts_with("0X") {
u16::from_str_radix(&seed_str[2..], 16).map_err(|e| make_err(line, e.to_string()))?
} else {
seed_str
.parse::<u16>()
.map_err(|e| make_err(line, e.to_string()))?
};
graph.next_id = graph.next_id.max(id.0 + 1);
graph.push(ScOp::Encode {
id,
prob,
length,
seed,
});
Ok(())
}
fn parse_and(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
let parts: Vec<&str> = text.splitn(2, "= sc.and").collect();
if parts.len() != 2 {
return Err(make_err(line, "malformed sc.and"));
}
let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
let rest = parts[1].trim();
let operands: Vec<&str> = rest.split(':').next().unwrap_or("").split(',').collect();
if operands.len() < 2 {
return Err(make_err(line, "sc.and needs 2 operands"));
}
let lhs = parse_value_id(operands[0]).map_err(|e| make_err(line, e))?;
let rhs = parse_value_id(operands[1]).map_err(|e| make_err(line, e))?;
graph.next_id = graph.next_id.max(id.0 + 1);
graph.push(ScOp::BitwiseAnd { id, lhs, rhs });
Ok(())
}
fn parse_popcount(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
let parts: Vec<&str> = text.splitn(2, "= sc.popcount").collect();
if parts.len() != 2 {
return Err(make_err(line, "malformed sc.popcount"));
}
let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
let rest = parts[1].trim();
let input_str = rest.split(':').next().unwrap_or("").trim();
let input = parse_value_id(input_str).map_err(|e| make_err(line, e))?;
graph.next_id = graph.next_id.max(id.0 + 1);
graph.push(ScOp::Popcount { id, input });
Ok(())
}
fn parse_dense_forward(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
let parts: Vec<&str> = text.splitn(2, "= sc.dense_forward").collect();
if parts.len() != 2 {
return Err(make_err(line, "malformed sc.dense_forward"));
}
let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
let rest = parts[1].trim();
let tokens: Vec<&str> = rest.split_whitespace().collect();
let inputs = parse_value_id(
tokens
.first()
.ok_or_else(|| make_err(line, "missing inputs"))?,
)
.map_err(|e| make_err(line, e))?;
let weights_str =
extract_kv(rest, "weights").ok_or_else(|| make_err(line, "missing weights"))?;
let weights = parse_value_id(&weights_str).map_err(|e| make_err(line, e))?;
let leak_str = extract_kv(rest, "leak").ok_or_else(|| make_err(line, "missing leak"))?;
let leak = parse_value_id(&leak_str).map_err(|e| make_err(line, e))?;
let gain_str = extract_kv(rest, "gain").ok_or_else(|| make_err(line, "missing gain"))?;
let gain = parse_value_id(&gain_str).map_err(|e| make_err(line, e))?;
let ni = extract_kv(rest, "ni")
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(3);
let nn = extract_kv(rest, "nn")
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(7);
let len = extract_kv(rest, "len")
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(1024);
let params = DenseParams {
n_inputs: ni,
n_neurons: nn,
stream_length: len,
..DenseParams::default()
};
graph.next_id = graph.next_id.max(id.0 + 1);
graph.push(ScOp::DenseForward {
id,
inputs,
weights,
leak,
gain,
params,
});
Ok(())
}
fn parse_lif_step(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
let parts: Vec<&str> = text.splitn(2, "= sc.lif_step").collect();
if parts.len() != 2 {
return Err(make_err(line, "malformed sc.lif_step"));
}
let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
let rest = parts[1].trim();
let tokens: Vec<&str> = rest.split_whitespace().collect();
let current = parse_value_id(
tokens
.first()
.ok_or_else(|| make_err(line, "missing current"))?,
)
.map_err(|e| make_err(line, e))?;
let leak_str = extract_kv(rest, "leak").ok_or_else(|| make_err(line, "missing leak"))?;
let leak = parse_value_id(&leak_str).map_err(|e| make_err(line, e))?;
let gain_str = extract_kv(rest, "gain").ok_or_else(|| make_err(line, "missing gain"))?;
let gain = parse_value_id(&gain_str).map_err(|e| make_err(line, e))?;
let noise_str = extract_kv(rest, "noise").ok_or_else(|| make_err(line, "missing noise"))?;
let noise = parse_value_id(&noise_str).map_err(|e| make_err(line, e))?;
let dw = extract_kv(rest, "dw")
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(16);
let frac = extract_kv(rest, "frac")
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(8);
let vt = extract_kv(rest, "vt")
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(256);
let rp = extract_kv(rest, "rp")
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(2);
let params = LifParams {
data_width: dw,
fraction: frac,
v_threshold: vt,
refractory_period: rp,
..LifParams::default()
};
graph.next_id = graph.next_id.max(id.0 + 1);
graph.push(ScOp::LifStep {
id,
current,
leak,
gain,
noise,
params,
});
Ok(())
}
fn parse_scale(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
let parts: Vec<&str> = text.splitn(2, "= sc.scale").collect();
if parts.len() != 2 {
return Err(make_err(line, "malformed sc.scale"));
}
let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
let rest = parts[1].trim();
let tokens: Vec<&str> = rest.split_whitespace().collect();
let input = parse_value_id(
tokens
.first()
.ok_or_else(|| make_err(line, "missing input"))?,
)
.map_err(|e| make_err(line, e))?;
let factor_str = extract_kv(rest, "factor").ok_or_else(|| make_err(line, "missing factor"))?;
let factor = factor_str
.parse::<f64>()
.map_err(|e| make_err(line, e.to_string()))?;
graph.next_id = graph.next_id.max(id.0 + 1);
graph.push(ScOp::Scale { id, input, factor });
Ok(())
}
fn parse_offset(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
let parts: Vec<&str> = text.splitn(2, "= sc.offset").collect();
if parts.len() != 2 {
return Err(make_err(line, "malformed sc.offset"));
}
let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
let rest = parts[1].trim();
let tokens: Vec<&str> = rest.split_whitespace().collect();
let input = parse_value_id(
tokens
.first()
.ok_or_else(|| make_err(line, "missing input"))?,
)
.map_err(|e| make_err(line, e))?;
let offset_str = extract_kv(rest, "offset").ok_or_else(|| make_err(line, "missing offset"))?;
let offset = offset_str
.parse::<f64>()
.map_err(|e| make_err(line, e.to_string()))?;
graph.next_id = graph.next_id.max(id.0 + 1);
graph.push(ScOp::Offset { id, input, offset });
Ok(())
}
fn parse_xor(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
let parts: Vec<&str> = text.splitn(2, "= sc.xor").collect();
if parts.len() != 2 {
return Err(make_err(line, "malformed sc.xor"));
}
let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
let rest = parts[1].trim();
let operands: Vec<&str> = rest.split(':').next().unwrap_or("").split(',').collect();
if operands.len() < 2 {
return Err(make_err(line, "sc.xor needs 2 operands"));
}
let lhs = parse_value_id(operands[0]).map_err(|e| make_err(line, e))?;
let rhs = parse_value_id(operands[1]).map_err(|e| make_err(line, e))?;
graph.next_id = graph.next_id.max(id.0 + 1);
graph.push(ScOp::BitwiseXor { id, lhs, rhs });
Ok(())
}
fn parse_reduce(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
let parts: Vec<&str> = text.splitn(2, "= sc.reduce").collect();
if parts.len() != 2 {
return Err(make_err(line, "malformed sc.reduce"));
}
let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
let rest = parts[1].trim();
let tokens: Vec<&str> = rest.split_whitespace().collect();
let input = parse_value_id(
tokens
.first()
.ok_or_else(|| make_err(line, "missing input"))?,
)
.map_err(|e| make_err(line, e))?;
let mode_str = extract_kv(rest, "mode").unwrap_or_else(|| "sum".to_string());
let mode = match mode_str.as_str() {
"max" => ReduceMode::Max,
_ => ReduceMode::Sum,
};
graph.next_id = graph.next_id.max(id.0 + 1);
graph.push(ScOp::Reduce { id, input, mode });
Ok(())
}
fn parse_graph_forward(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
let parts: Vec<&str> = text.splitn(2, "= sc.graph_forward").collect();
if parts.len() != 2 {
return Err(make_err(line, "malformed sc.graph_forward"));
}
let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
let rest = parts[1].trim();
let tokens: Vec<&str> = rest.split_whitespace().collect();
let features = parse_value_id(
tokens
.first()
.ok_or_else(|| make_err(line, "missing features"))?,
)
.map_err(|e| make_err(line, e))?;
let adj_str = extract_kv(rest, "adj").ok_or_else(|| make_err(line, "missing adj"))?;
let adjacency = parse_value_id(&adj_str).map_err(|e| make_err(line, e))?;
let n_nodes = extract_kv(rest, "nodes")
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(16);
let n_features = extract_kv(rest, "features")
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(4);
graph.next_id = graph.next_id.max(id.0 + 1);
graph.push(ScOp::GraphForward {
id,
features,
adjacency,
n_nodes,
n_features,
});
Ok(())
}
fn parse_softmax_attention(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
let parts: Vec<&str> = text.splitn(2, "= sc.softmax_attention").collect();
if parts.len() != 2 {
return Err(make_err(line, "malformed sc.softmax_attention"));
}
let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
let rest = parts[1].trim();
let before_colon = rest.split(':').next().unwrap_or("");
let operands: Vec<&str> = before_colon.split(',').collect();
if operands.len() < 3 {
return Err(make_err(line, "sc.softmax_attention needs q, k, v"));
}
let q = parse_value_id(operands[0]).map_err(|e| make_err(line, e))?;
let k = parse_value_id(operands[1]).map_err(|e| make_err(line, e))?;
let v_token = operands[2].split_whitespace().next().unwrap_or("");
let v = parse_value_id(v_token).map_err(|e| make_err(line, e))?;
let dim_k = extract_kv(rest, "dim_k")
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(64);
graph.next_id = graph.next_id.max(id.0 + 1);
graph.push(ScOp::SoftmaxAttention { id, q, k, v, dim_k });
Ok(())
}
fn parse_kuramoto_step(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
let parts: Vec<&str> = text.splitn(2, "= sc.kuramoto_step").collect();
if parts.len() != 2 {
return Err(make_err(line, "malformed sc.kuramoto_step"));
}
let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
let rest = parts[1].trim();
let tokens: Vec<&str> = rest.split_whitespace().collect();
let phases = parse_value_id(
tokens
.first()
.ok_or_else(|| make_err(line, "missing phases"))?,
)
.map_err(|e| make_err(line, e))?;
let omega_str = extract_kv(rest, "omega").ok_or_else(|| make_err(line, "missing omega"))?;
let omega = parse_value_id(&omega_str).map_err(|e| make_err(line, e))?;
let k_str = extract_kv(rest, "K").ok_or_else(|| make_err(line, "missing K"))?;
let coupling = parse_value_id(&k_str).map_err(|e| make_err(line, e))?;
let dt = extract_kv(rest, "dt")
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.01);
graph.next_id = graph.next_id.max(id.0 + 1);
graph.push(ScOp::KuramotoStep {
id,
phases,
omega,
coupling,
dt,
});
Ok(())
}
fn parse_div_const(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
let parts: Vec<&str> = text.splitn(2, "= sc.div_const").collect();
if parts.len() != 2 {
return Err(make_err(line, "malformed sc.div_const"));
}
let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
let rest = parts[1].trim();
let tokens: Vec<&str> = rest.split_whitespace().collect();
let input = parse_value_id(
tokens
.first()
.ok_or_else(|| make_err(line, "missing input"))?,
)
.map_err(|e| make_err(line, e))?;
let divisor_str =
extract_kv(rest, "divisor").ok_or_else(|| make_err(line, "missing divisor"))?;
let divisor = divisor_str
.parse::<u64>()
.map_err(|e| make_err(line, e.to_string()))?;
graph.next_id = graph.next_id.max(id.0 + 1);
graph.push(ScOp::DivConst { id, input, divisor });
Ok(())
}