← Files VeraARCHIVED FILE

modules/business-planning/scripts/planning_cycle.py

7.09 KB · Oct 2, 2026 · 00:29 UTC

↓ Download file

"""Preserve planning revisions and mechanically detect unexamined carry-over.

Snapshot identity and exact input differences are reproducible. Whether the new
evidence changes a business decision is deliberately left to the model.
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

from planning_workflow import digest, indexed, require, reviewed

__all__ = ["review_cycle", "narrative_groups"]


def narrative_groups(
    groups: dict[str, Any], case: dict[str, Any], available: set[str], prefix: str
) -> list[str]:
    """Check declared narrative references without interpreting the prose."""
    all_ids = set(indexed(case["narrative"], "narrative"))
    issues = []
    for name, ids in groups.items():
        require(
            isinstance(ids, list)
            and all(isinstance(i, str) for i in ids)
            and len(ids) == len(set(ids))
            and set(ids) <= all_ids,
            f"Invalid {prefix} narrative references: {name}",
        )
        if not ids or not set(ids) <= available:
            issues.append(f"{prefix} {name} is incomplete or contains a withheld claim")
    return issues


def _changes(previous: dict[str, Any], case: dict[str, Any]) -> list[dict[str, Any]]:
    """Compare authored business inputs, excluding review metadata and prose."""
    changes = []
    for field in (
        "entity_name",
        "company_stage",
        "planning_objective",
        "reporting_currency",
        "periods",
        "sources",
        "evidence",
        "assumptions",
        "decisions",
        "observations",
        "resolutions",
        "financial",
        "commercial",
        "financing",
    ):
        before, after = previous.get(field), case.get(field)
        if field == "sources":
            before = [s for s in before if s["role"] != "prior_plan"]
            after = [s for s in after if s["role"] != "prior_plan"]
        if before != after:
            changes.append({"field": field, "before": before, "after": after})
    return changes


def review_cycle(
    case: dict[str, Any], source_root: Path, narrative: list[dict[str, Any]]
) -> tuple[dict[str, Any] | None, list[str], set[str]]:
    """Load a registered parent snapshot and withhold unreconsidered conclusions."""
    cycle = case.get("cycle")
    if cycle is None:
        return (
            None,
            ["Planning cycle missing: record the question and next test"],
            set(),
        )
    require(
        set(cycle)
        == {
            "id",
            "parent_source_id",
            "question",
            "trigger_ids",
            "analysis_ids",
            "decision_ids",
            "next_test_ids",
            "reopen_when_ids",
            "reassessed_ids",
        },
        "Unexpected planning cycle fields",
    )
    for field in ("id", "question"):
        require(
            isinstance(cycle[field], str) and bool(cycle[field].strip()),
            f"Planning cycle {field} required",
        )
    available = {n["id"] for n in narrative}
    groups = {
        k: cycle[k]
        for k in ("analysis_ids", "decision_ids", "next_test_ids", "reopen_when_ids")
    }
    issues = narrative_groups(groups, case, available, "Planning cycle")
    refs = set(indexed([*case["evidence"], *case["assumptions"]], "basis"))
    require(
        isinstance(cycle["trigger_ids"], list)
        and all(isinstance(i, str) for i in cycle["trigger_ids"])
        and set(cycle["trigger_ids"]) <= refs,
        "Unknown cycle evidence",
    )
    require(bool(cycle["trigger_ids"]), "Cycle needs evidence or a labelled hypothesis")
    reassessed = cycle["reassessed_ids"]
    require(
        isinstance(reassessed, list)
        and all(isinstance(i, str) for i in reassessed)
        and len(reassessed) == len(set(reassessed))
        and set(reassessed) <= set(indexed(case["narrative"], "narrative")),
        "Invalid reconsidered narrative IDs",
    )
    parent_id = cycle["parent_source_id"]
    prior_sources = {s["id"]: s for s in case["sources"] if s["role"] == "prior_plan"}
    require(
        set(prior_sources) == ({parent_id} if parent_id is not None else set()),
        "Register exactly the selected prior plan for this cycle",
    )
    changes: list[dict[str, Any]] = []
    stale: set[str] = set()
    history = []
    parent_hash = None
    if parent_id is not None:
        source = prior_sources[parent_id]
        previous = json.loads(
            (source_root / source["path"]).read_text(encoding="utf-8")
        )
        require(
            isinstance(previous, dict)
            and previous.get("schema_version") == "mparanza.business_planning_plan.v3",
            "Parent must be a shared business plan snapshot",
        )
        require(
            previous.get("content_sha256")
            == digest({k: v for k, v in previous.items() if k != "content_sha256"}),
            "Prior plan snapshot hash mismatch",
        )
        previous_case = previous["case"]
        require(
            previous.get("case_sha256") == digest(previous_case),
            "Prior case snapshot hash mismatch",
        )
        require(
            previous_case["case_id"] == case["case_id"],
            "Prior plan belongs to another case",
        )
        # A renamed parent file cannot launder its embedded source restrictions.
        from copy import deepcopy

        from planning_report import _check_audience

        parent_permissions = deepcopy(previous_case)
        parent_permissions["audience"] = case["audience"]
        parent_permissions["decisions"].extend(case["decisions"])
        _check_audience(parent_permissions)
        parent_hash = previous["content_sha256"]
        prior_cycle = previous.get("planning_cycle")
        if prior_cycle:
            history = [
                *prior_cycle["history"],
                {
                    "id": previous_case["cycle"]["id"],
                    "question": previous_case["cycle"]["question"],
                    "content_sha256": parent_hash,
                },
            ]
        require(
            cycle["id"] not in {h["id"] for h in history},
            "Cycle ID already exists in this history",
        )
        changes = _changes(previous_case, case)
        if changes:
            if case["review"] == previous_case["review"] and reviewed(case["review"]):
                issues.append(
                    "Changed planning basis requires renewed professional review"
                )
                for entry in narrative:
                    entry["provisional"] = True
            prior_ids = set(indexed(previous_case["narrative"], "prior narrative"))
            stale = prior_ids & set(indexed(case["narrative"], "narrative")) - set(
                reassessed
            )
            if stale:
                issues.append(
                    "Changed planning basis requires reconsideration: "
                    + ", ".join(sorted(stale))
                )
    return (
        {
            "parent_content_sha256": parent_hash,
            "history": history,
            "changes": changes,
            "withheld_narrative_ids": sorted(stale),
        },
        issues,
        stale,
    )

SHA-256: 86ea90f4c52b8b00bd012e6da49d08b337a3a8aac3ac8be072b648e8076b991e