@@ -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 "
0 commit comments