from __future__ import annotations import hashlib import json import uuid from datetime import datetime, timezone from pathlib import Path from typing import Any BASE_MODEL = "black-forest-labs/FLUX.2-klein-base-4B" def load_registry(registry_path: str | Path = "registry/loras.json") -> dict[str, Any]: with open(registry_path, "r", encoding="utf-8") as f: return json.load(f) def lora_map(registry: dict[str, Any]) -> dict[str, dict[str, Any]]: return {entry["id"]: entry for entry in registry.get("loras", [])} def expand_ancestry( lora_id: str, weight: float, registry_by_id: dict[str, dict[str, Any]], *, max_depth: int = 8, _depth: int = 0, _seen: set[str] | None = None, ) -> dict[str, Any]: """Expand a LoRA node into a provenance tree. Defaults to 8 levels: effectively complete for the hackathon, but bounded. Circular references are marked instead of recursing forever. """ seen = set() if _seen is None else set(_seen) if lora_id not in registry_by_id: raise KeyError(f"Unknown LoRA id: {lora_id}") lora = registry_by_id[lora_id] node = { "lora_id": lora["id"], "weight": round(float(weight), 4), "weight_pct": round(float(weight) * 100, 2), "creator": lora.get("creator"), "cultural_source": lora.get("cultural_source"), "hf_repo": lora.get("hf_repo"), "checkpoint_step": lora.get("checkpoint_step"), "type": lora.get("type"), "parent_ids": lora.get("parent_ids", []), "status": lora.get("status", "unknown"), "children": [], } if lora_id in seen: node["cycle_detected"] = True return node if _depth >= max_depth: node["max_depth_reached"] = True return node seen.add(lora_id) parent_ids = lora.get("parent_ids", []) if parent_ids: parent_weight = float(weight) / len(parent_ids) node["children"] = [ expand_ancestry( parent_id, parent_weight, registry_by_id, max_depth=max_depth, _depth=_depth + 1, _seen=seen, ) for parent_id in parent_ids if parent_id in registry_by_id ] return node def normalize_weights(weights: list[float]) -> list[float]: total = sum(weights) if total <= 0: raise ValueError("Blend weights must sum to a positive number") return [w / total for w in weights] def file_sha256(path: str | Path | None) -> str | None: if path is None: return None p = Path(path) if not p.exists() or not p.is_file(): return None h = hashlib.sha256() with p.open("rb") as f: for chunk in iter(lambda: f.read(1024 * 1024), b""): h.update(chunk) return "sha256:" + h.hexdigest() def build_provenance( lora_ids: list[str], weights: list[float], registry: dict[str, Any], prompt: str, seed: int, output_path: str | None = None, *, max_depth: int = 8, ) -> dict[str, Any]: if len(lora_ids) != len(weights): raise ValueError("lora_ids and weights must be the same length") weights = normalize_weights(weights) by_id = lora_map(registry) ancestry = [ expand_ancestry(lora_id, weight, by_id, max_depth=max_depth) for lora_id, weight in zip(lora_ids, weights) ] return { "generation_id": str(uuid.uuid4()), "timestamp": datetime.now(timezone.utc).isoformat(), "prompt": prompt, "seed": seed, "base_model": BASE_MODEL, "ancestry": ancestry, "image_hash": file_sha256(output_path), "output_path": output_path, } def provenance_sentence(provenance: dict[str, Any]) -> str: parts = [ f"{node['weight_pct']:g}% {node['cultural_source']}" for node in provenance.get("ancestry", []) ] if not parts: return "No lineage data available yet." return "This image blends " + " and ".join(parts) + "."