Skip to content

Commit 0060acb

Browse files
fix(eval): bind prompt target provenance
1 parent ebfc440 commit 0060acb

5 files changed

Lines changed: 204 additions & 87 deletions

File tree

examples/optimization/eval_optimize_loop/README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,12 @@ stores a normalized, credential-free evaluation configuration and its hash;
134134
report validation derives optimizer and final judge-call multipliers from that
135135
snapshot rather than trusting the reported counters. Evaluation-metric and
136136
evalset manifests bind that snapshot to file hashes, case counts, and
137-
conversation-turn counts. Prompt artifacts and optimizer rounds embed prompt
138-
content so every reported SHA-256 remains independently recomputable even when
139-
the original run directory is unavailable.
137+
conversation-turn counts. A prompt-target manifest binds the registered target
138+
names to source paths and SHA-256 values. Validation authenticates those source
139+
files, recomputes every candidate diff from embedded baseline content, and
140+
rejects unknown optimizer targets. Prompt artifacts and optimizer rounds embed
141+
content so their reported SHA-256 values remain independently recomputable even
142+
when the original run directory is unavailable.
140143

141144
`optimizer_dev.evalset.json` is the optimizer-internal holdout passed to
142145
`AgentOptimizer.optimize(..., validation_dataset_path=...)`. `val.evalset.json`

examples/optimization/eval_optimize_loop/fixtures/optimization_report.sample.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2712,6 +2712,16 @@
27122712
"evaluation_sha256": "a2a1ec7e174d243b397ee7875d7528cb198d1c1d3fbce9dc82f8cb85e33fe166",
27132713
"evaluation_metrics_sha256": "a2a1ec7e174d243b397ee7875d7528cb198d1c1d3fbce9dc82f8cb85e33fe166",
27142714
"optimizer_config_sha256": "9df0a82039d6de55660169bf6061572da99b647f0141cdfcd5c6f0b4cd448d98",
2715+
"prompt_targets": {
2716+
"system_prompt": {
2717+
"source_path": "examples/optimization/eval_optimize_loop/agent/prompts/system.md",
2718+
"sha256": "ac2208517d00b42bccfc2336108753e0e3c0ea238345c6b851896b09a4819ab3"
2719+
},
2720+
"router_prompt": {
2721+
"source_path": "examples/optimization/eval_optimize_loop/agent/prompts/router.md",
2722+
"sha256": "1897b273cf69ace89b50e5c9ba77e135935544bd67100ecd11c78ee7fa317f10"
2723+
}
2724+
},
27152725
"evalsets": {
27162726
"train": {
27172727
"path": "examples/optimization/eval_optimize_loop/train.evalset.json",

examples/optimization/eval_optimize_loop/optimization_report.schema.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,15 @@
177177
"diff": {"type": "string"}
178178
}
179179
},
180+
"promptTarget": {
181+
"type": "object",
182+
"additionalProperties": false,
183+
"required": ["source_path", "sha256"],
184+
"properties": {
185+
"source_path": {"type": "string", "minLength": 1},
186+
"sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}
187+
}
188+
},
180189
"baseline": {
181190
"type": "object",
182191
"additionalProperties": false,
@@ -765,6 +774,7 @@
765774
"evaluation_sha256",
766775
"evaluation_metrics_sha256",
767776
"optimizer_config_sha256",
777+
"prompt_targets",
768778
"evalsets",
769779
"paths"
770780
],
@@ -776,6 +786,15 @@
776786
"evaluation_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
777787
"evaluation_metrics_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
778788
"optimizer_config_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
789+
"prompt_targets": {
790+
"type": "object",
791+
"additionalProperties": false,
792+
"required": ["system_prompt", "router_prompt"],
793+
"properties": {
794+
"system_prompt": {"$ref": "#/$defs/promptTarget"},
795+
"router_prompt": {"$ref": "#/$defs/promptTarget"}
796+
}
797+
},
779798
"evalsets": {
780799
"type": "object",
781800
"additionalProperties": false,

examples/optimization/eval_optimize_loop/run_pipeline.py

Lines changed: 64 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
PROMPT_DIR = HERE / "agent" / "prompts"
5757
SYSTEM_PROMPT_PATH = PROMPT_DIR / "system.md"
5858
ROUTER_PROMPT_PATH = PROMPT_DIR / "router.md"
59+
PROMPT_TARGET_NAMES = ("system_prompt", "router_prompt")
5960
DEFAULT_RUNS_DIR = HERE / "runs"
6061
DEFAULT_SEED = 7
6162
DEFAULT_MAX_SECONDS = 180.0
@@ -279,19 +280,12 @@ def validate_prompt_artifacts(artifacts: list[dict[str, Any]], label: str) -> No
279280
candidate_path = REPO_ROOT / candidate_path
280281
if candidate_path.is_file() and candidate_path.read_text(encoding="utf-8") != content:
281282
reject(f"{label} prompt artifact content does not match the referenced file")
282-
source_path = Path(artifact["source_path"])
283-
if not source_path.is_absolute():
284-
source_path = REPO_ROOT / source_path
285-
if source_path.is_file():
286-
source_text = source_path.read_text(encoding="utf-8")
287-
if artifact["diff"] != prompt_diff(source_text, content, artifact["name"]):
288-
reject(f"{label} prompt artifact diff does not match source and candidate content")
289283

290284
for summary_name in ("train", "optimizer_dev", "validation", "final_validation"):
291285
reject_duplicate_cases(baseline[summary_name], f"baseline.{summary_name}")
292286
validate_evaluation_summary(baseline[summary_name], f"baseline.{summary_name}")
293287
validate_prompt_artifacts(baseline["prompt_artifacts"], "baseline")
294-
baseline_prompt_names = {artifact["name"] for artifact in baseline["prompt_artifacts"]}
288+
baseline_prompts_by_name = {artifact["name"]: artifact for artifact in baseline["prompt_artifacts"]}
295289

296290
if report["failure_attribution"] != attribution_for(baseline["validation"]):
297291
reject("top-level failure attribution does not match baseline validation failures")
@@ -313,6 +307,34 @@ def validate_prompt_artifacts(artifacts: list[dict[str, Any]], label: str) -> No
313307
reject("evaluation config hash does not match the normalized snapshot")
314308
expected_judge_multiplier = judge_calls_per_agent_call(evaluation_snapshot)
315309
config_paths = config_snapshot["paths"]
310+
expected_prompt_names = set(PROMPT_TARGET_NAMES)
311+
prompt_targets = config_snapshot["prompt_targets"]
312+
if set(prompt_targets) != expected_prompt_names:
313+
reject("prompt target manifest must contain exactly the registered target names")
314+
if set(baseline_prompts_by_name) != expected_prompt_names:
315+
reject("baseline prompt artifacts must match the registered target names")
316+
for name in PROMPT_TARGET_NAMES:
317+
target = prompt_targets[name]
318+
baseline_artifact = baseline_prompts_by_name[name]
319+
expected_source_path = config_paths[name]
320+
if target["source_path"] != expected_source_path:
321+
reject(f"prompt target source path does not match config paths: {name}")
322+
if report["artifacts"].get(name) != expected_source_path:
323+
reject(f"prompt source path does not match report artifacts: {name}")
324+
if baseline_artifact["source_path"] != expected_source_path:
325+
reject(f"baseline prompt source path does not match target manifest: {name}")
326+
if target["sha256"] != baseline_artifact["sha256"]:
327+
reject(f"prompt target hash does not match baseline content: {name}")
328+
if baseline_artifact["diff"] != prompt_diff(baseline_artifact["content"], baseline_artifact["content"], name):
329+
reject(f"baseline prompt diff does not match embedded baseline content: {name}")
330+
source_path = Path(expected_source_path)
331+
if not source_path.is_absolute():
332+
source_path = REPO_ROOT / source_path
333+
if not source_path.is_file():
334+
reject(f"referenced prompt source artifact must exist: {name}")
335+
source_text = source_path.read_text(encoding="utf-8")
336+
if sha256_text(source_text) != target["sha256"] or source_text != baseline_artifact["content"]:
337+
reject(f"prompt target manifest does not match the referenced source artifact: {name}")
316338
expected_optimizer_config_path = config_paths["optimizer_config"]
317339
if report["artifacts"].get("optimizer_config") != expected_optimizer_config_path:
318340
reject("optimizer config path does not match report artifacts")
@@ -375,6 +397,8 @@ def validate_prompt_artifacts(artifacts: list[dict[str, Any]], label: str) -> No
375397
reject("optimization round identifiers must be unique")
376398
for round_record in report["optimization_rounds"]:
377399
prompt_keys = set(round_record["prompt_paths"])
400+
if not prompt_keys.issubset(expected_prompt_names):
401+
reject("optimization round contains an unknown prompt target")
378402
if prompt_keys != set(round_record["prompt_sha256"]) or prompt_keys != set(round_record["prompt_contents"]):
379403
reject("optimization round prompt path, hash, and content keys must match")
380404
for name in prompt_keys:
@@ -460,8 +484,21 @@ def validate_prompt_artifacts(artifacts: list[dict[str, Any]], label: str) -> No
460484
if candidate_audit["config_sha256"] != expected_optimizer_config_hash:
461485
reject(f"candidate audit config hash does not match config snapshot: {candidate_id}")
462486
validate_prompt_artifacts(candidate["prompt_artifacts"], f"candidate {candidate_id}")
463-
if {artifact["name"] for artifact in candidate["prompt_artifacts"]} != baseline_prompt_names:
487+
candidate_prompts_by_name = {artifact["name"]: artifact for artifact in candidate["prompt_artifacts"]}
488+
if set(candidate_prompts_by_name) != expected_prompt_names:
464489
reject(f"candidate prompt artifact names must match baseline targets: {candidate_id}")
490+
for name in PROMPT_TARGET_NAMES:
491+
candidate_artifact = candidate_prompts_by_name[name]
492+
baseline_artifact = baseline_prompts_by_name[name]
493+
if candidate_artifact["source_path"] != prompt_targets[name]["source_path"]:
494+
reject(f"candidate prompt source path must match target manifest: {candidate_id}/{name}")
495+
expected_diff = prompt_diff(
496+
baseline_artifact["content"],
497+
candidate_artifact["content"],
498+
name,
499+
)
500+
if candidate_artifact["diff"] != expected_diff:
501+
reject(f"candidate prompt diff does not match embedded baseline: {candidate_id}/{name}")
465502
pipeline_cost = cost["estimated_total"]
466503
audit_cost = candidate_audit["cost"]
467504
if audit_cost["known"] is not (pipeline_cost is not None) or audit_cost["estimated"] != pipeline_cost:
@@ -888,35 +925,6 @@ def _prompt_sort_key(item: tuple[Any, Any]) -> tuple[str, str]:
888925
return type(name).__name__, repr(name)
889926

890927

891-
def _normalized_prompt_artifact_key(
892-
name: Any,
893-
used_names: set[str],
894-
) -> tuple[str, bool, bool]:
895-
if isinstance(name, str) and name in {"system_prompt", "router_prompt"}:
896-
used_names.add(name)
897-
return name, False, False
898-
if isinstance(name, str):
899-
normalized_name = Path(name).name
900-
normalized_name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", normalized_name)
901-
normalized_name = normalized_name.strip(" .")
902-
else:
903-
normalized_name = f"prompt_{type(name).__name__}"
904-
if not normalized_name or normalized_name in {".", ".."}:
905-
normalized_name = "prompt"
906-
if _is_windows_reserved_name(normalized_name):
907-
normalized_name = f"prompt_{normalized_name}"
908-
909-
base_name = normalized_name
910-
suffix = 2
911-
casefold_collision = False
912-
used_casefold_names = {used_name.casefold() for used_name in used_names}
913-
while normalized_name.casefold() in used_casefold_names:
914-
casefold_collision = True
915-
normalized_name = f"{base_name}__{suffix}"
916-
suffix += 1
917-
return normalized_name, normalized_name != name, casefold_collision
918-
919-
920928
def write_optimizer_round_artifacts(
921929
*,
922930
run_dir: Path,
@@ -958,25 +966,13 @@ def write_optimizer_round_artifacts(
958966
prompt_reasons.append("candidate_prompts was not a mapping and was normalized to an empty mapping")
959967
raw_candidate_prompts = {}
960968
raw_prompt_items = sorted(raw_candidate_prompts.items(), key=_prompt_sort_key)
961-
used_prompt_names = {
962-
raw_name
963-
for raw_name, _ in raw_prompt_items
964-
if isinstance(raw_name, str) and raw_name in {"system_prompt", "router_prompt"}
965-
}
966969
for raw_name, raw_content in raw_prompt_items:
967-
name, name_was_normalized, casefold_collision = _normalized_prompt_artifact_key(
968-
raw_name,
969-
used_prompt_names,
970-
)
971-
used_prompt_names.add(name)
972-
if name_was_normalized:
973-
invalid_prompt_evidence = True
974-
if "prompt artifact key was normalized safely" not in prompt_reasons:
975-
prompt_reasons.append("prompt artifact key was normalized safely")
976-
if casefold_collision:
970+
if not isinstance(raw_name, str) or raw_name not in PROMPT_TARGET_NAMES:
977971
invalid_prompt_evidence = True
978-
if "prompt filenames collided case-insensitively" not in prompt_reasons:
979-
prompt_reasons.append("prompt filenames collided case-insensitively and were disambiguated")
972+
if "unknown prompt target was dropped" not in prompt_reasons:
973+
prompt_reasons.append("unknown prompt target was dropped")
974+
continue
975+
name = raw_name
980976
if isinstance(raw_content, str):
981977
content = raw_content
982978
else:
@@ -1605,6 +1601,16 @@ def read_source_prompts(system_prompt: Path, router_prompt: Path) -> dict[str, t
16051601
}
16061602

16071603

1604+
def build_prompt_target_manifest(
1605+
source_prompts: Mapping[str, tuple[Path, str]],
1606+
) -> dict[str, dict[str, str]]:
1607+
return {
1608+
name: {"source_path": str(source_path), "sha256": sha256_text(source_text)}
1609+
for name in PROMPT_TARGET_NAMES
1610+
for source_path, source_text in [source_prompts[name]]
1611+
}
1612+
1613+
16081614
def offline_candidate_prompts(
16091615
source_prompts: dict[str, tuple[Path, str]],
16101616
candidate_id: str,
@@ -3124,6 +3130,7 @@ async def build_offline_report(
31243130
"evaluation_sha256": sha256_json(evaluation_snapshot),
31253131
"evaluation_metrics_sha256": sha256_json_file(metrics_path),
31263132
"optimizer_config_sha256": sha256_json_file(optimizer_config),
3133+
"prompt_targets": build_prompt_target_manifest(source_prompts),
31273134
"evalsets": build_evalset_manifests(train_evalset, optimizer_dev_evalset, val_evalset),
31283135
"paths": {
31293136
"train_evalset": str(train_evalset),
@@ -3669,9 +3676,7 @@ async def counted_optimizer_call_agent(query: str) -> str:
36693676
run_dir=run_dir,
36703677
candidate_id="baseline",
36713678
source_prompts=source_prompts,
3672-
candidate_prompts=dict(
3673-
getattr(result, "baseline_prompts", {}) or {name: text for name, (_, text) in source_prompts.items()}
3674-
),
3679+
candidate_prompts={name: text for name, (_, text) in source_prompts.items()},
36753680
summary="Source prompts before AgentOptimizer.optimize.",
36763681
source_written=False,
36773682
)
@@ -3770,6 +3775,7 @@ async def counted_optimizer_call_agent(query: str) -> str:
37703775
"evaluation_sha256": sha256_json(evaluation_snapshot),
37713776
"evaluation_metrics_sha256": sha256_json_file(metrics_path),
37723777
"optimizer_config_sha256": sha256_json_file(runtime_optimizer_path),
3778+
"prompt_targets": build_prompt_target_manifest(source_prompts),
37733779
"evalsets": build_evalset_manifests(train_path, optimizer_dev_path, val_path),
37743780
"paths": {
37753781
"train_evalset": str(train_path),

0 commit comments

Comments
 (0)