Skip to content

Commit c27e2f6

Browse files
authored
Merge pull request #25 from KadenMc/fix/validator-batch-ledger-resolution
fix: batch citations resolve against the universal ledger
2 parents 75c4eda + 61f755c commit c27e2f6

4 files changed

Lines changed: 165 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.6.1] - 2026-05-26
11+
12+
### Fixed
13+
14+
- **Validator batch citations now resolve against the universal ledger.**
15+
In 0.6.0 the job-id citation check correctly consulted both the local
16+
signac project AND `.aexp/ledger/<id>.json`, but the batch-citation
17+
check still went through `aexp.linking.list_batches()` — which walks
18+
the local signac project only. The result: a finding citing a batch
19+
selector like `{type: batch, experiment_id: E005, selector: {condition:
20+
X}}` would spuriously emit `finding.empty_batch` even when the
21+
universal ledger held matching terminal runs from another machine.
22+
Now the batch-resolution path also builds an `(experiment_id,
23+
condition) -> [job_ids]` index from ledger entries and counts those
24+
matches before falling through to the empty/elsewhere paths. Closes
25+
the gap surfaced during the electricrag laptop validate at 0.6.0:
26+
after the cluster backfilled, F005 batch citations correctly resolve.
27+
Regression test: `tests/test_validate_cross_machine.py::test_validator_batch_citation_resolves_via_ledger_only`.
28+
29+
### Notes
30+
31+
- `registered_machine` in ledger entries remains *informational
32+
provenance only* — the validator does not consult it for resolution.
33+
A run is in the ledger or it isn't; which machine registered it
34+
doesn't affect whether it satisfies a citation. (This is intentional
35+
per the Phase 2 universal-ledger design and is documented in the new
36+
regression test's comment.)
37+
38+
### Note about PyPI
39+
40+
0.6.0 was tagged in source but never published to PyPI; this 0.6.1
41+
release is the first publish of the cross-machine-ledger surface. PyPI
42+
history therefore goes 0.5.0 → 0.6.1.
43+
1044
## [0.6.0] - 2026-05-25
1145

1246
### Added

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "agentic-experiments"
3-
version = "0.6.0"
3+
version = "0.6.1"
44
description = "Git-first, hypothesis-forcing experiment tracking for agent-driven ML research. Bundles a research harness for the H->E->F artifact model, uses signac for local execution/run state, and bridges to W&B for remote observability."
55
authors = [
66
{name = "Kaden McKeen", email = "mckeenkaden@gmail.com"}

src/aexp/validate.py

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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), [])

tests/test_validate_cross_machine.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,96 @@ def test_validator_treats_ledger_entry_as_here(installed_repo: Path) -> None:
687687
assert result.ok
688688

689689

690+
def test_validator_batch_citation_resolves_via_ledger_only(
691+
installed_repo: Path,
692+
) -> None:
693+
"""Regression: batch citations must resolve against ledger entries
694+
that have NO local signac workspace.
695+
696+
Before the fix, the batch-citation check only walked the local signac
697+
project (via `list_batches`), so a finding citing
698+
`{type: batch, experiment_id: E001, selector: {condition: X}}` whose
699+
only matching runs lived in the ledger from another machine would
700+
spuriously emit `finding.empty_batch` even though the universal ledger
701+
had matching terminal runs.
702+
"""
703+
kb = installed_repo / "kb"
704+
_seed_h_e_artifacts(kb)
705+
706+
# Synthesize a ledger entry that lives ONLY in .aexp/ledger/
707+
# (no corresponding .runs/workspace/<id>/ on this machine).
708+
#
709+
# NOTE on `registered_machine`: this field is *informational
710+
# provenance* — the validator does NOT consult it during batch
711+
# resolution (or any other resolution path). The string `"cluster"`
712+
# below is purely to mirror the real-world scenario this test
713+
# regresses (cluster-registered runs satisfying a citation read on
714+
# the laptop); the test would pass identically with any other value
715+
# in `registered_machine`. The Phase 2 design choice was that the
716+
# ledger is universal: a run is in the ledger or it isn't, and
717+
# machine identity does not gate satisfaction.
718+
jid = "9" * 32
719+
target = installed_repo / LEDGER_DIR_REL / f"{jid}.json"
720+
target.parent.mkdir(parents=True, exist_ok=True)
721+
target.write_text(
722+
_json.dumps(
723+
{
724+
"schema_version": 1,
725+
"job_id": jid,
726+
"status": "complete",
727+
"statepoint": {
728+
"experiment_id": "E001",
729+
"condition": "remote_only",
730+
},
731+
"registered_machine": "cluster",
732+
"promoted_at": "2026-05-26T00:00:00Z",
733+
},
734+
indent=2,
735+
sort_keys=True,
736+
)
737+
+ "\n",
738+
encoding="utf-8",
739+
)
740+
741+
# F001 cites a BATCH (not a job id) by experiment + condition selector.
742+
_write_artifact(
743+
kb,
744+
"research/findings",
745+
"F001-a.md",
746+
{
747+
"id": "F001",
748+
"type": "finding",
749+
"hypothesis": "H001",
750+
"experiment": "E001",
751+
"impact": "moderate",
752+
"created": "2026-04-20",
753+
"supporting_runs": [
754+
{
755+
"type": "batch",
756+
"experiment_id": "E001",
757+
"selector": {"condition": "remote_only"},
758+
}
759+
],
760+
},
761+
"# F001\n> **Hypothesis**: [[H001]]\n> **Experiment**: [[E001]]\n> **Impact**: moderate\n> **Created**: 2026-04-20\n",
762+
)
763+
764+
result = validate_repo(installed_repo, mode="runs-only")
765+
citation_codes = [
766+
i.code
767+
for i in result.issues
768+
if i.code
769+
in (
770+
"finding.broken_run_citation",
771+
"finding.absent_run_citation",
772+
"finding.empty_batch",
773+
"finding.absent_batch_runs",
774+
)
775+
]
776+
assert citation_codes == [], [i.message for i in result.issues]
777+
assert result.ok
778+
779+
690780
def test_validator_ledger_supersedes_runs_index_for_same_job(
691781
installed_repo: Path,
692782
) -> None:

0 commit comments

Comments
 (0)