@@ -20,6 +20,7 @@ pub use hebb::engine::sim::SimEngine;
2020pub use spike_persist:: spawn_spike_persister;
2121pub use weight_persist:: spawn_weight_persister;
2222
23+ use std:: collections:: BTreeMap ;
2324use std:: path:: PathBuf ;
2425use std:: time:: Duration ;
2526use tokio:: sync:: { broadcast, mpsc, oneshot} ;
@@ -73,6 +74,25 @@ pub struct EngineSeedReport {
7374 pub added_edges : usize ,
7475}
7576
77+ #[ derive( Debug , Clone , serde:: Serialize ) ]
78+ pub struct EngineRunSummary {
79+ pub requested_duration_ms : f32 ,
80+ pub duration_ms : f32 ,
81+ pub dt_ms : f32 ,
82+ pub steps : usize ,
83+ pub t_start_ms : f64 ,
84+ pub t_end_ms : f64 ,
85+ pub total_spikes : usize ,
86+ pub per_neuron : Vec < EngineRunNeuronCount > ,
87+ pub cancelled : bool ,
88+ }
89+
90+ #[ derive( Debug , Clone , serde:: Serialize ) ]
91+ pub struct EngineRunNeuronCount {
92+ pub node_id : Uuid ,
93+ pub spikes : usize ,
94+ }
95+
7696pub enum EngineCommand {
7797 AddNode ( Uuid ) ,
7898 AddEdge {
@@ -100,6 +120,11 @@ pub enum EngineCommand {
100120 node_id : Uuid ,
101121 reply : oneshot:: Sender < Result < ( ) , String > > ,
102122 } ,
123+ RunFor {
124+ duration_ms : f32 ,
125+ dt_ms : f32 ,
126+ reply : oneshot:: Sender < Result < EngineRunSummary , String > > ,
127+ } ,
103128 Snapshot ( oneshot:: Sender < EngineSnapshot > ) ,
104129 /// Live topology snapshot with synapse endpoints. This is the
105130 /// read-only graph view the agent harness needs without going
@@ -338,6 +363,19 @@ impl SimHandle {
338363 rx. await . map_err ( |_| "engine dropped reply" . to_string ( ) ) ?
339364 }
340365
366+ pub async fn run_for ( & self , duration_ms : f32 , dt_ms : f32 ) -> Result < EngineRunSummary , String > {
367+ let ( tx, rx) = oneshot:: channel ( ) ;
368+ self . cmd_tx
369+ . send ( EngineCommand :: RunFor {
370+ duration_ms,
371+ dt_ms,
372+ reply : tx,
373+ } )
374+ . await
375+ . map_err ( |_| "engine offline" . to_string ( ) ) ?;
376+ rx. await . map_err ( |_| "engine dropped reply" . to_string ( ) ) ?
377+ }
378+
341379 pub async fn snapshot ( & self ) -> Result < EngineSnapshot , & ' static str > {
342380 let ( tx, rx) = oneshot:: channel ( ) ;
343381 self . cmd_tx
@@ -737,6 +775,19 @@ pub fn spawn_engine(tick_hz: u32) -> (SimHandle, tokio::task::JoinHandle<()>) {
737775 } ;
738776 let _ = reply. send( result) ;
739777 }
778+ EngineCommand :: RunFor {
779+ duration_ms,
780+ dt_ms,
781+ reply,
782+ } => {
783+ let result = run_engine_for(
784+ & mut engine,
785+ & spike_tx,
786+ duration_ms,
787+ dt_ms,
788+ ) ;
789+ let _ = reply. send( result) ;
790+ }
740791 EngineCommand :: Snapshot ( reply) => {
741792 let snap = EngineSnapshot {
742793 t_ms: engine. t_ms,
@@ -1830,6 +1881,60 @@ fn open_folder_into_engine(
18301881 Ok ( ( engine, cortex_type, kind, cortex, summary) )
18311882}
18321883
1884+ fn run_engine_for (
1885+ engine : & mut SimEngine ,
1886+ spike_tx : & broadcast:: Sender < SpikeFrame > ,
1887+ duration_ms : f32 ,
1888+ dt_ms : f32 ,
1889+ ) -> Result < EngineRunSummary , String > {
1890+ if !duration_ms. is_finite ( ) || !dt_ms. is_finite ( ) {
1891+ return Err ( "duration_ms and dt_ms must be finite" . into ( ) ) ;
1892+ }
1893+ if duration_ms < 0.0 {
1894+ return Err ( "duration_ms must be >= 0" . into ( ) ) ;
1895+ }
1896+ if dt_ms <= 0.0 {
1897+ return Err ( "dt_ms must be > 0" . into ( ) ) ;
1898+ }
1899+
1900+ let t_start_ms = engine. t_ms ;
1901+ let steps = ( duration_ms / dt_ms) . ceil ( ) as usize ;
1902+ let mut counts: BTreeMap < Uuid , usize > = BTreeMap :: new ( ) ;
1903+ let mut total_spikes = 0 ;
1904+
1905+ for step in 0 ..steps {
1906+ let elapsed = step as f32 * dt_ms;
1907+ let remaining = ( duration_ms - elapsed) . max ( 0.0 ) ;
1908+ let step_dt = remaining. min ( dt_ms) ;
1909+ if step_dt <= 0.0 {
1910+ break ;
1911+ }
1912+ let frame = engine. tick ( step_dt) ;
1913+ if !frame. events . is_empty ( ) {
1914+ total_spikes += frame. events . len ( ) ;
1915+ for event in & frame. events {
1916+ * counts. entry ( event. node_id ) . or_insert ( 0 ) += 1 ;
1917+ }
1918+ let _ = spike_tx. send ( frame) ;
1919+ }
1920+ }
1921+
1922+ Ok ( EngineRunSummary {
1923+ requested_duration_ms : duration_ms,
1924+ duration_ms,
1925+ dt_ms,
1926+ steps,
1927+ t_start_ms,
1928+ t_end_ms : engine. t_ms ,
1929+ total_spikes,
1930+ per_neuron : counts
1931+ . into_iter ( )
1932+ . map ( |( node_id, spikes) | EngineRunNeuronCount { node_id, spikes } )
1933+ . collect ( ) ,
1934+ cancelled : false ,
1935+ } )
1936+ }
1937+
18331938/// Periodically diff the live weight set against the last published one
18341939/// and broadcast deltas. Runs as a sibling task to the engine — reads
18351940/// state through `SimHandle::weight_snapshot` (one mpsc round-trip) so
0 commit comments