#!/usr/bin/env python3 """research_task_tree.py — canonical research process state (RP1/RP2). The ResearchTaskTree is the single source of truth for a research process: which stage is current, which artifacts are accepted, which questions are blocking. It persists state in the research artifact directory and syncs to Gitea issue labels. The tree is the authority. The TUI, MiMo capsules, and Gitea comments are projections of this state — not independent truths. Canonical pipeline (handoff 2026-09-08 — supersedes RSC0-9 stage design): ISSUE → INWARD → OUTWARD → APPLICABILITY → optional INNOVATION → ADVERSARIAL → SYNTHESIS → QUALIFY ISSUE replaces DEFINE: the Gitea issue is the authoritative statement of intent; a deterministic issue-quality gate checks it is buildable, but there is no cognitive DEFINE stage. OUTWARD is ONE unified natural MiMo research task (OUTWARD_DISCOVERY/OUTWARD_READING are retired as stages; their concepts survive only as artifact subsections). Stage transitions: StageCompleted(current) → advance to next stage StageFailed(current, reason) → retry or escalate REFINE(unknowns) → targeted return to the earliest deficient stage (no full-pipeline restart; INWARD is not rerun unless Rune reality changed) """ from __future__ import annotations import json import os import time from pathlib import Path from typing import Any STAGES = [ "ISSUE", "INWARD", "OUTWARD", "APPLICABILITY", "INNOVATION", "ADVERSARIAL", "SYNTHESIS", "QUALIFY", ] # Legacy stage names that must never become READY through the canonical path. RETIRED_STAGES = {"DEFINE", "OUTWARD_DISCOVERY", "OUTWARD_READING"} STAGE_ARTIFACT_FILES = { "ISSUE": "issue_gate.json", "INWARD": "inward.json", "OUTWARD": "outward.json", "APPLICABILITY": "applicability.json", "ADVERSARIAL": "adversarial.json", "INNOVATION": "innovation.json", "SYNTHESIS": "synthesis.json", "QUALIFY": "qualify.json", } # Candidate classifications for APPLICABILITY (§9 of canonical handoff). APPLICABILITY_CLASSES = { "DIRECTLY_APPLICABLE", "APPLICABLE_WITH_ADAPTATION", "CONFLICTS_WITH_RUNE", "TOO_COMPLEX_FOR_VALUE", "INSUFFICIENT_EVIDENCE", "PROMISING_FOR_INNOVATION", } # APPLICABILITY routing decision (conditional INNOVATION correction). # The default is SKIP — INNOVATION runs only when warranted. APPLICABILITY_ROUTES = { "STANDARD_SOLUTION_SUFFICIENT", # → ADVERSARIAL (INNOVATION skipped) "INNOVATION_WARRANTED", # → INNOVATION → ADVERSARIAL } # Valid INNOVATION conclusions. NO_INNOVATION_WARRANTED is a SUCCESS result. INNOVATION_CONCLUSIONS = { "ADOPT", "ADAPT", "BORROW", "BUILD", "NO_INNOVATION_WARRANTED", } # ── ADVERSARIAL disposition contract (pipeline correction 2026-09-09) ────── # ADVERSARIAL owns epistemic refinement; QUALIFY owns implementation-readiness. # Only SURVIVES* dispositions unlock SYNTHESIS. Everything else triggers # targeted refinement at the earliest stage that can repair the defect. ADVERSARIAL_DISPOSITIONS = { "SURVIVES", # → SYNTHESIS READY "SURVIVES_WITH_MITIGATIONS", # → SYNTHESIS READY (mitigations are required inputs) "EVIDENCE_DEFICIENT", # → targeted OUTWARD → APPLICABILITY → ADVERSARIAL "APPLICABILITY_DEFICIENT", # → APPLICABILITY → ADVERSARIAL "INNOVATION_WARRANTED", # → INNOVATION → ADVERSARIAL "NO_VIABLE_CANDIDATE", # → targeted OUTWARD (expand candidate space) } SYNTHESIS_UNLOCKING_DISPOSITIONS = {"SURVIVES", "SURVIVES_WITH_MITIGATIONS"} # Targeted refinement routes per disposition (§4 of the correction). ADVERSARIAL_REFINEMENT_ROUTES = { "EVIDENCE_DEFICIENT": "OUTWARD", "APPLICABILITY_DEFICIENT": "APPLICABILITY", "INNOVATION_WARRANTED": "INNOVATION", "NO_VIABLE_CANDIDATE": "OUTWARD", } # QUALIFY outcomes (narrowed semantics, §8–10 of the correction). QUALIFY_OUTCOMES = { "PROCEED", # implementation-ready → MiMo Code "REFINE_SYNTHESIS", # build instructions weak → SYNTHESIS only "REOPEN_EARLIER_STAGE", # exceptional: genuinely new blocking fact } class ResearchTaskTree: """Canonical research process state. One instance per research issue.""" def __init__(self, issue: int, round_no: int = 1, artifact_dir: str = ""): self.issue = issue self.round = round_no self.artifact_dir = artifact_dir or f"research/issue-{issue}" self.state = self._load() def _state_file(self) -> str: return os.path.join(self.artifact_dir, "task_tree.json") def _lock_file(self) -> str: return os.path.join(self.artifact_dir, "process.lock") def acquire_process_lock(self) -> bool: """Idempotency guard: only one active ResearchProcess per issue. Uses a lock file with TTL — stale locks from crashed workers expire.""" lock_path = Path(self._lock_file()) lock_path.parent.mkdir(parents=True, exist_ok=True) if lock_path.exists(): age = time.time() - lock_path.stat().st_mtime ttl = int(os.environ.get("RESEARCH_LOCK_TTL", "7200")) # 2h default if age < ttl: return False # another process holds the lock lock_path.unlink() # stale — take over lock_path.write_text(f"pid={os.getpid()} acquired={time.time():.0f}") return True def release_process_lock(self) -> None: lock_path = Path(self._lock_file()) if lock_path.exists(): lock_path.unlink() def _load(self) -> dict: path = os.path.join(self.artifact_dir, "task_tree.json") if os.path.exists(path): with open(path) as f: state = json.load(f) self.round = state.get("round", self.round) return state return { "issue": self.issue, "round": self.round, "current_stage": STAGES[0], "completed_stages": [], "artifacts": {}, # stage -> artifact dict "blocking_unknowns": [], "status": "ACTIVE", # RUNNING | STALLED | COMPLETE | FAILED "stage_history": [], } def save(self) -> None: os.makedirs(self.artifact_dir, exist_ok=True) path = os.path.join(self.artifact_dir, "task_tree.json") with open(path, "w") as f: json.dump(self.state, f, indent=2) @property def current_stage(self) -> str: return self.state["current_stage"] @property def is_complete(self) -> bool: return self.state["status"] == "COMPLETE" def advance(self, artifact: dict) -> str: """Accept the current stage's artifact, advance to the next stage. Returns the next stage name. Raises if already at QUALIFY. Retired stage names cannot be advanced (cannot become READY).""" stage = self.state["current_stage"] if stage in RETIRED_STAGES: raise ValueError( f"Stage '{stage}' is retired (canonical handoff 2026-09-08); " f"it cannot be executed or advanced. Canonical stages: {STAGES}" ) if stage not in self.state["completed_stages"] or stage != "QUALIFY": self.state["completed_stages"].append(stage) self.state["artifacts"][stage] = artifact self.state["stage_history"].append( { "stage": stage, "round": self.round, "completed_at": "auto", "artifact_keys": list(artifact.keys()), } ) # Check if we're in a targeted branch targeted = self.state.get("targeted_branch_stages", []) if targeted and stage in targeted: # In a targeted branch: advance within the branch t_idx = targeted.index(stage) if t_idx < len(targeted) - 1: self.state["current_stage"] = targeted[t_idx + 1] else: # Targeted branch complete → return to SYNTHESIS v2 self.state["current_stage"] = self.state.get("targeted_return_stage", "SYNTHESIS") self.state["targeted_branch_stages"] = [] else: # Normal pipeline: advance to next stage in order — with # stage-specific routing decisions: if stage == "APPLICABILITY": # APPLICABILITY decides whether INNOVATION runs (conditional). self.state["current_stage"] = self._route_after_applicability(artifact) elif stage == "ADVERSARIAL": # ADVERSARIAL is the FINAL EPISTEMIC GATE (correction 2026-09-09): # its disposition decides whether SYNTHESIS unlocks or refinement # routes to the earliest repairable stage. self.state["current_stage"] = self._route_after_adversarial(artifact) elif stage == "QUALIFY": self.state["current_stage"] = self._route_after_qualify(artifact) else: idx = STAGES.index(stage) if idx < len(STAGES) - 1: self.state["current_stage"] = STAGES[idx + 1] else: self.state["current_stage"] = "QUALIFY" self.save() return self.state["current_stage"] def _route_after_adversarial(self, artifact: dict) -> str: """ADVERSARIAL disposition routing (correction §3–4). SURVIVES / SURVIVES_WITH_MITIGATIONS → SYNTHESIS (gated by the readiness invariant). Everything else routes to the earliest stage that can repair the defect. Raises on missing/invalid disposition — an adversarial pass without a disposition cannot unlock anything. """ disposition = artifact.get("disposition") if disposition not in ADVERSARIAL_DISPOSITIONS: raise ValueError( f"ADVERSARIAL artifact must carry a valid disposition " f"({sorted(ADVERSARIAL_DISPOSITIONS)}), got: {disposition!r}" ) self.state["adversarial_disposition"] = disposition if disposition in SYNTHESIS_UNLOCKING_DISPOSITIONS: ok, problems = self.synthesis_readiness(artifact) if not ok: raise ValueError("SYNTHESIS readiness invariant failed: " + "; ".join(problems)) return "SYNTHESIS" return ADVERSARIAL_REFINEMENT_ROUTES[disposition] def _route_after_qualify(self, artifact: dict) -> str: """Narrowed QUALIFY routing (correction §8–10). QUALIFY owns IMPLEMENTATION-READINESS, not epistemic refinement. PROCEED → complete (implementation handoff) REFINE_SYNTHESIS → SYNTHESIS only (never an OUTWARD pass) REOPEN_EARLIER_STAGE → earliest affected stage, ONLY with a genuinely new blocking fact + why earlier gates couldn't have caught it (exceptional escape hatch) """ outcome = artifact.get("outcome", "PROCEED") if outcome not in QUALIFY_OUTCOMES: raise ValueError(f"QUALIFY outcome must be one of {sorted(QUALIFY_OUTCOMES)}, got: {outcome!r}") self.state["qualify_outcome"] = outcome if outcome == "PROCEED": self.state["status"] = "COMPLETE" return "QUALIFY" # terminal if outcome == "REFINE_SYNTHESIS": return "SYNTHESIS" # REOPEN_EARLIER_STAGE: all four required fields must be present required_fields = ( "reason", "return_to", "new_blocking_fact", "why_previous_gate_could_not_reasonably_have_caught_it", ) missing = [f for f in required_fields if not artifact.get(f)] if missing: raise ValueError(f"REOPEN_EARLIER_STAGE requires {required_fields}; missing: {missing}") return_to = artifact["return_to"] if return_to not in STAGES or return_to == "QUALIFY": raise ValueError(f"REOPEN_EARLIER_STAGE return_to must be an epistemic stage, got: {return_to!r}") return return_to def synthesis_readiness(self, adversarial_artifact: dict) -> tuple[bool, list[str]]: """Deterministic SYNTHESIS READY invariant (correction §7). SYNTHESIS READY IFF: - ADVERSARIAL disposition ∈ {SURVIVES, SURVIVES_WITH_MITIGATIONS} - at least one surviving candidate exists - required mitigations are represented (when disposition is SURVIVES_WITH_MITIGATIONS) - blocking adversarial unknowns = 0 """ problems = [] disposition = adversarial_artifact.get("disposition") if disposition not in SYNTHESIS_UNLOCKING_DISPOSITIONS: problems.append(f"disposition {disposition!r} does not unlock SYNTHESIS") survivors = adversarial_artifact.get("surviving_candidates", []) if not survivors: problems.append("no surviving candidate") if disposition == "SURVIVES_WITH_MITIGATIONS" and not adversarial_artifact.get("required_mitigations"): problems.append( "SURVIVES_WITH_MITIGATIONS without required_mitigations — " "mitigations are mandatory synthesis inputs, not optional prose" ) if self.state.get("blocking_unknowns"): problems.append(f"blocking adversarial unknowns present: {len(self.state['blocking_unknowns'])}") return (len(problems) == 0, problems) def _route_after_applicability(self, artifact: dict) -> str: """APPLICABILITY decides the INNOVATION branch (canonical correction). STANDARD_SOLUTION_SUFFICIENT → ADVERSARIAL (INNOVATION skipped) INNOVATION_WARRANTED → INNOVATION → ADVERSARIAL INNOVATION exists to answer: can useful aspects be combined/borrowed to produce something materially better? The standard researched mechanism may already be the best solution. Default when the artifact is silent: STANDARD_SOLUTION_SUFFICIENT (skip — do not manufacture novelty for its own sake). """ route = artifact.get("route", "STANDARD_SOLUTION_SUFFICIENT") if route not in ("STANDARD_SOLUTION_SUFFICIENT", "INNOVATION_WARRANTED"): raise ValueError( f"APPLICABILITY route must be STANDARD_SOLUTION_SUFFICIENT or INNOVATION_WARRANTED, got: {route!r}" ) self.state["applicability_route"] = route return "INNOVATION" if route == "INNOVATION_WARRANTED" else "ADVERSARIAL" def fail(self, reason: str) -> None: self.state["stage_history"].append( { "stage": self.state["current_stage"], "round": self.round, "failed": True, "reason": reason, } ) self.save() def more_research(self, unknowns: list[dict]) -> list[dict]: """Targeted refinement (QUALIFY → REFINE, canonical §14). Routes to the EARLIEST deficient stage. No full-pipeline restart: INWARD is not rerun unless Rune reality itself changed. Retired stage names (OUTWARD_DISCOVERY/OUTWARD_READING) route to the unified OUTWARD stage. """ self.state["blocking_unknowns"] = unknowns self.round += 1 self.state["round"] = self.round branches = [] all_stages = [] for u in unknowns: u_type = u.get("type", "mixed") if u_type in ("repo/inward", "direct-verification"): stages_needed = ["INWARD"] elif u_type in ("academic", "current-tech", "software-source"): stages_needed = ["OUTWARD"] elif u_type == "counter-evidence": stages_needed = ["ADVERSARIAL"] elif u_type == "applicability": stages_needed = ["APPLICABILITY"] elif u_type == "innovation": stages_needed = ["INNOVATION"] else: stages_needed = ["OUTWARD"] branches.append( { "unknown": u, "stages": stages_needed, "round": self.round, } ) all_stages.extend(stages_needed) # Route to the earliest deficient stage in canonical order, # then refresh downstream stages (targeted refinement path). if all_stages: first = min(all_stages, key=lambda s: STAGES.index(s)) self.state["current_stage"] = first self.state["targeted_branch_stages"] = all_stages self.state["targeted_return_stage"] = "SYNTHESIS" self.save() return branches def get_prior_artifacts(self, for_stage: str) -> dict: """Return the accepted artifacts this stage needs.""" idx = STAGES.index(for_stage) if for_stage in STAGES else len(STAGES) return {STAGES[i]: self.state["artifacts"].get(STAGES[i], {}) for i in range(idx)} def capsule(self) -> dict: """The process capsule for MiMo invocation (§14).""" return { "process_id": f"research-{self.issue}-r{self.round}", "issue": self.issue, "round": self.round, "current_stage": self.current_stage, "completed_stages": self.state["completed_stages"], "accepted_artifacts": { # Adversarial-gated refinement (correction §5–6): mitigations # and falsification tests MUST reach SYNTHESIS as required inputs. k: { kk: v for kk, v in vv.items() if kk in ( "decision", "claims", "sources", "candidates", "disposition", "required_mitigations", "falsification_tests", "surviving_candidates", "rejected_candidates", "route", ) } for k, vv in self.state["artifacts"].items() }, "blocking_unknowns": self.state.get("blocking_unknowns", []), "allowed_action_now": self.current_stage, "expected_completion_artifact": STAGE_ARTIFACT_FILES.get(self.current_stage, "artifact.json"), }