Skip to content

Commit 72013ac

Browse files
ihhclaude
andcommitted
codegen: phylo recursion + Newick parser Rust port (Increment 5)
The phylo-skeleton-bake crate now ships a `phylo` module: a Rust port of src/phylo_intersect.cpp's NewickParser + buildSubtree recursion + phyloIntersect entry point. PhyloNode { name, children, parent, branch_length } PhyloTree { nodes, root } PhyloTree::parse_newick(&str) -> Self output_alphabet(&Machine) -> Vec<String> wild_echo(&[String]) -> Machine ← 1-state self-loop machine phylo_intersect(&Machine, &PhyloTree, time_param) -> Machine Recursion mirrors phylo_intersect.cpp::buildSubtree exactly: - leaves: wild_echo(T.output_alphabet) - degree-1 internal: branch_transducer_for_child (renames T's time parameter per child name, then composes with the recursive subtree) - degree ≥ 2 internal: fold-left intersect over the children's branch transducers Validation guards from the C++ are preserved: if T has the time parameter, every non-root node must have a non-empty unique name (else panic). Leaf clamps and the felsenstein-on/off switch are not yet ported (follow-ups); this is the non-clamped, raw-fold path. Tests: - 4 new unit tests: Newick parse on `(A:0.1,B:0.2)P;` and the 4-leaf quartet `((A,B)P,(C,D)Q)R;` (correct node count + child structure + branch-length parsing); wild_echo shape; output_alphabet correctness (sorted unique). - 1 integration test (phylo_intersect_runs_on_baked_t_and_tree): end-to-end smoke that parses the baked T_JSON + TREE_NEWICK and runs phylo_intersect, confirming the result has 2-array state ids from the intersect-of-composes pipeline plus at least one emit transition. Crate now has 32 unit tests + 11 integration tests, all passing under `cargo test --release`. Remaining for the bake-and-expand work: Increment 4e (advance_sort + process_cycles, only matters for cyclic intermediate machines) and Increment 6 (wire prebuild() to materialise EMIT_SHARDS / SILENT_TRANSITIONS / lw[] from the phylo_intersect result and merge with the existing DP codegen template). See task #38. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d6185e1 commit 72013ac

2 files changed

Lines changed: 323 additions & 5 deletions

File tree

src/rust_codegen.cpp

Lines changed: 296 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2951,12 +2951,302 @@ mod tests {
29512951
)RUST";
29522952
}
29532953

2954+
// src/phylo.rs — Newick parser + buildSubtree recursion.
2955+
// Mirrors the public surface of src/phylo_intersect.cpp: parse a Newick
2956+
// string into a PhyloTree, then walk it recursively, calling
2957+
// machine::compose / intersect / rename_for_branch to produce M_full.
2958+
{
2959+
std::ofstream f (outputDir + "/src/phylo.rs");
2960+
f << R"RUST(//! Newick parser + phylo-intersect recursion. Rust port of
2961+
//! `src/phylo_intersect.cpp`. Given a branch transducer T (already parsed
2962+
//! into a `Machine`), a Newick tree, and the time-parameter name, returns
2963+
//! the phylo-composed Machine that mirrors the legacy `phyloIntersect`.
2964+
2965+
use serde_json::Value;
2966+
use crate::machine::{Machine, State, Transition, compose, intersect, rename_for_branch};
2967+
2968+
#[derive(Debug, Clone)]
2969+
pub struct PhyloNode {
2970+
pub name: String,
2971+
pub children: Vec<usize>,
2972+
pub parent: Option<usize>,
2973+
pub branch_length: Option<f64>,
2974+
}
2975+
2976+
#[derive(Debug, Clone)]
2977+
pub struct PhyloTree {
2978+
pub nodes: Vec<PhyloNode>,
2979+
pub root: usize,
2980+
}
2981+
2982+
impl PhyloTree {
2983+
pub fn parse_newick(s: &str) -> Self {
2984+
let mut p = NewickParser { src: s.as_bytes(), pos: 0,
2985+
nodes: Vec::new() };
2986+
let root = p.parse_subtree(None);
2987+
p.skip_ws();
2988+
if p.pos < p.src.len() && p.src[p.pos] == b';' { p.pos += 1; }
2989+
PhyloTree { nodes: p.nodes, root }
2990+
}
2991+
2992+
pub fn is_leaf(&self, i: usize) -> bool { self.nodes[i].children.is_empty() }
2993+
}
2994+
2995+
struct NewickParser<'a> {
2996+
src: &'a [u8],
2997+
pos: usize,
2998+
nodes: Vec<PhyloNode>,
2999+
}
3000+
3001+
impl<'a> NewickParser<'a> {
3002+
fn skip_ws(&mut self) {
3003+
while self.pos < self.src.len() && (self.src[self.pos] as char).is_whitespace() {
3004+
self.pos += 1;
3005+
}
3006+
}
3007+
fn peek(&mut self, c: u8) -> bool {
3008+
self.skip_ws();
3009+
self.pos < self.src.len() && self.src[self.pos] == c
3010+
}
3011+
fn consume(&mut self, c: u8) -> bool {
3012+
if self.peek(c) { self.pos += 1; true } else { false }
3013+
}
3014+
fn expect(&mut self, c: u8) {
3015+
if !self.consume(c) {
3016+
panic!("Newick parse error: expected '{}' at position {}", c as char, self.pos);
3017+
}
3018+
}
3019+
fn is_name_char(c: u8) -> bool {
3020+
!matches!(c, b'(' | b')' | b',' | b':' | b';' | b'\'' | b'[' | b']')
3021+
&& !(c as char).is_whitespace()
3022+
}
3023+
fn parse_name(&mut self) -> String {
3024+
self.skip_ws();
3025+
let mut name = String::new();
3026+
if self.pos < self.src.len() && self.src[self.pos] == b'\'' {
3027+
self.pos += 1;
3028+
while self.pos < self.src.len() && self.src[self.pos] != b'\'' {
3029+
name.push(self.src[self.pos] as char);
3030+
self.pos += 1;
3031+
}
3032+
if self.pos == self.src.len() {
3033+
panic!("Newick parse error: unterminated quoted name");
3034+
}
3035+
self.pos += 1;
3036+
} else {
3037+
while self.pos < self.src.len() && Self::is_name_char(self.src[self.pos]) {
3038+
name.push(self.src[self.pos] as char);
3039+
self.pos += 1;
3040+
}
3041+
}
3042+
name
3043+
}
3044+
fn parse_branch_length(&mut self) -> Option<f64> {
3045+
self.skip_ws();
3046+
if !self.consume(b':') { return None; }
3047+
self.skip_ws();
3048+
let start = self.pos;
3049+
if self.pos < self.src.len() && (self.src[self.pos] == b'+' || self.src[self.pos] == b'-') {
3050+
self.pos += 1;
3051+
}
3052+
while self.pos < self.src.len() {
3053+
let c = self.src[self.pos];
3054+
if (c as char).is_ascii_digit() || c == b'.' || c == b'e' || c == b'E'
3055+
|| c == b'+' || c == b'-' {
3056+
self.pos += 1;
3057+
} else { break; }
3058+
}
3059+
if self.pos == start {
3060+
panic!("Newick parse error: expected branch length number at position {}", start);
3061+
}
3062+
let s = std::str::from_utf8(&self.src[start..self.pos]).unwrap();
3063+
Some(s.parse().expect("Newick parse error: bad branch length"))
3064+
}
3065+
fn parse_subtree(&mut self, parent: Option<usize>) -> usize {
3066+
let self_idx = self.nodes.len();
3067+
self.nodes.push(PhyloNode {
3068+
name: String::new(),
3069+
children: Vec::new(),
3070+
parent,
3071+
branch_length: None,
3072+
});
3073+
self.skip_ws();
3074+
if self.peek(b'(') {
3075+
self.expect(b'(');
3076+
let first = self.parse_subtree(Some(self_idx));
3077+
self.nodes[self_idx].children.push(first);
3078+
while self.peek(b',') {
3079+
self.expect(b',');
3080+
let next = self.parse_subtree(Some(self_idx));
3081+
self.nodes[self_idx].children.push(next);
3082+
}
3083+
self.expect(b')');
3084+
}
3085+
let nm = self.parse_name();
3086+
self.nodes[self_idx].name = nm;
3087+
if let Some(bl) = self.parse_branch_length() {
3088+
self.nodes[self_idx].branch_length = Some(bl);
3089+
}
3090+
self_idx
3091+
}
3092+
}
3093+
3094+
// ---- Helpers for buildSubtree -------------------------------------------
3095+
3096+
/// Sorted set of distinct non-empty output symbols across all transitions.
3097+
pub fn output_alphabet(m: &Machine) -> Vec<String> {
3098+
let mut set: std::collections::BTreeSet<String> = Default::default();
3099+
for s in &m.state {
3100+
for t in &s.trans {
3101+
if !t.out_sym.is_empty() {
3102+
set.insert(t.out_sym.clone());
3103+
}
3104+
}
3105+
}
3106+
set.into_iter().collect()
3107+
}
3108+
3109+
/// Mirror of `Machine::wildEcho(symbols)`: 1-state machine with a self-loop
3110+
/// transition (sym → sym, weight 1) for each symbol.
3111+
pub fn wild_echo(symbols: &[String]) -> Machine {
3112+
let mut trans = Vec::with_capacity(symbols.len());
3113+
for sym in symbols {
3114+
trans.push(Transition {
3115+
to: 0,
3116+
in_sym: sym.clone(),
3117+
out_sym: sym.clone(),
3118+
weight: Value::from(1.0_f64),
3119+
});
3120+
}
3121+
let id = Value::Array(symbols.iter().map(|s| Value::String(s.clone())).collect());
3122+
Machine {
3123+
state: vec![State { id, trans }],
3124+
defs: Default::default(),
3125+
cons: Default::default(),
3126+
}
3127+
}
3128+
3129+
// ---- Phylo recursion ----------------------------------------------------
3130+
3131+
fn branch_transducer_for_child(tree: &PhyloTree, child_v: usize,
3132+
t: &Machine, time_param: &str,
3133+
rename_time: bool) -> Machine {
3134+
let child = &tree.nodes[child_v];
3135+
let t_copy = if rename_time {
3136+
rename_for_branch(t, time_param, &child.name)
3137+
} else {
3138+
t.clone()
3139+
};
3140+
let sub = build_subtree(tree, child_v, t, time_param, rename_time);
3141+
compose(&t_copy, &sub)
3142+
}
3143+
3144+
fn build_subtree(tree: &PhyloTree, v: usize,
3145+
t: &Machine, time_param: &str, rename_time: bool) -> Machine {
3146+
let node = &tree.nodes[v];
3147+
if node.children.is_empty() {
3148+
// Leaf: wildEcho over T's output alphabet. (Leaf clamps not yet
3149+
// ported — clamped-leaf workflow is a follow-up.)
3150+
return wild_echo(&output_alphabet(t));
3151+
}
3152+
if node.children.len() == 1 {
3153+
return branch_transducer_for_child(tree, node.children[0],
3154+
t, time_param, rename_time);
3155+
}
3156+
// Degree ≥ 2: fold-left intersect.
3157+
let mut acc = branch_transducer_for_child(tree, node.children[0],
3158+
t, time_param, rename_time);
3159+
for &c in &node.children[1..] {
3160+
let sib = branch_transducer_for_child(tree, c, t, time_param, rename_time);
3161+
acc = intersect(&acc, &sib);
3162+
}
3163+
acc
3164+
}
3165+
3166+
/// Top-level entry point: build the phylo-composed Machine from a branch
3167+
/// transducer T, a parsed PhyloTree, and the per-branch time-parameter
3168+
/// name. Mirrors `phyloIntersect` in src/phylo_intersect.cpp (without leaf
3169+
/// clamps and without `phylo-no-felsenstein` switching, both of which are
3170+
/// follow-ups).
3171+
pub fn phylo_intersect(t: &Machine, tree: &PhyloTree, time_param: &str) -> Machine {
3172+
let rename_time = t.has_param(time_param);
3173+
if rename_time {
3174+
// Validate that every non-root node has a non-empty unique name
3175+
// (matches the C++ guard in phyloIntersect).
3176+
let mut seen = std::collections::HashSet::new();
3177+
for (i, n) in tree.nodes.iter().enumerate() {
3178+
if i == tree.root { continue; }
3179+
assert!(!n.name.is_empty(),
3180+
"phylo: branch transducer has parameter \"{}\" but node {} has no name",
3181+
time_param, i);
3182+
assert!(seen.insert(n.name.clone()),
3183+
"phylo: duplicate node name \"{}\"", n.name);
3184+
}
3185+
}
3186+
build_subtree(tree, tree.root, t, time_param, rename_time)
3187+
}
3188+
3189+
#[cfg(test)]
3190+
mod tests {
3191+
use super::*;
3192+
3193+
#[test]
3194+
fn newick_parse_binary() {
3195+
let tree = PhyloTree::parse_newick("(A:0.1,B:0.2)P;");
3196+
assert_eq!(tree.nodes.len(), 3);
3197+
let p = &tree.nodes[tree.root];
3198+
assert_eq!(p.name, "P");
3199+
assert_eq!(p.children.len(), 2);
3200+
assert_eq!(tree.nodes[p.children[0]].name, "A");
3201+
assert_eq!(tree.nodes[p.children[0]].branch_length, Some(0.1));
3202+
assert_eq!(tree.nodes[p.children[1]].name, "B");
3203+
assert_eq!(tree.nodes[p.children[1]].branch_length, Some(0.2));
3204+
}
3205+
3206+
#[test]
3207+
fn newick_parse_quartet() {
3208+
let tree = PhyloTree::parse_newick("((A,B)P,(C,D)Q)R;");
3209+
assert_eq!(tree.nodes.len(), 7);
3210+
let r = &tree.nodes[tree.root];
3211+
assert_eq!(r.name, "R");
3212+
assert_eq!(r.children.len(), 2);
3213+
}
3214+
3215+
#[test]
3216+
fn wild_echo_shape() {
3217+
let alph = vec!["A".to_string(), "C".to_string(), "G".to_string(), "T".to_string()];
3218+
let we = wild_echo(&alph);
3219+
assert_eq!(we.n_states(), 1);
3220+
assert_eq!(we.state[0].trans.len(), 4);
3221+
for t in &we.state[0].trans {
3222+
assert_eq!(t.in_sym, t.out_sym);
3223+
assert_eq!(t.to, 0);
3224+
}
3225+
}
3226+
3227+
#[test]
3228+
fn output_alphabet_is_sorted_unique() {
3229+
let m = Machine::from_json(&serde_json::json!({
3230+
"state": [
3231+
{"id": 0, "trans": [
3232+
{"to": 1, "in": "x", "out": "B"},
3233+
{"to": 1, "in": "x", "out": "A"},
3234+
{"to": 1, "in": "x", "out": "B"}
3235+
]},
3236+
{"id": 1}
3237+
]
3238+
}));
3239+
assert_eq!(output_alphabet(&m), vec!["A".to_string(), "B".to_string()]);
3240+
}
3241+
}
3242+
)RUST";
3243+
}
3244+
29543245
// src/lib.rs — bake the JSON inputs as `&'static str` consts, expose
2955-
// the weight_algebra module, expose a stub `prebuild()` that panics.
2956-
// Subsequent increments will replace the stub with a Rust port of the
2957-
// WFST algebra (Machine + compose / intersect / waitingMachine /
2958-
// ergodicMachine + phylo recursion) so prebuild() can materialise
2959-
// M_full's per-symbol DP tables in shape with M_skel.
3246+
// the weight_algebra / machine / phylo modules, plus the stub
3247+
// `prebuild()`. Increment 6 will wire prebuild() to materialise the per-
3248+
// symbol DP tables (EMIT_SHARDS, SILENT_TRANSITIONS, lw[]) by calling
3249+
// phylo::phylo_intersect on the baked T + tree.
29603250
{
29613251
std::ofstream f (outputDir + "/src/lib.rs");
29623252
f << "// Auto-generated by Machine Boss --phylo-skeleton --codegen --rust.\n"
@@ -2966,6 +3256,7 @@ mod tests {
29663256
"\n"
29673257
"pub mod weight_algebra;\n"
29683258
"pub mod machine;\n"
3259+
"pub mod phylo;\n"
29693260
"\n"
29703261
"/// Canonical branch transducer T (no per-branch time-parameter\n"
29713262
"/// renaming). Format is the standard Machine Boss machine JSON.\n"

t/check_phylo_skeleton_bake.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,33 @@ def main():
6161
#[test] #[should_panic(expected = "not yet implemented")]
6262
fn prebuild_panics() { phylo_skeleton::prebuild(); }
6363
64+
#[test] fn phylo_intersect_runs_on_baked_t_and_tree() {
65+
// End-to-end smoke: parse the baked T_JSON + TREE_NEWICK, run the
66+
// Rust phylo_intersect, verify the result has the expected shape
67+
// (every state has a 2-array id from intersect, a non-zero state count,
68+
// and at least one emit transition).
69+
use phylo_skeleton::machine::Machine;
70+
use phylo_skeleton::phylo::{PhyloTree, phylo_intersect};
71+
let t_json: Value = serde_json::from_str(T_JSON).expect("T_JSON parses");
72+
let t = Machine::from_json(&t_json);
73+
let tree = PhyloTree::parse_newick(TREE_NEWICK);
74+
let m = phylo_intersect(&t, &tree, TIME_PARAM);
75+
assert!(m.n_states() > 0);
76+
// Every state should have a 2-element-array id (from compose's array
77+
// state-name convention).
78+
if let Value::Array(a) = &m.state[0].id {
79+
assert_eq!(a.len(), 2);
80+
} else {
81+
panic!("state 0 id not a 2-array: {:?}", m.state[0].id);
82+
}
83+
// At least one emit transition should exist (the phylo machine emits
84+
// pair-tokens for the leaves).
85+
let emit_count: usize = m.state.iter()
86+
.map(|s| s.trans.iter().filter(|t| !t.out_sym.is_empty()).count())
87+
.sum();
88+
assert!(emit_count > 0, "no emit transitions in phylo machine");
89+
}
90+
6491
#[test] fn compose_t_with_self_state_count_matches_cpp() {
6592
// Smoke check that the Rust compose runs end-to-end on the baked T and
6693
// produces SOMETHING sensible. We don't compare to C++ M_full here

0 commit comments

Comments
 (0)