{review['stage']} / {review['prompt_profile_id']} / {review['parameter_profile_id']} / {review['case_id']} / seed {review['seed']}
" + f"{review['artifact_id']}
From 4398305d6dda202b82d2c5dc8bf0a8619337ae39 Mon Sep 17 00:00:00 2001
From: Hsueh0216 <105940021+Hsueh0216@users.noreply.github.com>
Date: Tue, 28 Jul 2026 10:26:11 +0700
Subject: [PATCH] feat(phase2c3): add SSD prompt and parameter ablation
---
benchmarks/phase2c3/ablation.v1.json | 210 +++++++
benchmarks/phase2c3/ablation_contract.py | 554 ++++++++++++++++++
benchmarks/phase2c3/run_ablation.py | 227 +++++++
benchmarks/phase2c3/selection.v1.json | 23 +
benchmarks/phase2c3/test_ablation_contract.py | 69 +++
docs/phase2c3-prompt-parameter-ablation.md | 165 ++++++
6 files changed, 1248 insertions(+)
create mode 100644 benchmarks/phase2c3/ablation.v1.json
create mode 100644 benchmarks/phase2c3/ablation_contract.py
create mode 100644 benchmarks/phase2c3/run_ablation.py
create mode 100644 benchmarks/phase2c3/selection.v1.json
create mode 100644 benchmarks/phase2c3/test_ablation_contract.py
create mode 100644 docs/phase2c3-prompt-parameter-ablation.md
diff --git a/benchmarks/phase2c3/ablation.v1.json b/benchmarks/phase2c3/ablation.v1.json
new file mode 100644
index 0000000..fe0446c
--- /dev/null
+++ b/benchmarks/phase2c3/ablation.v1.json
@@ -0,0 +1,210 @@
+{
+ "schema": "hanclassstudio.phase2c3_prompt_parameter_ablation.v1",
+ "version": "1.0.0",
+ "experiment_id": "phase2c3-ssd1b-prompt-parameter-ablation",
+ "purpose": "Controlled prompt and sampling-parameter ablation for the existing SSD-1B plus original flat-cartoon LoRA route.",
+ "host_contract": {
+ "operating_system": "macos",
+ "architecture": "arm64",
+ "memory_bytes": 17179869184,
+ "device": "mps"
+ },
+ "runtime_contract": {
+ "backend": "huggingface-diffusers",
+ "diffusers_revision": "v0.36.0",
+ "model_revision": "60987f37e94cd59c36b1cba832b9f97b57395a10",
+ "width": 512,
+ "height": 512,
+ "variant": "ssd-1b-plus-original-flat-cartoon-lora",
+ "production_defaults_changed": false,
+ "custom_nodes": false,
+ "arbitrary_graph": false
+ },
+ "model": {
+ "id": "ssd-1b",
+ "repository": "segmind/SSD-1B",
+ "revision": "60987f37e94cd59c36b1cba832b9f97b57395a10",
+ "license": "Apache-2.0",
+ "selected_files": [
+ {"path": "text_encoder/model.fp16.safetensors", "bytes": 246144864, "sha256": "5487ea0eee9c9a9bff8abd097908d4deff3ae1fa87b3b67397f8b9538139d447"},
+ {"path": "text_encoder_2/model.fp16.safetensors", "bytes": 1389382880, "sha256": "d3df577f6e3799c8e1bd9b40e30133710e02e8e25d0ce48cdcc790e7dfe12d6d"},
+ {"path": "unet/diffusion_pytorch_model.fp16.safetensors", "bytes": 2662790608, "sha256": "40d8ea9159f3e875278dacc7879442d58c45850cf13c62f5e26681061c51829a"},
+ {"path": "vae/diffusion_pytorch_model.fp16.safetensors", "bytes": 167335342, "sha256": "6353737672c94b96174cb590f711eac6edf2fcce5b6e91aa9d73c5adc589ee48"}
+ ]
+ },
+ "lora": {
+ "id": "flat-cartoon-lora",
+ "source": "project-authored procedural SVG/Pillow dataset; official Diffusers SDXL LoRA trainer",
+ "relative_path": "lora/pytorch_lora_weights.safetensors",
+ "bytes": 10910072,
+ "sha256": "03dd1968d37e7fdb5f1d86a5ad3ac09a961acd37b39353aed27f398e6266f785",
+ "rank": 4,
+ "alpha": 4,
+ "enabled": true
+ },
+ "negative_prompt": "text, letters, words, captions, subtitles, watermark, logo, extra limbs, malformed hands, duplicate people, merged bodies, cropped subjects, distorted furniture, blurry, low resolution, oversaturated",
+ "cases": [
+ {
+ "id": "obj_apple_01",
+ "category": "single_object",
+ "scene": "a plain warm classroom-like background with a small table",
+ "subject": "one red apple",
+ "count": "exactly one apple and no people",
+ "action": "no action",
+ "relation": "the apple is centered on the table",
+ "prompt": "one red apple on a small table, centered and fully visible",
+ "minimal": "one red apple only",
+ "seeds": [260101, 260201],
+ "confirmation_seeds": [260301],
+ "must_satisfy": ["exactly one apple", "apple fully visible", "no written text"],
+ "severe_failure": ["missing apple", "multiple apples", "text artifact"]
+ },
+ {
+ "id": "action_wave_01",
+ "category": "person_action",
+ "scene": "a plain warm classroom-like background",
+ "subject": "one child",
+ "count": "exactly one person",
+ "action": "the child waves with the right hand while standing",
+ "relation": "the full body is visible and separated from the background",
+ "prompt": "exactly one child waves with the right hand while standing, fully visible",
+ "minimal": "one child waving",
+ "seeds": [260104, 260204],
+ "confirmation_seeds": [260304],
+ "must_satisfy": ["exactly one person", "one raised waving hand", "full body visible"],
+ "severe_failure": ["wrong count", "wrong action", "anatomy defect"]
+ },
+ {
+ "id": "count_three_01",
+ "category": "person_count",
+ "scene": "a plain warm classroom-like background",
+ "subject": "three children",
+ "count": "exactly three people and no more",
+ "action": "standing still",
+ "relation": "the three children stand side by side with separated bodies",
+ "prompt": "exactly three children stand in one row, all three fully visible and separated",
+ "minimal": "three students only",
+ "seeds": [260109, 260209],
+ "confirmation_seeds": [260309],
+ "must_satisfy": ["exactly three people", "all visible", "no merged bodies"],
+ "severe_failure": ["wrong count", "merged people", "cropped subject"]
+ },
+ {
+ "id": "class_pair_01",
+ "category": "classroom_activity",
+ "scene": "a simple classroom with an empty board and a table",
+ "subject": "two students",
+ "count": "exactly two people",
+ "action": "the students read one open book together",
+ "relation": "both students sit at the same table and share the book",
+ "prompt": "exactly two students sit at a classroom table and read one open book together, no writing on the board",
+ "minimal": "two students reading together",
+ "seeds": [260115, 260215],
+ "confirmation_seeds": [260315],
+ "must_satisfy": ["exactly two people", "one shared open book", "classroom interaction", "no board text"],
+ "severe_failure": ["wrong count", "missing book", "wrong scene", "text artifact"]
+ }
+ ],
+ "prompt_profiles": [
+ {
+ "id": "P0",
+ "name": "current-baseline",
+ "description": "The current SSD-1B plus original LoRA evaluation profile, unchanged.",
+ "template": "flatcartoonstyle, clean flat two-dimensional educational cartoon, clear visual hierarchy, simple natural shapes, balanced composition, soft controlled colors, clear separation between people and objects, plain warm background, no written text, {prompt}"
+ },
+ {
+ "id": "P1",
+ "name": "hard-constraint-first",
+ "description": "Count/action/relation constraints precede a short style tail.",
+ "template": "{hard_constraints}. {prompt}. Flat two-dimensional educational cartoon, simple shapes, clear separated subjects, plain warm background, no written text."
+ },
+ {
+ "id": "P2",
+ "name": "natural-language-teaching-scene",
+ "description": "A teacher-like scene description with restrained style language.",
+ "template": "A clear classroom teaching illustration shows {natural_scene}. Use simple flat cartoon shapes, clear silhouettes, calm colors, and no written text."
+ },
+ {
+ "id": "P3",
+ "name": "structured-fields",
+ "description": "The same intent is serialized as bounded subject/count/action/relation/scene/style/exclusions fields.",
+ "template": "subject: {subject}; count: {count}; action: {action}; relation: {relation}; scene: {scene}; style: flat two-dimensional educational cartoon with clear silhouettes; exclusions: no extra people, no text, no watermark"
+ },
+ {
+ "id": "P4",
+ "name": "minimal-constraint",
+ "description": "Only the core teaching object/action and a few style words remain.",
+ "template": "{minimal}. simple flat cartoon, clear shapes, no text"
+ }
+ ],
+ "parameter_profiles": [
+ {
+ "id": "A0",
+ "name": "current-baseline",
+ "description": "Current SSD-1B pilot settings.",
+ "scheduler": "euler",
+ "scheduler_class": "EulerDiscreteScheduler",
+ "steps": 8,
+ "guidance_scale": 7.5,
+ "width": 512,
+ "height": 512
+ },
+ {
+ "id": "A1",
+ "name": "balanced",
+ "description": "More steps with lower guidance for steadier semantics.",
+ "scheduler": "dpm++",
+ "scheduler_class": "DPMSolverMultistepScheduler",
+ "steps": 12,
+ "guidance_scale": 6.5,
+ "width": 512,
+ "height": 512
+ },
+ {
+ "id": "A2",
+ "name": "strong-constraint",
+ "description": "Ancestral Euler with slightly higher guidance, bounded below over-guidance failure.",
+ "scheduler": "euler_a",
+ "scheduler_class": "EulerAncestralDiscreteScheduler",
+ "steps": 10,
+ "guidance_scale": 8.5,
+ "width": 512,
+ "height": 512
+ },
+ {
+ "id": "A3",
+ "name": "soft",
+ "description": "DDIM with lower guidance to test whether extra elements and anatomy defects reduce.",
+ "scheduler": "ddim",
+ "scheduler_class": "DDIMScheduler",
+ "steps": 10,
+ "guidance_scale": 6.0,
+ "width": 512,
+ "height": 512
+ }
+ ],
+ "stages": {
+ "prompt_only": {
+ "fixed_parameter_profile": "A0",
+ "prompt_profiles": ["P0", "P1", "P2", "P3", "P4"],
+ "cases": "all",
+ "seeds": "case.seeds"
+ },
+ "parameter_only": {
+ "fixed_prompt_profile": "P0",
+ "parameter_profiles": ["A0", "A1", "A2", "A3"],
+ "cases": "all",
+ "seeds": "case.seeds"
+ },
+ "top_combo_confirmation": {
+ "selection_limit": 3,
+ "seeds": "case.confirmation_seeds",
+ "selection_rule": "Select only after prompt-only and parameter-only review; record the evidence and keep teacher fields pending."
+ }
+ },
+ "review_contract": {
+ "fields": ["better_than_baseline", "teaching_usable", "wrong_count", "wrong_action", "wrong_spatial_relation", "extra_person", "missing_object", "style_acceptable", "reviewer_notes"],
+ "automatic_teacher_scores": false,
+ "technical_observations_are_not_teacher_conclusions": true
+ }
+}
diff --git a/benchmarks/phase2c3/ablation_contract.py b/benchmarks/phase2c3/ablation_contract.py
new file mode 100644
index 0000000..b12d9b6
--- /dev/null
+++ b/benchmarks/phase2c3/ablation_contract.py
@@ -0,0 +1,554 @@
+"""Pure contract and resumable artifact layer for the Phase 2C.3 ablation."""
+
+from __future__ import annotations
+
+import csv
+import hashlib
+import json
+import os
+import shutil
+import struct
+import zlib
+from collections.abc import Callable, Iterable
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+SCHEMA = "hanclassstudio.phase2c3_prompt_parameter_ablation.v1"
+STATE_SCHEMA = "hanclassstudio.phase2c3_prompt_parameter_ablation_state.v1"
+REVIEW_SCHEMA = "hanclassstudio.phase2c3_prompt_parameter_ablation_review.v1"
+_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
+_MAX_PNG_BYTES = 32 * 1024 * 1024
+
+
+class AblationError(RuntimeError):
+ pass
+
+
+def canonical(value: Any) -> bytes:
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
+
+
+def sha256_bytes(payload: bytes) -> str:
+ return hashlib.sha256(payload).hexdigest()
+
+
+def sha256_json(value: Any) -> str:
+ return sha256_bytes(canonical(value))
+
+
+def sha256_file(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as handle:
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def utc_now() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def read_json(path: Path) -> Any:
+ try:
+ return json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, ValueError) as exc:
+ raise AblationError(f"invalid JSON: {path}") from exc
+
+
+def write_json(path: Path, value: Any) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
+ temporary.write_bytes(canonical(value) + b"\n")
+ os.replace(temporary, path)
+
+
+def load_spec(path: Path) -> dict[str, Any]:
+ spec = read_json(path)
+ if not isinstance(spec, dict) or spec.get("schema") != SCHEMA:
+ raise AblationError("ablation schema mismatch")
+ if len(spec.get("cases", [])) != 4:
+ raise AblationError("the controlled ablation must contain exactly four cases")
+ if [p.get("id") for p in spec.get("prompt_profiles", [])] != ["P0", "P1", "P2", "P3", "P4"]:
+ raise AblationError("prompt profiles must be P0 through P4")
+ if [p.get("id") for p in spec.get("parameter_profiles", [])] != ["A0", "A1", "A2", "A3"]:
+ raise AblationError("parameter profiles must be A0 through A3")
+ if spec.get("stages", {}).get("prompt_only", {}).get("fixed_parameter_profile") != "A0":
+ raise AblationError("prompt-only stage must hold A0 fixed")
+ if spec.get("stages", {}).get("parameter_only", {}).get("fixed_prompt_profile") != "P0":
+ raise AblationError("parameter-only stage must hold P0 fixed")
+ return spec
+
+
+def _by_id(items: Iterable[dict[str, Any]], item_id: str) -> dict[str, Any]:
+ for item in items:
+ if item.get("id") == item_id:
+ return item
+ raise AblationError(f"unknown profile or case: {item_id}")
+
+
+def _hard_constraints(case: dict[str, Any]) -> str:
+ if case["id"] == "obj_apple_01":
+ return "ONE RED APPLE ONLY; ONE OBJECT; NO PEOPLE; NO EXTRA OBJECTS"
+ if case["id"] == "action_wave_01":
+ return "ONE PERSON ONLY; ONE CHILD; RIGHT HAND WAVING; NO EXTRA PEOPLE; NO CROWD"
+ if case["id"] == "count_three_01":
+ return "EXACTLY THREE PEOPLE; THREE CHILDREN ONLY; NO EXTRA PEOPLE; NO CROWD"
+ return "EXACTLY TWO PEOPLE; TWO STUDENTS ONLY; ONE SHARED BOOK; NO EXTRA PEOPLE"
+
+
+def render_prompt(profile: dict[str, Any], case: dict[str, Any]) -> str:
+ template = profile["template"]
+ values = {
+ "prompt": case["prompt"],
+ "hard_constraints": _hard_constraints(case),
+ "natural_scene": f"{case['subject']} {case['action']}; {case['relation']} in {case['scene']}",
+ "subject": case["subject"],
+ "count": case["count"],
+ "action": case["action"],
+ "relation": case["relation"],
+ "scene": case["scene"],
+ "minimal": case["minimal"],
+ }
+ try:
+ return template.format(**values)
+ except KeyError as exc:
+ raise AblationError(f"prompt profile has an unknown field: {exc}") from exc
+
+
+@dataclass(frozen=True)
+class AblationTask:
+ key: str
+ stage: str
+ prompt_profile_id: str
+ parameter_profile_id: str
+ case_id: str
+ seed: int
+ request: dict[str, Any]
+ plan: dict[str, Any]
+
+
+def build_tasks(
+ spec: dict[str, Any],
+ stage: str,
+ *,
+ combos: list[tuple[str, str]] | None = None,
+) -> list[AblationTask]:
+ cases = spec["cases"]
+ profiles = {p["id"]: p for p in spec["prompt_profiles"]}
+ parameters = {p["id"]: p for p in spec["parameter_profiles"]}
+ if stage == "prompt-only":
+ pairs = [(pid, "A0") for pid in ["P0", "P1", "P2", "P3", "P4"]]
+ seeds_key = "seeds"
+ elif stage == "parameter-only":
+ pairs = [("P0", aid) for aid in ["A0", "A1", "A2", "A3"]]
+ seeds_key = "seeds"
+ elif stage == "top-combo-confirmation":
+ pairs = combos or []
+ seeds_key = "confirmation_seeds"
+ if not pairs or len(pairs) > 3:
+ raise AblationError("confirmation needs one to three explicit prompt:parameter combos")
+ else:
+ raise AblationError(f"unknown stage: {stage}")
+ tasks: list[AblationTask] = []
+ for prompt_id, parameter_id in pairs:
+ if prompt_id not in profiles or parameter_id not in parameters:
+ raise AblationError(f"unknown prompt/parameter pair: {prompt_id}:{parameter_id}")
+ for case in cases:
+ for seed in case[seeds_key]:
+ prompt = render_prompt(profiles[prompt_id], case)
+ task_key = f"{stage}:{prompt_id}:{parameter_id}:{case['id']}:{seed}"
+ request = {
+ "schema": "hanclassstudio.teaching_image_request.v1",
+ "asset_id": f"eval-phase2c3-{stage}-{prompt_id}-{parameter_id}-{case['id']}-{seed}",
+ "purpose": "teaching_illustration",
+ "subject": case["subject"],
+ "action": case["action"],
+ "environment": case["scene"],
+ "aspect_ratio": "1:1",
+ "seed": int(seed),
+ "source_trace": [
+ "benchmark:phase2c3-prompt-parameter-ablation",
+ f"stage:{stage}",
+ f"prompt-profile:{prompt_id}",
+ f"parameter-profile:{parameter_id}",
+ f"case:{case['id']}",
+ f"seed:{seed}",
+ ],
+ }
+ plan = {
+ "schema": "hanclassstudio.phase2c3_execution_plan.v1",
+ "task_id": task_key,
+ "request_sha256": sha256_json(request),
+ "stage": stage,
+ "prompt_profile_id": prompt_id,
+ "parameter_profile_id": parameter_id,
+ "case_id": case["id"],
+ "model_revision": spec["model"]["revision"],
+ "positive_prompt": prompt,
+ "negative_prompt": spec["negative_prompt"],
+ "seed": int(seed),
+ "steps": parameters[parameter_id]["steps"],
+ "guidance_scale": parameters[parameter_id]["guidance_scale"],
+ "scheduler": parameters[parameter_id]["scheduler"],
+ "scheduler_class": parameters[parameter_id]["scheduler_class"],
+ "width": parameters[parameter_id]["width"],
+ "height": parameters[parameter_id]["height"],
+ "source_trace": request["source_trace"],
+ }
+ plan["execution_plan_sha256"] = sha256_json(plan)
+ tasks.append(AblationTask(task_key, stage, prompt_id, parameter_id, case["id"], int(seed), request, plan))
+ return tasks
+
+
+def verify_png(payload: bytes, *, width: int, height: int) -> dict[str, Any]:
+ if len(payload) <= 33 or len(payload) > _MAX_PNG_BYTES or not payload.startswith(_PNG_SIGNATURE):
+ raise AblationError("PNG signature or size is invalid")
+ offset = len(_PNG_SIGNATURE)
+ seen_ihdr = seen_idat = seen_iend = False
+ chunks = 0
+ while offset + 12 <= len(payload):
+ length = struct.unpack(">I", payload[offset : offset + 4])[0]
+ data_start = offset + 8
+ data_end = data_start + length
+ crc_end = data_end + 4
+ if crc_end > len(payload):
+ raise AblationError("PNG chunk exceeds payload")
+ name = payload[offset + 4 : offset + 8]
+ data = payload[data_start:data_end]
+ expected_crc = struct.unpack(">I", payload[data_end:crc_end])[0]
+ if zlib.crc32(name + data) & 0xFFFFFFFF != expected_crc:
+ raise AblationError("PNG CRC mismatch")
+ chunks += 1
+ if name == b"IHDR":
+ if seen_ihdr or len(data) != 13:
+ raise AblationError("PNG IHDR is invalid")
+ seen_ihdr = True
+ actual_width, actual_height = struct.unpack(">II", data[:8])
+ if (actual_width, actual_height) != (width, height):
+ raise AblationError("PNG dimensions differ from fixed experiment")
+ elif name == b"IDAT":
+ seen_idat = True
+ elif name == b"IEND":
+ seen_iend = True
+ if crc_end != len(payload):
+ raise AblationError("PNG contains trailing bytes")
+ break
+ offset = crc_end
+ if not (seen_ihdr and seen_idat and seen_iend):
+ raise AblationError("PNG required chunks are missing")
+ return {"width": width, "height": height, "size_bytes": len(payload), "sha256": sha256_bytes(payload), "chunk_count": chunks}
+
+
+def _atomic_write(path: Path, payload: bytes) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
+ temporary.write_bytes(payload)
+ os.replace(temporary, path)
+
+
+class AblationRunner:
+ """One stage state file; failed tasks do not stop later cases."""
+
+ def __init__(self, spec: dict[str, Any], output_dir: Path, state_path: Path, identity: dict[str, Any], tasks: list[AblationTask]) -> None:
+ self.spec = spec
+ self.output_dir = output_dir
+ self.state_path = state_path
+ self.identity = identity
+ self.tasks = tasks
+ self.state = self._load_or_create()
+
+ def _new_state(self) -> dict[str, Any]:
+ return {
+ "schema": STATE_SCHEMA,
+ "experiment_id": self.spec["experiment_id"],
+ "stage": self.tasks[0].stage if self.tasks else "unknown",
+ "spec_sha256": sha256_json(self.spec),
+ "identity": self.identity,
+ "identity_sha256": sha256_json(self.identity),
+ "status": "pending",
+ "created_at": utc_now(),
+ "updated_at": utc_now(),
+ "tasks": {
+ task.key: {
+ "task": task.key,
+ "stage": task.stage,
+ "prompt_profile_id": task.prompt_profile_id,
+ "parameter_profile_id": task.parameter_profile_id,
+ "case_id": task.case_id,
+ "seed": task.seed,
+ "request": task.request,
+ "plan": task.plan,
+ "status": "pending",
+ "attempts": 0,
+ "result": None,
+ "failure": None,
+ }
+ for task in self.tasks
+ },
+ }
+
+ def _load_or_create(self) -> dict[str, Any]:
+ if not self.state_path.exists():
+ state = self._new_state()
+ write_json(self.state_path, state)
+ return state
+ state = read_json(self.state_path)
+ if state.get("schema") != STATE_SCHEMA or state.get("spec_sha256") != sha256_json(self.spec):
+ raise AblationError("state belongs to a different ablation spec")
+ if state.get("identity_sha256") != sha256_json(self.identity):
+ invalidated = dict(state)
+ invalidated["status"] = "invalidated"
+ invalidated["invalidated_at"] = utc_now()
+ invalidated["invalidated_reason"] = "model/runtime/LoRA identity changed"
+ write_json(self.state_path.with_suffix(".invalidated.json"), invalidated)
+ state = self._new_state()
+ state["invalidated_previous_state"] = True
+ write_json(self.state_path, state)
+ return state
+
+ def _save(self) -> None:
+ self.state["updated_at"] = utc_now()
+ write_json(self.state_path, self.state)
+
+ def _artifact_valid(self, record: dict[str, Any]) -> bool:
+ result = record.get("result") or {}
+ image = self.output_dir / result.get("image_path", "")
+ provenance = self.output_dir / result.get("provenance_path", "")
+ return (
+ image.is_file()
+ and provenance.is_file()
+ and sha256_file(image) == result.get("sha256")
+ and sha256_file(provenance) == result.get("provenance_sha256")
+ and result.get("identity_sha256") == sha256_json(self.identity)
+ )
+
+ def _write_manifest(self, result: dict[str, Any], task: AblationTask, provenance: dict[str, Any]) -> None:
+ path = self.output_dir / "asset_manifest.json"
+ manifest = read_json(path) if path.exists() else {"schema": "hanclassstudio.asset_manifest.v1", "images": []}
+ manifest["images"] = [item for item in manifest.get("images", []) if item.get("id") != result["asset_id"]]
+ manifest["images"].append({
+ "id": result["asset_id"],
+ "kind": "image",
+ "path": result["image_path"],
+ "placeholder": False,
+ "mime_type": "image/png",
+ "content_hash": result["sha256"],
+ "review_state": "pending_review",
+ "request_fingerprint": result["request_sha256"],
+ "generation": {
+ "provider": "hcs.phase2c3.diffusers-ablation",
+ "model": self.spec["model"]["id"],
+ "lora": self.spec["lora"]["id"],
+ "prompt_profile_id": task.prompt_profile_id,
+ "parameter_profile_id": task.parameter_profile_id,
+ "prompt": task.plan["positive_prompt"],
+ "seed": task.seed,
+ "source_trace": task.request["source_trace"],
+ },
+ "verified_image_artifact": {
+ "schema": "hanclassstudio.phase2c3_verified_image_artifact.v1",
+ "artifact_id": result["artifact_id"],
+ "asset_id": result["asset_id"],
+ "path": result["image_path"],
+ "mime_type": "image/png",
+ "width": result["width"],
+ "height": result["height"],
+ "size_bytes": result["size_bytes"],
+ "sha256": result["sha256"],
+ "provenance_ref": result["provenance_path"],
+ "provenance_sha256": result["provenance_sha256"],
+ "provenance": provenance,
+ },
+ })
+ write_json(path, manifest)
+
+ def _persist(self, task: AblationTask, rendered: dict[str, Any]) -> dict[str, Any]:
+ payload = rendered.get("png_bytes")
+ if not isinstance(payload, bytes):
+ raise AblationError("executor did not return PNG bytes")
+ technical = rendered.get("technical") or {}
+ if technical.get("near_solid_warning"):
+ raise AblationError("technical precheck rejected a near-solid image")
+ verified = verify_png(payload, width=task.plan["width"], height=task.plan["height"])
+ image_path = self.output_dir / "images" / task.stage / task.prompt_profile_id / task.parameter_profile_id / task.case_id / f"{task.seed}.png"
+ provenance_path = self.output_dir / "provenance" / f"{task.stage}-{task.prompt_profile_id}-{task.parameter_profile_id}-{task.case_id}-{task.seed}.json"
+ provenance = {
+ "schema": "hanclassstudio.phase2c3_provenance.v1",
+ "request": task.request,
+ "plan": task.plan,
+ "identity": self.identity,
+ "technical": {**verified, **technical},
+ "source_trace": task.request["source_trace"],
+ "generated_at": utc_now(),
+ }
+ provenance_bytes = canonical(provenance) + b"\n"
+ _atomic_write(image_path, payload)
+ _atomic_write(provenance_path, provenance_bytes)
+ artifact_seed = f"{task.key}:{verified['sha256']}".encode()
+ artifact_id = f"img-{sha256_bytes(artifact_seed)[:24]}"
+ result = {
+ "task": task.key,
+ "request_sha256": task.plan["request_sha256"],
+ "execution_plan_sha256": task.plan["execution_plan_sha256"],
+ "artifact_id": artifact_id,
+ "asset_id": task.request["asset_id"],
+ "image_path": image_path.relative_to(self.output_dir).as_posix(),
+ "provenance_path": provenance_path.relative_to(self.output_dir).as_posix(),
+ "sha256": verified["sha256"],
+ "size_bytes": verified["size_bytes"],
+ "width": verified["width"],
+ "height": verified["height"],
+ "provenance_sha256": sha256_bytes(provenance_bytes),
+ "identity_sha256": sha256_json(self.identity),
+ "technical": provenance["technical"],
+ "review_status": "pending_review",
+ "failure_tags": [],
+ }
+ self._write_manifest(result, task, provenance)
+ return result
+
+ def run(self, executor: Callable[[AblationTask], dict[str, Any]], *, max_attempts: int = 1, stop_after: int | None = None) -> dict[str, Any]:
+ attempted = 0
+ self.state["status"] = "running"
+ self._save()
+ try:
+ for task in self.tasks:
+ record = self.state["tasks"][task.key]
+ if record["status"] == "succeeded" and self._artifact_valid(record):
+ continue
+ if record["status"] == "succeeded":
+ record["status"] = "pending"
+ record["result"] = None
+ while record["attempts"] < max_attempts:
+ record["attempts"] += 1
+ attempted += 1
+ try:
+ record["result"] = self._persist(task, executor(task))
+ record["status"] = "succeeded"
+ record["failure"] = None
+ self._save()
+ break
+ except Exception as exc: # noqa: BLE001 - isolate one case
+ record["status"] = "failed"
+ record["failure"] = {"type": type(exc).__name__, "message": str(exc), "at": utc_now()}
+ self._save()
+ if stop_after is not None and attempted >= stop_after:
+ self.state["status"] = "paused"
+ self._save()
+ return self.state
+ except KeyboardInterrupt:
+ self.state["status"] = "paused"
+ self._save()
+ return self.state
+ self.state["status"] = "completed" if all(item["status"] == "succeeded" for item in self.state["tasks"].values()) else "completed_with_failures"
+ self._save()
+ return self.state
+
+
+def _state_files(output_dir: Path) -> list[Path]:
+ return (
+ sorted(path for path in (output_dir / "state").glob("*.json") if not path.name.endswith(".invalidated.json"))
+ if (output_dir / "state").is_dir()
+ else []
+ )
+
+
+def all_records(output_dir: Path) -> list[dict[str, Any]]:
+ records: list[dict[str, Any]] = []
+ for path in _state_files(output_dir):
+ state = read_json(path)
+ records.extend(state.get("tasks", {}).values())
+ return records
+
+
+def aggregate_report(spec: dict[str, Any], output_dir: Path, *, selection: dict[str, Any] | None = None) -> dict[str, Any]:
+ records = all_records(output_dir)
+ report: dict[str, Any] = {
+ "schema": "hanclassstudio.phase2c3_prompt_parameter_ablation_report.v1",
+ "experiment_id": spec["experiment_id"],
+ "spec_sha256": sha256_json(spec),
+ "total": len(records),
+ "succeeded": sum(r.get("status") == "succeeded" for r in records),
+ "failed": sum(r.get("status") == "failed" for r in records),
+ "pending": sum(r.get("status") not in {"succeeded", "failed"} for r in records),
+ "teacher_review": {"pending": sum(r.get("status") == "succeeded" for r in records), "reviewed": 0, "automatic_scores": False, "conclusion": None},
+ "prompt_profiles": {},
+ "parameter_profiles": {},
+ "stages": {},
+ "selection": selection,
+ "limitations": ["Technical checks and engineering observations are not teacher conclusions.", "No automatic better_than_baseline or teaching_usable values are produced."],
+ }
+ for record in records:
+ stage = record.get("stage")
+ report["stages"].setdefault(stage, {"total": 0, "succeeded": 0, "failed": 0})
+ report["stages"][stage]["total"] += 1
+ report["stages"][stage][record.get("status")] = report["stages"][stage].get(record.get("status"), 0) + 1
+ if record.get("status") != "succeeded" or not record.get("result"):
+ continue
+ result = record["result"]
+ technical = result.get("technical", {})
+ for key, value in (("prompt_profiles", record.get("prompt_profile_id")), ("parameter_profiles", record.get("parameter_profile_id"))):
+ bucket = report[key].setdefault(value, {"total": 0, "succeeded": 0, "durations": [], "near_solid_warnings": 0})
+ bucket["total"] += 1
+ bucket["succeeded"] += 1
+ if technical.get("duration_seconds") is not None:
+ bucket["durations"].append(technical["duration_seconds"])
+ bucket["near_solid_warnings"] += int(bool(technical.get("near_solid_warning")))
+ for bucket_group in (report["prompt_profiles"], report["parameter_profiles"]):
+ for bucket in bucket_group.values():
+ durations = bucket.pop("durations")
+ bucket["mean_duration_seconds"] = sum(durations) / len(durations) if durations else None
+ write_json(output_dir / "ablation-report.json", report)
+ return report
+
+
+def write_review_package(spec: dict[str, Any], output_dir: Path) -> Path:
+ package = output_dir / "review-package"
+ images_dir = package / "images"
+ images_dir.mkdir(parents=True, exist_ok=True)
+ fields = ["stage", "prompt_profile_id", "parameter_profile_id", "case_id", "seed", "asset_id", "artifact_id", "review_status", "better_than_baseline", "teaching_usable", "wrong_count", "wrong_action", "wrong_spatial_relation", "extra_person", "missing_object", "style_acceptable", "reviewer_notes"]
+ reviews: list[dict[str, Any]] = []
+ manifest: list[dict[str, Any]] = []
+ for record in all_records(output_dir):
+ if record.get("status") != "succeeded" or not record.get("result"):
+ continue
+ result = record["result"]
+ source = output_dir / result["image_path"]
+ name = f"{record['stage']}-{record['prompt_profile_id']}-{record['parameter_profile_id']}-{record['case_id']}-{record['seed']}.png"
+ if source.is_file():
+ shutil.copy2(source, images_dir / name)
+ review = {
+ "schema": REVIEW_SCHEMA,
+ "stage": record["stage"],
+ "prompt_profile_id": record["prompt_profile_id"],
+ "parameter_profile_id": record["parameter_profile_id"],
+ "case_id": record["case_id"],
+ "seed": record["seed"],
+ "asset_id": result["asset_id"],
+ "artifact_id": result["artifact_id"],
+ "image_path": f"images/{name}",
+ "review_status": "pending_review",
+ **{field: None for field in fields[8:-1]},
+ "reviewer_notes": "",
+ }
+ reviews.append(review)
+ manifest.append({"task": record["task"], "result": result, "request": record["request"], "plan": record["plan"]})
+ write_json(package / "teacher-reviews.pending.json", {"schema": REVIEW_SCHEMA, "reviews": reviews})
+ with (package / "teacher-reviews.csv").open("w", newline="", encoding="utf-8") as handle:
+ writer = csv.DictWriter(handle, fieldnames=fields)
+ writer.writeheader()
+ for review in reviews:
+ writer.writerow({key: json.dumps(review[key], ensure_ascii=False) if isinstance(review[key], list) else review[key] for key in fields})
+ write_json(package / "experiment-manifest.json", {"schema": SCHEMA, "spec": spec, "records": manifest})
+ cards = []
+ for review in reviews:
+ cards.append(
+ f" {review['artifact_id']}{review['stage']} / {review['prompt_profile_id']} / {review['parameter_profile_id']} / {review['case_id']} / seed {review['seed']}
"
+ f"
Technical checks are separate from teacher review. Every review field is pending.