@@ -68,6 +68,13 @@ type Parallel struct {
6868 // Runtime state for logging/context (captured once)
6969 moduleInfoOnce sync.Once
7070 moduleInfo parallelModuleInfo
71+
72+ mu sync.Mutex
73+
74+ // Factory cache to avoid double factory calls during probing and execution
75+ factoryCache []core.Module
76+ factoryCount int
77+ factoryCounted bool
7178}
7279
7380// getParallelModuleInfo extracts module type and LM model information for logging
@@ -214,12 +221,21 @@ func NewParallel(module core.Module) *Parallel {
214221 batchKey : "_batch" ,
215222 repeat : 1 ,
216223 verbose : false ,
224+ factoryCache : nil ,
225+ factoryCount : 0 ,
226+ factoryCounted : false ,
217227 }
218228}
219229
220230// NewParallelWithFactory creates a Parallel module with a factory function.
221231// The factory is called for each task with the task index.
222- // This is the recommended approach for stateful modules.
232+ //
233+ // When inputs don't specify a batch (_batch or slices), Parallel probes the
234+ // factory to determine module count by calling factory(i) until it returns nil.
235+ // The factory MUST return nil for out-of-bounds indices.
236+ //
237+ // In factory mode, WithRepeat(n) is ignored as the factory count determines
238+ // the number of parallel tasks.
223239func NewParallelWithFactory (factory func (i int ) core.Module ) * Parallel {
224240 return & Parallel {
225241 factory : factory ,
@@ -231,11 +247,17 @@ func NewParallelWithFactory(factory func(i int) core.Module) *Parallel {
231247 batchKey : "_batch" ,
232248 repeat : 1 ,
233249 verbose : false ,
250+ factoryCache : nil ,
251+ factoryCount : 0 ,
252+ factoryCounted : false ,
234253 }
235254}
236255
237256// NewParallelWithInstances creates a Parallel module with pre-created instances.
238- // Each task will use instances[i % len(instances)].
257+ // Each task uses instances[i % len(instances)].
258+ //
259+ // By default, repeat is set to len(instances), so a single input runs through
260+ // all instances. Override with WithRepeat() if using batch inputs.
239261func NewParallelWithInstances (instances []core.Module ) * Parallel {
240262 if len (instances ) == 0 {
241263 panic ("NewParallelWithInstances: instances slice cannot be empty" )
@@ -248,8 +270,11 @@ func NewParallelWithInstances(instances []core.Module) *Parallel {
248270 returnAll : true ,
249271 onlySuccessful : true ,
250272 batchKey : "_batch" ,
251- repeat : 1 ,
273+ repeat : len ( instances ), // Auto-set to run all instances with same input
252274 verbose : false ,
275+ factoryCache : nil ,
276+ factoryCount : 0 ,
277+ factoryCounted : false ,
253278 }
254279}
255280
@@ -367,7 +392,7 @@ func (p *Parallel) Forward(ctx context.Context, inputs map[string]any) (*core.Pr
367392 }()
368393
369394 // Expand inputs into batch
370- batch , err := p .expandInputs (inputs )
395+ batch , err := p .expandInputs (ctx , inputs )
371396 if err != nil {
372397 predErr = fmt .Errorf ("failed to expand inputs: %w" , err )
373398 return nil , predErr
@@ -416,6 +441,14 @@ func (p *Parallel) Forward(ctx context.Context, inputs map[string]any) (*core.Pr
416441 // Module getter
417442 getModule := func (i int ) core.Module {
418443 if p .factory != nil {
444+ // Check cache first (probed during expandInputs)
445+ p .mu .Lock ()
446+ if i < len (p .factoryCache ) && p .factoryCache [i ] != nil {
447+ mod := p .factoryCache [i ]
448+ p .mu .Unlock ()
449+ return mod
450+ }
451+ p .mu .Unlock ()
419452 return p .factory (i )
420453 }
421454 if len (p .instances ) > 0 {
@@ -712,7 +745,7 @@ func (p *Parallel) Forward(ctx context.Context, inputs map[string]any) (*core.Pr
712745}
713746
714747// expandInputs converts inputs into a slice of input maps
715- func (p * Parallel ) expandInputs (inputs map [string ]any ) ([]map [string ]any , error ) {
748+ func (p * Parallel ) expandInputs (ctx context. Context , inputs map [string ]any ) ([]map [string ]any , error ) {
716749 // Check for explicit batch
717750 if batchVal , ok := inputs [p .batchKey ]; ok {
718751 batch , ok := batchVal .([]map [string ]any )
@@ -760,7 +793,26 @@ func (p *Parallel) expandInputs(inputs map[string]any) ([]map[string]any, error)
760793 return batch , nil
761794 }
762795
763- // No batch, no slices - repeat if configured
796+ // No batch, no slices - check for factory-based module count
797+ if p .factory != nil {
798+ // Log warning if WithRepeat was also used
799+ if p .repeat > 1 {
800+ logging .GetLogger ().Warn (ctx , "Parallel: WithRepeat(n) ignored because factory is present. Factory count takes precedence." , map [string ]any {
801+ "repeat_ignored" : p .repeat ,
802+ })
803+ }
804+ // Probe factory to determine module count
805+ n := p .probeFactoryCount ()
806+ batch := make ([]map [string ]any , n )
807+ for i := 0 ; i < n ; i ++ {
808+ taskInputs := make (map [string ]any )
809+ maps .Copy (taskInputs , inputs )
810+ batch [i ] = taskInputs
811+ }
812+ return batch , nil
813+ }
814+
815+ // Repeat if configured
764816 if p .repeat > 1 {
765817 batch := make ([]map [string ]any , p .repeat )
766818 for i := 0 ; i < p .repeat ; i ++ {
@@ -776,6 +828,39 @@ func (p *Parallel) expandInputs(inputs map[string]any) ([]map[string]any, error)
776828 return []map [string ]any {inputs }, nil
777829}
778830
831+ // probeFactoryCount determines how many modules the factory can create
832+ // by calling factory(i) until it returns nil. Returns at least 1.
833+ func (p * Parallel ) probeFactoryCount () int {
834+ p .mu .Lock ()
835+ defer p .mu .Unlock ()
836+ if p .factoryCounted {
837+ return p .factoryCount
838+ }
839+ if p .factory == nil {
840+ p .factoryCount = 1
841+ p .factoryCounted = true
842+ return 1
843+ }
844+
845+ const maxProbe = 1000 // Safety limit
846+ var cache []core.Module
847+ for i := 0 ; i < maxProbe ; i ++ {
848+ mod := p .factory (i )
849+ if mod == nil {
850+ p .factoryCount = i
851+ p .factoryCache = cache
852+ p .factoryCounted = true
853+ return p .factoryCount
854+ }
855+ cache = append (cache , mod )
856+ }
857+
858+ p .factoryCount = maxProbe
859+ p .factoryCache = cache
860+ p .factoryCounted = true
861+ return maxProbe
862+ }
863+
779864// summarizeLatencies calculates min/max/avg/p50 from latencies
780865func summarizeLatencies (latencies []time.Duration ) struct {
781866 MinMs int64 `json:"min_ms"`
@@ -857,6 +942,9 @@ func (p *Parallel) Clone() core.Module {
857942 batchKey : p .batchKey ,
858943 repeat : p .repeat ,
859944 verbose : p .verbose ,
945+ factoryCache : nil ,
946+ factoryCount : 0 ,
947+ factoryCounted : false ,
860948 }
861949
862950 // Only clone module if it exists
0 commit comments