← Files Auto Preference LearnerARCHIVED FILE

skills/auto-preference-learner/scripts/aggregate_global_candidates.py

12.8 KB · Oct 4, 2026 · 12:29 UTC

↓ Download file

#!/usr/bin/env python3
from __future__ import annotations

import argparse
from pathlib import Path
from typing import Any

from _common import load_json, normalize_text, stable_id, write_json_output
from reconcile_agents import MUTATING_ACTIONS, coverage_errors, ensure_decision_ids, evidence_ref_scopes, path_key


VALID_GLOBAL_STATUSES = {"candidate", "project-only", "already-global"}
VALID_OUTCOMES = {"promote", "keep", "reject", "already-global"}


class GlobalAggregationError(ValueError):
    pass


def candidate_key(instruction: str, semantic_scope: str) -> str:
    return stable_id(normalize_text(instruction), normalize_text(semantic_scope), prefix="G-")


def _pending_candidates(state: dict[str, Any]) -> list[dict[str, Any]]:
    value = state.get("pending_global_candidates", {})
    items = value.values() if isinstance(value, dict) else value if isinstance(value, list) else []
    result: list[dict[str, Any]] = []
    for raw in items:
        if not isinstance(raw, dict):
            raise GlobalAggregationError("pending global candidate must be an object")
        candidate = dict(raw)
        identifier = str(candidate.get("candidate_id") or "").strip()
        instruction = str(candidate.get("instruction") or "").strip()
        scope = str(candidate.get("semantic_scope") or "").strip()
        sources = candidate.get("sources")
        if not identifier or identifier != candidate_key(instruction, scope) or not isinstance(sources, list) or not sources:
            raise GlobalAggregationError(f"invalid pending global candidate: {identifier or '<missing>'}")
        candidate["sources"] = [{**source, "current": False} for source in sources if isinstance(source, dict)]
        result.append(candidate)
    return result


def prepare_bundle(decisions_value: Any, batch: dict[str, Any], state: dict[str, Any] | None = None) -> dict[str, Any]:
    raw_decisions = decisions_value.get("decisions") if isinstance(decisions_value, dict) else decisions_value
    if not isinstance(raw_decisions, list):
        raise GlobalAggregationError("project decisions must be an array or an object containing decisions")
    decisions = ensure_decision_ids(raw_decisions)
    ref_scopes = evidence_ref_scopes(batch)
    errors = coverage_errors(decisions, batch, ref_scopes)
    if errors:
        raise GlobalAggregationError("project review is incomplete: " + "; ".join(item["error"] for item in errors))

    candidates = {item["candidate_id"]: item for item in _pending_candidates(state or {})}
    for decision in decisions:
        identifier = str(decision["decision_id"])
        refs = [str(value) for value in decision.get("evidence_refs", [])]
        if decision.get("target") == "project" and str(decision.get("project_root") or "").strip():
            project_root: str | None = str(Path(str(decision["project_root"])).expanduser().resolve())
            target_scope = path_key(Path(project_root))
            if any(not ref_scopes.get(ref) or target_scope not in ref_scopes[ref] for ref in refs):
                raise GlobalAggregationError(f"project review decision {identifier} uses evidence from another project")
        elif decision.get("target") == "global" and decision.get("project_root") is None:
            project_root = None
            if any(ref not in ref_scopes or ref_scopes[ref] for ref in refs):
                raise GlobalAggregationError(f"global-context decision {identifier} requires unscoped evidence")
        else:
            raise GlobalAggregationError(f"review decision {identifier} has an invalid target scope")
        disposition = decision.get("global_disposition")
        if not isinstance(disposition, dict) or disposition.get("status") not in VALID_GLOBAL_STATUSES:
            raise GlobalAggregationError(f"project review decision {identifier} requires a valid global_disposition")
        status = str(disposition["status"])
        if status != "candidate":
            if project_root is None and decision.get("action") in MUTATING_ACTIONS:
                raise GlobalAggregationError(
                    f"mutating global-context decision {identifier} must enter global aggregation as a candidate"
                )
            if not str(disposition.get("reason") or "").strip():
                raise GlobalAggregationError(f"global disposition for {identifier} requires a reason")
            continue
        instruction = str(disposition.get("instruction") or "").strip()
        semantic_scope = str(disposition.get("semantic_scope") or "").strip()
        reason = str(disposition.get("reason") or "").strip()
        explicit = disposition.get("explicit_global_intent")
        if not instruction or not semantic_scope or not reason or not isinstance(explicit, bool):
            raise GlobalAggregationError(
                f"global candidate for {identifier} requires instruction, semantic_scope, reason, and explicit_global_intent"
            )
        if project_root is None and explicit is not True:
            raise GlobalAggregationError(f"global-context candidate for {identifier} requires explicit_global_intent")
        candidate_id = candidate_key(instruction, semantic_scope)
        candidate = candidates.setdefault(
            candidate_id,
            {
                "candidate_id": candidate_id,
                "instruction": instruction,
                "semantic_scope": semantic_scope,
                "reason": reason,
                "explicit_global_intent": explicit,
                "sources": [],
            },
        )
        candidate["explicit_global_intent"] = bool(candidate.get("explicit_global_intent")) or explicit
        source = {
            "decision_id": identifier,
            "project_root": project_root,
            "evidence_refs": refs,
            "local_action": decision.get("action"),
            "current": True,
        }
        existing_source_keys = {
            (
                str(item.get("decision_id")),
                path_key(Path(str(item.get("project_root")))) if item.get("project_root") else "",
            )
            for item in candidate["sources"]
            if isinstance(item, dict)
        }
        source_scope = path_key(Path(project_root)) if project_root else ""
        if (identifier, source_scope) not in existing_source_keys:
            candidate["sources"].append(source)

    return {
        "version": 1,
        "project_decisions": decisions,
        "global_candidates": sorted(candidates.values(), key=lambda item: item["candidate_id"]),
    }


def _source_projects(candidates: list[dict[str, Any]]) -> set[str]:
    return {
        path_key(Path(str(source["project_root"])))
        for candidate in candidates
        for source in candidate.get("sources", [])
        if isinstance(source, dict) and source.get("project_root")
    }


def finalize_bundle(bundle: dict[str, Any], global_review: dict[str, Any]) -> dict[str, Any]:
    project_decisions = bundle.get("project_decisions")
    candidates_value = bundle.get("global_candidates")
    groups = global_review.get("groups") if isinstance(global_review, dict) else None
    if not isinstance(project_decisions, list) or not isinstance(candidates_value, list) or not isinstance(groups, list):
        raise GlobalAggregationError("bundle and global review have invalid structure")
    candidates = {str(item.get("candidate_id")): item for item in candidates_value if isinstance(item, dict)}
    if len(candidates) != len(candidates_value) or not all(candidates):
        raise GlobalAggregationError("bundle has a missing or duplicate candidate_id")

    seen: set[str] = set()
    promoted: list[dict[str, Any]] = []
    pending: dict[str, dict[str, Any]] = {}
    suppressed_source_decisions = {
        str(source.get("decision_id"))
        for candidate in candidates.values()
        for source in candidate.get("sources", [])
        if isinstance(source, dict) and source.get("current") is True and source.get("project_root") is None
    }
    for group in groups:
        if not isinstance(group, dict) or group.get("outcome") not in VALID_OUTCOMES:
            raise GlobalAggregationError("each global review group requires a valid outcome")
        ids = [str(value) for value in group.get("candidate_ids", [])]
        if not ids or len(ids) != len(set(ids)):
            raise GlobalAggregationError("each global review group requires unique candidate_ids")
        unknown = set(ids) - set(candidates)
        reused = set(ids) & seen
        if unknown or reused:
            raise GlobalAggregationError(f"global review has unknown or repeated candidate_ids: {sorted(unknown | reused)}")
        seen.update(ids)
        members = [candidates[identifier] for identifier in ids]
        outcome = str(group["outcome"])
        if outcome == "keep":
            for candidate in members:
                stored = dict(candidate)
                stored["sources"] = [
                    {key: value for key, value in source.items() if key != "current"}
                    for source in candidate.get("sources", [])
                    if isinstance(source, dict)
                ]
                pending[stored["candidate_id"]] = stored
            continue
        if outcome != "promote":
            continue
        source_projects = _source_projects(members)
        explicit = any(bool(item.get("explicit_global_intent")) for item in members)
        if len(source_projects) < 2 and not explicit:
            raise GlobalAggregationError("global promotion requires two project roots or explicit global intent")
        decision = group.get("decision")
        if not isinstance(decision, dict):
            raise GlobalAggregationError("promote outcome requires a decision")
        decision = dict(decision)
        if decision.get("target") != "global" or decision.get("action") not in MUTATING_ACTIONS:
            raise GlobalAggregationError("promoted decision must be a mutating global decision")
        current_refs = sorted(
            {
                str(ref)
                for candidate in members
                for source in candidate.get("sources", [])
                if isinstance(source, dict) and source.get("current") is True
                for ref in source.get("evidence_refs", [])
            }
        )
        if not current_refs:
            raise GlobalAggregationError("global promotion requires evidence from the current batch")
        decision["evidence_refs"] = current_refs
        decision["global_candidate_ids"] = ids
        decision["global_evidence_project_roots"] = sorted(source_projects)
        decision["explicit_global_intent"] = explicit
        promoted.append(decision)
        suppressed_source_decisions.update(
            str(source.get("decision_id"))
            for candidate in members
            for source in candidate.get("sources", [])
            if isinstance(source, dict) and source.get("current") is True
        )

    missing = set(candidates) - seen
    if missing:
        raise GlobalAggregationError(f"global review did not disposition every candidate: {sorted(missing)}")

    final_project_decisions: list[dict[str, Any]] = []
    for original in project_decisions:
        decision = dict(original)
        if decision.get("decision_id") in suppressed_source_decisions and decision.get("action") in MUTATING_ACTIONS:
            decision["action"] = "no-op"
            decision["explanation"] = "source mutation is deferred to the completed global aggregation"
        final_project_decisions.append(decision)
    return {
        "review_protocol": "project-then-global-v1",
        "decisions": [*final_project_decisions, *promoted],
        "pending_global_candidates": pending,
    }


def main() -> int:
    parser = argparse.ArgumentParser(description="Prepare and finalize the mandatory cross-project global-candidate review.")
    subparsers = parser.add_subparsers(dest="command", required=True)
    prepare = subparsers.add_parser("prepare")
    prepare.add_argument("--decisions", required=True)
    prepare.add_argument("--batch", required=True)
    prepare.add_argument("--state")
    prepare.add_argument("--output")
    finalize = subparsers.add_parser("finalize")
    finalize.add_argument("--bundle", required=True)
    finalize.add_argument("--global-review", required=True)
    finalize.add_argument("--output")
    args = parser.parse_args()
    if args.command == "prepare":
        value = prepare_bundle(
            load_json(Path(args.decisions).expanduser().resolve(), []),
            load_json(Path(args.batch).expanduser().resolve(), {}),
            load_json(Path(args.state).expanduser().resolve(), {}) if args.state else {},
        )
    else:
        value = finalize_bundle(
            load_json(Path(args.bundle).expanduser().resolve(), {}),
            load_json(Path(args.global_review).expanduser().resolve(), {}),
        )
    write_json_output(value, args.output)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: 0c70a65c96f9a39f607c90267d56abe93bf6e744640af3fc94169a8d89ab2978