11//! META-AGENT: add `pub mod trajectory;` to lib.rs.
22
3+ use crate :: markov_bundle:: GrammaticalRole ;
4+
35#[ derive( Debug , Clone ) ]
46pub struct Trajectory {
57 pub fingerprint : Vec < f32 > ,
68 pub radius : u32 ,
79}
810
911impl Trajectory {
10- pub fn role_bundle ( & self , start : usize , stop : usize ) -> Vec < f32 > {
12+ /// Slice the role band out of the trajectory's fingerprint.
13+ pub fn role_bundle ( & self , role : GrammaticalRole ) -> Vec < f32 > {
14+ let ( start, stop) = role. slice ( ) ;
15+ self . role_bundle_range ( start, stop)
16+ }
17+
18+ /// Lower-level slice helper retained for test fixtures + callers
19+ /// that pre-compute (start, stop) by hand. Prefer `role_bundle(role)`.
20+ pub fn role_bundle_range ( & self , start : usize , stop : usize ) -> Vec < f32 > {
1121 let stop = stop. min ( self . fingerprint . len ( ) ) ;
1222 if start >= stop {
1323 return Vec :: new ( ) ;
1424 }
1525 self . fingerprint [ start..stop] . to_vec ( )
1626 }
1727
28+ /// Score the codebook against the role's bundle, filter by
29+ /// `threshold` (cosine ≥ threshold), sort descending, truncate to
30+ /// `top_k`.
31+ ///
32+ /// `threshold` and `top_k` are explicit so callers tune them per
33+ /// style / per role band — no hidden 0.5 / 5 defaults baked into
34+ /// the carrier. See `role_candidates_default` for the
35+ /// backwards-compat shim with the previous (0.5, 5) values.
1836 pub fn role_candidates (
1937 & self ,
20- start : usize ,
21- stop : usize ,
38+ role : GrammaticalRole ,
2239 codebook : & [ Vec < f32 > ] ,
40+ threshold : f32 ,
41+ top_k : usize ,
2342 ) -> Vec < Candidate > {
24- let bundle = self . role_bundle ( start , stop ) ;
43+ let bundle = self . role_bundle ( role ) ;
2544 let mut scored: Vec < Candidate > = codebook
2645 . iter ( )
2746 . enumerate ( )
28- . map ( |( i, entry) | {
29- let score = cosine ( & bundle, entry) ;
30- Candidate {
31- codebook_index : i,
32- score,
33- }
47+ . map ( |( i, entry) | Candidate {
48+ codebook_index : i,
49+ score : cosine ( & bundle, entry) ,
3450 } )
35- . filter ( |c| c. score > 0.5 )
51+ . filter ( |c| c. score >= threshold )
3652 . collect ( ) ;
3753 scored. sort_by ( |a, b| {
3854 b. score
3955 . partial_cmp ( & a. score )
4056 . unwrap_or ( std:: cmp:: Ordering :: Equal )
4157 } ) ;
42- scored. truncate ( 5 ) ;
58+ scored. truncate ( top_k ) ;
4359 scored
4460 }
61+
62+ /// Backwards-compat shim with the previous signature
63+ /// (threshold = 0.5, top_k = 5).
64+ #[ deprecated( note = "use role_candidates with explicit threshold + top_k" ) ]
65+ pub fn role_candidates_default (
66+ & self ,
67+ role : GrammaticalRole ,
68+ codebook : & [ Vec < f32 > ] ,
69+ ) -> Vec < Candidate > {
70+ self . role_candidates ( role, codebook, 0.5 , 5 )
71+ }
4572}
4673
74+ /// Cosine similarity. **Panics** on length mismatch — the carrier
75+ /// guarantees role-aligned slices and a length mismatch is a wiring
76+ /// bug, not a runtime input error. Callers that need a fallible
77+ /// variant should length-check before invoking.
4778fn cosine ( a : & [ f32 ] , b : & [ f32 ] ) -> f32 {
48- let n = a. len ( ) . min ( b. len ( ) ) ;
49- if n == 0 {
79+ assert_eq ! (
80+ a. len( ) ,
81+ b. len( ) ,
82+ "cosine: length mismatch ({} vs {})" ,
83+ a. len( ) ,
84+ b. len( )
85+ ) ;
86+ if a. is_empty ( ) {
5087 return 0.0 ;
5188 }
52- let dot: f32 = a[ ..n ] . iter ( ) . zip ( & b [ ..n ] ) . map ( |( x, y) | x * y) . sum ( ) ;
53- let na: f32 = a[ ..n ] . iter ( ) . map ( |x| x * x) . sum :: < f32 > ( ) . sqrt ( ) ;
54- let nb: f32 = b[ ..n ] . iter ( ) . map ( |x| x * x) . sum :: < f32 > ( ) . sqrt ( ) ;
89+ let dot: f32 = a. iter ( ) . zip ( b ) . map ( |( x, y) | x * y) . sum ( ) ;
90+ let na: f32 = a. iter ( ) . map ( |x| x * x) . sum :: < f32 > ( ) . sqrt ( ) ;
91+ let nb: f32 = b. iter ( ) . map ( |x| x * x) . sum :: < f32 > ( ) . sqrt ( ) ;
5592 if na < 1e-9 || nb < 1e-9 {
5693 0.0
5794 } else {
@@ -68,31 +105,102 @@ pub struct Candidate {
68105#[ cfg( test) ]
69106mod tests {
70107 use super :: * ;
108+
109+ fn full_carrier ( value : f32 ) -> Vec < f32 > {
110+ vec ! [ value; 16_384 ]
111+ }
112+
71113 #[ test]
72- fn role_bundle_returns_slice ( ) {
114+ fn role_bundle_returns_subject_band ( ) {
73115 let t = Trajectory {
74- fingerprint : vec ! [ 1.0 ; 100 ] ,
116+ fingerprint : full_carrier ( 1.0 ) ,
75117 radius : 5 ,
76118 } ;
77- assert_eq ! ( t. role_bundle( 10 , 30 ) . len( ) , 20 ) ;
119+ let bundle = t. role_bundle ( GrammaticalRole :: Subject ) ;
120+ let ( start, stop) = GrammaticalRole :: Subject . slice ( ) ;
121+ assert_eq ! ( bundle. len( ) , stop - start) ;
78122 }
123+
79124 #[ test]
80- fn role_bundle_empty_when_inverted ( ) {
125+ fn role_bundle_range_empty_when_inverted ( ) {
81126 let t = Trajectory {
82- fingerprint : vec ! [ 1.0 ; 100 ] ,
127+ fingerprint : full_carrier ( 1.0 ) ,
83128 radius : 5 ,
84129 } ;
85- assert_eq ! ( t. role_bundle ( 50 , 30 ) . len( ) , 0 ) ;
130+ assert_eq ! ( t. role_bundle_range ( 50 , 30 ) . len( ) , 0 ) ;
86131 }
132+
87133 #[ test]
88134 fn role_candidates_filters_by_threshold ( ) {
135+ // Codebook of 5 with similarities [0.9, 0.8, 0.4, 0.3, 0.1];
136+ // threshold 0.5 → only the first two pass.
137+ let ( start, stop) = GrammaticalRole :: Subject . slice ( ) ;
138+ let n = stop - start;
139+ let mut fingerprint = vec ! [ 0.0_f32 ; 16_384 ] ;
140+ for v in fingerprint[ start..stop] . iter_mut ( ) {
141+ * v = 1.0 ;
142+ }
89143 let t = Trajectory {
90- fingerprint : vec ! [ 1.0 ; 100 ] ,
144+ fingerprint,
91145 radius : 5 ,
92146 } ;
93- let codebook: Vec < Vec < f32 > > = vec ! [ vec![ 1.0 ; 100 ] , vec![ -1.0 ; 100 ] ] ;
94- let cands = t. role_candidates ( 0 , 100 , & codebook) ;
95- assert_eq ! ( cands. len( ) , 1 ) ;
147+ // Build codebook entries where each entry has `value` in the
148+ // subject band and 0.0 elsewhere — cosine vs the all-1 bundle
149+ // becomes deterministic.
150+ let make_entry = |scale : f32 | -> Vec < f32 > { vec ! [ scale; n] } ;
151+ // Cosine of all-ones bundle vs scaled all-ones → 1.0 regardless
152+ // of scale (when scale > 0). Use the sign + zeroing of
153+ // individual positions to engineer specific cosines.
154+ // Concretely: an entry that has the first `k` positions = 1 and
155+ // the rest = 0 has cosine = sqrt(k/n) against the all-ones
156+ // bundle.
157+ let make_partial = |k : usize | -> Vec < f32 > {
158+ let mut e = vec ! [ 0.0_f32 ; n] ;
159+ for v in e. iter_mut ( ) . take ( k) {
160+ * v = 1.0 ;
161+ }
162+ e
163+ } ;
164+ // Choose k so cosines are roughly [0.9, 0.8, 0.4, 0.3, 0.1].
165+ let codebook: Vec < Vec < f32 > > = vec ! [
166+ make_partial( ( 0.9_f32 * 0.9 * n as f32 ) as usize ) ,
167+ make_partial( ( 0.8_f32 * 0.8 * n as f32 ) as usize ) ,
168+ make_partial( ( 0.4_f32 * 0.4 * n as f32 ) as usize ) ,
169+ make_partial( ( 0.3_f32 * 0.3 * n as f32 ) as usize ) ,
170+ make_partial( ( 0.1_f32 * 0.1 * n as f32 ) as usize ) ,
171+ ] ;
172+ let _ = make_entry; // silence unused if-the-build-is-aggressive
173+ let cands = t. role_candidates ( GrammaticalRole :: Subject , & codebook, 0.5 , 10 ) ;
174+ assert_eq ! ( cands. len( ) , 2 , "threshold 0.5 should keep 2 of 5 entries" ) ;
175+ // Sorted descending — the 0.9-cosine entry comes first.
96176 assert_eq ! ( cands[ 0 ] . codebook_index, 0 ) ;
177+ assert_eq ! ( cands[ 1 ] . codebook_index, 1 ) ;
178+ }
179+
180+ #[ test]
181+ fn role_candidates_top_k_truncation ( ) {
182+ // Codebook of 10 entries all above threshold — top_k=3 must
183+ // return exactly 3.
184+ let ( start, stop) = GrammaticalRole :: Subject . slice ( ) ;
185+ let n = stop - start;
186+ let mut fingerprint = vec ! [ 0.0_f32 ; 16_384 ] ;
187+ for v in fingerprint[ start..stop] . iter_mut ( ) {
188+ * v = 1.0 ;
189+ }
190+ let t = Trajectory {
191+ fingerprint,
192+ radius : 5 ,
193+ } ;
194+ let codebook: Vec < Vec < f32 > > = ( 0 ..10 ) . map ( |_| vec ! [ 1.0_f32 ; n] ) . collect ( ) ;
195+ let cands = t. role_candidates ( GrammaticalRole :: Subject , & codebook, 0.5 , 3 ) ;
196+ assert_eq ! ( cands. len( ) , 3 ) ;
197+ }
198+
199+ #[ test]
200+ #[ should_panic( expected = "length mismatch" ) ]
201+ fn cosine_panics_on_length_mismatch ( ) {
202+ let a = vec ! [ 1.0_f32 ; 10 ] ;
203+ let b = vec ! [ 1.0_f32 ; 11 ] ;
204+ let _ = cosine ( & a, & b) ;
97205 }
98206}
0 commit comments