|
| 1 | +//! The **arcuate fasciculus** — the Broca↔Wernicke cable made to carry signal |
| 2 | +//! (closes the conduction-aphasia gap, `E-ARCUATE-CONDUCTION`). |
| 3 | +//! |
| 4 | +//! ## What it does |
| 5 | +//! |
| 6 | +//! Owns both ends of the cable and slides the projection into the evidence ring: |
| 7 | +//! - **Broca / producer** — a `MarkovBundler`. Each fed sentence advances its |
| 8 | +//! window; once saturated it emits a `Trajectory` (the role-superposed |
| 9 | +//! projection wave). |
| 10 | +//! - **Wernicke / evidence ring** — a contract `ContextChain` (the ±5 replay |
| 11 | +//! surface). Each emitted projection is sign-binarized and **slid** into the |
| 12 | +//! ring's newest slot. |
| 13 | +//! |
| 14 | +//! The diagnosis it fixes: `disambiguator_glue` is the cable, and the contract |
| 15 | +//! `ContextChain` gives fill + coherence + replay primitives — but **no |
| 16 | +//! streaming advance**, and `MarkovBundler::push` had no caller. So the cable |
| 17 | +//! existed but carried no signal (production + comprehension intact, *repetition* |
| 18 | +//! failing — textbook conduction aphasia). `Arcuate` owns the producer + the |
| 19 | +//! ring-slide, so the projection now flows from Broca into Wernicke's window. |
| 20 | +//! |
| 21 | +//! ## Scope (deliberate, anti-spaghetti) |
| 22 | +//! |
| 23 | +//! This is a SEPARATE seam — it is **not** wired into `pipeline.rs`'s live |
| 24 | +//! 512-bit `ContextWindow`. How the two coexist is a distinct decision; fusing |
| 25 | +//! them here would be the spaghetti the design explicitly avoids. The connector |
| 26 | +//! is offline-testable on its own. |
| 27 | +//! |
| 28 | +//! ## Firewall |
| 29 | +//! |
| 30 | +//! The only thing crossing into the contract is a `Binary16K` fingerprint (the |
| 31 | +//! sign-binarized projection) — never a COCA rank. The contract takes no |
| 32 | +//! `deepnsm` dependency; `deepnsm` injects through the existing fingerprint seam. |
| 33 | +//! Double-windowing note: the bundler does ±radius bundling and the chain is ±5, |
| 34 | +//! so the ring holds windowed-projection fingerprints — adequate to carry signal; |
| 35 | +//! whether per-sentence (radius-0) fingerprints are preferable is `OQ-ARC-WINDOW`. |
| 36 | +
|
| 37 | +use crate::disambiguator_glue::sign_binarize_to_binary16k; |
| 38 | +use crate::markov_bundle::{Kernel, MarkovBundler, WindowedSentence}; |
| 39 | +use crate::trajectory::Trajectory; |
| 40 | + |
| 41 | +use lance_graph_contract::crystal::fingerprint::CrystalFingerprint; |
| 42 | +use lance_graph_contract::grammar::context_chain::{ |
| 43 | + ContextChain, DisambiguateOpts, DisambiguationResult, |
| 44 | +}; |
| 45 | + |
| 46 | +/// The arcuate connector: a `MarkovBundler` producer feeding a ±5 |
| 47 | +/// `ContextChain` evidence ring. |
| 48 | +pub struct Arcuate { |
| 49 | + bundler: MarkovBundler, |
| 50 | + chain: ContextChain, |
| 51 | +} |
| 52 | + |
| 53 | +impl Arcuate { |
| 54 | + /// New connector with bundler `radius` + `kernel`. The ring is the |
| 55 | + /// contract's fixed ±5 (`CHAIN_LEN = 11`), independent of `radius`. |
| 56 | + #[must_use] |
| 57 | + pub fn new(radius: u32, kernel: Kernel) -> Self { |
| 58 | + Self { |
| 59 | + bundler: MarkovBundler::new(radius, kernel), |
| 60 | + chain: ContextChain::new(), |
| 61 | + } |
| 62 | + } |
| 63 | + |
| 64 | + /// Feed one sentence's windowed tokens (Broca). When the bundler window |
| 65 | + /// saturates it emits a `Trajectory` (the projection); that projection is |
| 66 | + /// sign-binarized and slid into the ±5 ring (the cable carrying signal). |
| 67 | + /// Returns the emitted projection, or `None` while the window still fills. |
| 68 | + pub fn feed(&mut self, sentence: WindowedSentence) -> Option<Trajectory> { |
| 69 | + let traj = self.bundler.push(sentence)?; |
| 70 | + let bits = sign_binarize_to_binary16k(&traj.fingerprint); |
| 71 | + self.slide_in(CrystalFingerprint::Binary16K(bits)); |
| 72 | + Some(traj) |
| 73 | + } |
| 74 | + |
| 75 | + /// Slide the ±5 ring forward by one: drop the oldest slot, append the |
| 76 | + /// newest (so the newest sits at the last index and the focal at index 5 |
| 77 | + /// trails it by five). The contract offers no streaming advance, so the |
| 78 | + /// connector owns the ring via the chain's public `fingerprints`. |
| 79 | + fn slide_in(&mut self, fp: CrystalFingerprint) { |
| 80 | + self.chain.fingerprints.remove(0); |
| 81 | + self.chain.fingerprints.push(Some(fp)); |
| 82 | + } |
| 83 | + |
| 84 | + /// The ±5 evidence ring (Wernicke's replay surface). |
| 85 | + #[must_use] |
| 86 | + pub fn chain(&self) -> &ContextChain { |
| 87 | + &self.chain |
| 88 | + } |
| 89 | + |
| 90 | + /// Disambiguate at the focal position against `candidates` (the ±5 replay). |
| 91 | + /// Delegates to the contract chain; the populated ring is the evidence. |
| 92 | + pub fn disambiguate<I>(&self, candidates: I) -> DisambiguationResult |
| 93 | + where |
| 94 | + I: IntoIterator<Item = CrystalFingerprint>, |
| 95 | + { |
| 96 | + self.chain.disambiguate_with( |
| 97 | + ContextChain::focal_index(), |
| 98 | + candidates, |
| 99 | + DisambiguateOpts::default(), |
| 100 | + ) |
| 101 | + } |
| 102 | +} |
| 103 | + |
| 104 | +#[cfg(test)] |
| 105 | +mod tests { |
| 106 | + use super::*; |
| 107 | + use crate::markov_bundle::{GrammaticalRole, TokenWithRole}; |
| 108 | + |
| 109 | + /// One SUBJECT-band sentence whose content distinguishes it by `value`. |
| 110 | + fn sentence(value: f32) -> WindowedSentence { |
| 111 | + let len = GrammaticalRole::Subject.slice().len(); |
| 112 | + WindowedSentence { |
| 113 | + tokens: vec![TokenWithRole { |
| 114 | + content_fp: vec![value; len], |
| 115 | + role: GrammaticalRole::Subject, |
| 116 | + }], |
| 117 | + } |
| 118 | + } |
| 119 | + |
| 120 | + fn fp(value: f32) -> CrystalFingerprint { |
| 121 | + CrystalFingerprint::Binary16K(sign_binarize_to_binary16k(&vec![value; 16_384])) |
| 122 | + } |
| 123 | + |
| 124 | + #[test] |
| 125 | + fn window_fills_before_first_projection() { |
| 126 | + // radius 2 → bundler needs 2*2+1 = 5 pushes before the first emit. |
| 127 | + let mut arc = Arcuate::new(2, Kernel::Uniform); |
| 128 | + for _ in 0..4 { |
| 129 | + assert!(arc.feed(sentence(1.0)).is_none()); |
| 130 | + } |
| 131 | + assert_eq!(arc.chain().filled(), 0, "ring untouched while window fills"); |
| 132 | + } |
| 133 | + |
| 134 | + #[test] |
| 135 | + fn projection_slides_into_ring_when_window_saturates() { |
| 136 | + let mut arc = Arcuate::new(2, Kernel::Uniform); |
| 137 | + let mut emitted = None; |
| 138 | + for _ in 0..5 { |
| 139 | + emitted = arc.feed(sentence(1.0)); |
| 140 | + } |
| 141 | + assert!(emitted.is_some(), "5th feed (radius 2) emits a projection"); |
| 142 | + assert_eq!(arc.chain().filled(), 1, "one projection slid into the ring"); |
| 143 | + // Newest occupies the last slot; ring length stays at the contract ±5. |
| 144 | + assert_eq!(arc.chain().fingerprints.len(), 11); |
| 145 | + assert!(arc.chain().fingerprints[10].is_some(), "newest at the tail"); |
| 146 | + } |
| 147 | + |
| 148 | + #[test] |
| 149 | + fn ring_saturates_and_keeps_length_after_many_feeds() { |
| 150 | + let mut arc = Arcuate::new(2, Kernel::Uniform); |
| 151 | + // 5 to warm up + 11 emits to fill all slots = 16 feeds; vary content so |
| 152 | + // the ring is not degenerate. |
| 153 | + for i in 0..16 { |
| 154 | + arc.feed(sentence(1.0 + i as f32)); |
| 155 | + } |
| 156 | + assert_eq!(arc.chain().fingerprints.len(), 11); |
| 157 | + assert!(arc.chain().is_saturated(), "ring full after ≥15 emits"); |
| 158 | + assert!(arc.chain().focal().is_some(), "focal slot carries signal"); |
| 159 | + } |
| 160 | + |
| 161 | + #[test] |
| 162 | + fn disambiguate_over_populated_ring_ranks_candidates() { |
| 163 | + let mut arc = Arcuate::new(2, Kernel::Uniform); |
| 164 | + for i in 0..16 { |
| 165 | + arc.feed(sentence(1.0 + i as f32)); |
| 166 | + } |
| 167 | + let result = arc.disambiguate([fp(1.0), fp(-1.0)]); |
| 168 | + assert_eq!(result.candidate_count, 2, "both candidates evaluated"); |
| 169 | + assert!(result.winner_index < 2, "a real winner over the ±5 evidence"); |
| 170 | + } |
| 171 | +} |
0 commit comments