@@ -290,7 +290,7 @@ def _check_finding_citations(
290290 """
291291 # Lazy imports to keep validate.py importable when runs_index/ledger
292292 # have issues, and to defer the (small) import cost.
293- from aexp .ledger import list_ledger_job_ids
293+ from aexp .ledger import list_ledger_job_ids , load_ledger_entry
294294 from aexp .runs_index import collect_known_elsewhere
295295
296296 issues : list [Issue ] = []
@@ -353,6 +353,25 @@ def _check_finding_citations(
353353 if isinstance (exp , str ) and isinstance (cond , str ):
354354 elsewhere_batches .setdefault ((exp , cond ), []).append (entry )
355355
356+ # Universal ledger entries also need to participate in batch resolution.
357+ # The job-id existence check above already counts ledger entries via
358+ # `known_job_ids = ledger_ids | local_ids`, but the batch-citation check
359+ # uses `list_batches()` which walks the local signac project only and
360+ # therefore can't see ledger-only entries. Without this lookup, a batch
361+ # citation like `{type: batch, experiment_id: E005, selector: {condition: X}}`
362+ # spuriously emits `finding.empty_batch` even though the universal ledger
363+ # has matching terminal runs from another machine.
364+ ledger_batches : dict [tuple [str , str ], list [str ]] = {}
365+ for jid in ledger_ids :
366+ entry = load_ledger_entry (repo_root , jid )
367+ if not entry :
368+ continue
369+ sp = entry .get ("statepoint" , {}) or {}
370+ exp = sp .get ("experiment_id" )
371+ cond = sp .get ("condition" )
372+ if isinstance (exp , str ) and isinstance (cond , str ):
373+ ledger_batches .setdefault ((exp , cond ), []).append (jid )
374+
356375 # finding.no_run_store: a single warning per validate run when no
357376 # source-of-truth for run identity is available — no ledger entries,
358377 # no local store, no cross-machine indexes. Without this, the validator
@@ -494,10 +513,28 @@ def _check_finding_citations(
494513 local_count = sum (b .count for b in local_matches ) if local_matches else 0
495514
496515 if local_count > 0 :
497- continue # here: clean
516+ continue # here: clean (local matches)
517+
518+ # Then check the universal ledger. `list_batches()` only walks
519+ # the local signac project, so ledger-only entries (no local
520+ # workspace) need a separate check via the (exp, cond) index
521+ # built above. Without this, batch citations spuriously emit
522+ # `finding.empty_batch` even when the ledger has matching
523+ # terminal runs from another machine.
524+ cond = selector .get ("condition" ) if isinstance (selector , dict ) else None
525+ ledger_match_count = 0
526+ if isinstance (cond , str ):
527+ ledger_match_count = len (ledger_batches .get ((exp_id , cond ), []))
528+ elif isinstance (selector , dict ) and not selector :
529+ # Empty selector: any ledger entry for this experiment counts.
530+ ledger_match_count = sum (
531+ 1 for (e , _c ) in ledger_batches .keys () if e == exp_id
532+ )
533+
534+ if ledger_match_count > 0 :
535+ continue # here: clean (ledger matches)
498536
499537 # Check the elsewhere index. Match by experiment_id + condition.
500- cond = selector .get ("condition" ) if isinstance (selector , dict ) else None
501538 elsewhere_matches : list [dict [str , Any ]] = []
502539 if isinstance (cond , str ):
503540 elsewhere_matches = elsewhere_batches .get ((exp_id , cond ), [])
0 commit comments