From b565d89cd804580a582c40744fa2757b54b75fd6 Mon Sep 17 00:00:00 2001 From: divo12 Date: Sat, 22 Aug 2026 18:22:47 +0530 Subject: [PATCH 1/4] implement governed candidate worktrees --- src/ofw/__init__.py | 22 ++ src/ofw/candidate.py | 493 ++++++++++++++++++++++++++++++++++++++++ tests/test_candidate.py | 241 ++++++++++++++++++++ 3 files changed, 756 insertions(+) create mode 100644 src/ofw/candidate.py create mode 100644 tests/test_candidate.py diff --git a/src/ofw/__init__.py b/src/ofw/__init__.py index 6a74bdf..892924d 100644 --- a/src/ofw/__init__.py +++ b/src/ofw/__init__.py @@ -21,6 +21,16 @@ BenchmarkRunner, BenchmarkStatus, ) +from ofw.candidate import ( + CandidateBuilder, + CandidateError, + CandidateErrorCode, + CandidateEvidence, + CandidatePolicy, + ChangePrediction, + FileEdit, + LineRange, +) from ofw.contracts import ( AssetAccess, ComponentKind, @@ -36,6 +46,7 @@ WorkspaceFile, ) from ofw.diagnosis import ( + ClusterId, ClusterRevisionRef, ClusterState, DiagnosisError, @@ -129,6 +140,8 @@ class _OfwNamespace: ExportPolicy = ExportPolicy BenchmarkPolicy = BenchmarkPolicy BenchmarkRunner = BenchmarkRunner + CandidatePolicy = CandidatePolicy + CandidateBuilder = CandidateBuilder def editable(self, path: Path) -> EditableFile: return editable(path) @@ -154,11 +167,18 @@ def collect( "BenchmarkResult", "BenchmarkRunner", "BenchmarkStatus", + "CandidateBuilder", + "CandidateError", + "CandidateErrorCode", + "CandidateEvidence", + "CandidatePolicy", "CanaryCase", "CaseId", "ClusterPartitionRule", "ClusterFamilyId", + "ClusterId", "ClusterState", + "ChangePrediction", "ConsentStatus", "ClusterRevisionRef", "ComponentKind", @@ -180,6 +200,7 @@ def collect( "ExportPartition", "ExportPolicy", "FailureCluster", + "FileEdit", "GitCommit", "Harness", "HarnessAsset", @@ -195,6 +216,7 @@ def collect( "LangfuseSpan", "LeakageError", "LeakageErrorCode", + "LineRange", "LocalProcess", "ModelFingerprint", "ModuleName", diff --git a/src/ofw/candidate.py b/src/ofw/candidate.py new file mode 100644 index 0000000..a550bc3 --- /dev/null +++ b/src/ofw/candidate.py @@ -0,0 +1,493 @@ +"""Governed AHE candidate manifests and isolated Git worktrees.""" + +from __future__ import annotations + +import hashlib +import math +import shutil +import subprocess # nosec B404 +import tempfile +from dataclasses import dataclass +from enum import IntEnum, StrEnum +from pathlib import Path + +from pydantic import TypeAdapter + +from ofw.contracts import ( + AssetAccess, + ComponentKind, + HarnessAsset, + HarnessRevision, + HarnessRevisionId, + Sha256Digest, +) +from ofw.diagnosis import ClusterId +from ofw.mine import write_artifact + + +class CandidateSchemaVersion(IntEnum): + V1 = 1 + + +class CandidateErrorCode(StrEnum): + REVISION_MISMATCH = "revision_mismatch" + EVIDENCE_MISMATCH = "evidence_mismatch" + FILE_NOT_EDITABLE = "file_not_editable" + BASE_DIGEST_MISMATCH = "base_digest_mismatch" + SELECTOR_INVALID = "selector_invalid" + BUDGET_EXCEEDED = "budget_exceeded" + WORKTREE_FAILED = "worktree_failed" + FROZEN_ASSET_CHANGED = "frozen_asset_changed" + NO_CHANGES = "no_changes" + + +class CandidateError(Exception): + __slots__ = ("code", "subject") + + def __init__(self, code: CandidateErrorCode, subject: str) -> None: + self.code = code + self.subject = subject + super().__init__(f"{code.value}: {subject}") + + +@dataclass(frozen=True, slots=True) +class CandidateId: + value: str + + def __str__(self) -> str: + return self.value + + +@dataclass(frozen=True, slots=True) +class CandidateBranch: + value: str + + +@dataclass(frozen=True, slots=True) +class LineRange: + start: int + end: int + + def __post_init__(self) -> None: + if self.start < 1 or self.end < self.start: + raise CandidateError(CandidateErrorCode.SELECTOR_INVALID, "invalid line range") + + +@dataclass(frozen=True, slots=True) +class FileEdit: + path: Path + expected_digest: Sha256Digest + replacement: str + selector: LineRange | None = None + + def __post_init__(self) -> None: + if self.path.is_absolute() or ".." in self.path.parts: + raise CandidateError(CandidateErrorCode.FILE_NOT_EDITABLE, self.path.as_posix()) + + +@dataclass(frozen=True, slots=True) +class CandidateEvidence: + revision_id: HarnessRevisionId + cluster_ids: tuple[ClusterId, ...] + eval_case_ids: tuple[str, ...] + memory_cluster_ids: tuple[ClusterId, ...] + + @property + def digest(self) -> Sha256Digest: + return _digest_text( + "\0".join( + ( + str(self.revision_id), + *(cluster.value for cluster in self.cluster_ids), + *self.eval_case_ids, + *(cluster.value for cluster in self.memory_cluster_ids), + ) + ) + ) + + +@dataclass(frozen=True, slots=True) +class ChangePrediction: + hypothesis: str + target_clusters: tuple[ClusterId, ...] + at_risk_cases: tuple[str, ...] + affected_components: tuple[ComponentKind, ...] + memory_candidates: tuple[ClusterId, ...] + expected_quality_delta: float + expected_cost_delta: float + expected_latency_delta: float + + def __post_init__(self) -> None: + if ( + not self.hypothesis + or not self.target_clusters + or not self.affected_components + or not all( + math.isfinite(value) + for value in ( + self.expected_quality_delta, + self.expected_cost_delta, + self.expected_latency_delta, + ) + ) + ): + raise CandidateError(CandidateErrorCode.EVIDENCE_MISMATCH, "invalid prediction") + + +@dataclass(frozen=True, slots=True) +class CandidatePolicy: + maximum_files: int + maximum_changed_bytes: int + allowed_components: tuple[ComponentKind, ...] + + def __post_init__(self) -> None: + if self.maximum_files < 1 or self.maximum_changed_bytes < 1 or not self.allowed_components: + raise CandidateError(CandidateErrorCode.BUDGET_EXCEEDED, "invalid policy") + + @property + def digest(self) -> Sha256Digest: + return _digest_text( + "\0".join( + ( + str(self.maximum_files), + str(self.maximum_changed_bytes), + *(component.value for component in self.allowed_components), + ) + ) + ) + + +@dataclass(frozen=True, slots=True) +class EditIntent: + path: Path + base_digest: Sha256Digest + replacement_digest: Sha256Digest + selector: LineRange | None + + +@dataclass(frozen=True, slots=True) +class CandidateManifest: + schema_version: CandidateSchemaVersion + candidate_id: CandidateId + base_revision_id: HarnessRevisionId + evidence_digest: Sha256Digest + policy_digest: Sha256Digest + hypothesis: str + target_clusters: tuple[ClusterId, ...] + at_risk_cases: tuple[str, ...] + affected_components: tuple[ComponentKind, ...] + memory_candidates: tuple[ClusterId, ...] + expected_quality_delta: float + expected_cost_delta: float + expected_latency_delta: float + edits: tuple[EditIntent, ...] + + def to_json(self) -> str: + return _MANIFEST_ADAPTER.dump_json(self).decode() + + +@dataclass(frozen=True, slots=True) +class CandidateRevision: + id: CandidateId + base_revision_id: HarnessRevisionId + branch: CandidateBranch + root: Path + changed_files: tuple[Path, ...] + changed_components: tuple[ComponentKind, ...] + manifest_path: Path + diff_path: Path + + +@dataclass(slots=True) +class CandidateWorkspace: + source_root: Path + parent: Path + root: Path + branch: CandidateBranch + closed: bool = False + + def close(self) -> None: + if self.closed: + return + _git(self.source_root, "worktree", "remove", "--force", str(self.root)) + _git(self.source_root, "branch", "-D", self.branch.value) + shutil.rmtree(self.parent, ignore_errors=True) + self.closed = True + + +@dataclass(frozen=True, slots=True) +class CandidateBuild: + candidate: CandidateRevision + workspace: CandidateWorkspace + + +_MANIFEST_ADAPTER: TypeAdapter[CandidateManifest] = TypeAdapter(CandidateManifest) + + +@dataclass(frozen=True, slots=True) +class CandidateBuilder: + revision: HarnessRevision + evidence: CandidateEvidence + policy: CandidatePolicy + + def create( + self, + edits: tuple[FileEdit, ...], + prediction: ChangePrediction, + ) -> CandidateBuild: + self._validate_request(edits, prediction) + intents = tuple( + EditIntent( + edit.path, + edit.expected_digest, + _digest_text(edit.replacement), + edit.selector, + ) + for edit in edits + ) + candidate_id = CandidateId( + "candidate_" + + hashlib.sha256( + "\0".join( + ( + str(self.revision.id), + str(self.evidence.digest), + str(self.policy.digest), + str(int(CandidateSchemaVersion.V1)), + prediction.hypothesis, + *(cluster.value for cluster in prediction.target_clusters), + *prediction.at_risk_cases, + *(component.value for component in prediction.affected_components), + *(cluster.value for cluster in prediction.memory_candidates), + str(prediction.expected_quality_delta), + str(prediction.expected_cost_delta), + str(prediction.expected_latency_delta), + *(intent.path.as_posix() for intent in intents), + *(str(intent.base_digest) for intent in intents), + *(str(intent.replacement_digest) for intent in intents), + *(_selector_text(intent.selector) for intent in intents), + ) + ).encode() + ).hexdigest() + ) + manifest = CandidateManifest( + CandidateSchemaVersion.V1, + candidate_id, + self.revision.id, + self.evidence.digest, + self.policy.digest, + prediction.hypothesis, + prediction.target_clusters, + prediction.at_risk_cases, + prediction.affected_components, + prediction.memory_candidates, + prediction.expected_quality_delta, + prediction.expected_cost_delta, + prediction.expected_latency_delta, + intents, + ) + manifest_path = ( + self.revision.root / ".ofw" / "candidates" / str(candidate_id) / "manifest.json" + ) + write_artifact(manifest_path, f"{manifest.to_json()}\n".encode()) + workspace = self._worktree(candidate_id) + try: + for edit in edits: + _apply_edit(workspace.root, edit) + self._validate_frozen(workspace.root) + diff = _git_bytes(workspace.root, "diff", "--binary", "--no-ext-diff", "HEAD", "--") + if not diff: + raise CandidateError(CandidateErrorCode.NO_CHANGES, str(candidate_id)) + diff_path = manifest_path.with_name("candidate.patch") + write_artifact(diff_path, diff) + components = tuple( + sorted( + {self._component(edit.path) for edit in edits}, + key=_component_sort_key, + ) + ) + candidate = CandidateRevision( + candidate_id, + self.revision.id, + workspace.branch, + workspace.root, + tuple(edit.path for edit in edits), + components, + manifest_path, + diff_path, + ) + return CandidateBuild(candidate, workspace) + except Exception: + workspace.close() + raise + + def _validate_request( + self, + edits: tuple[FileEdit, ...], + prediction: ChangePrediction, + ) -> None: + if self.evidence.revision_id != self.revision.id: + raise CandidateError(CandidateErrorCode.REVISION_MISMATCH, str(self.revision.id)) + if ( + any(cluster not in self.evidence.cluster_ids for cluster in prediction.target_clusters) + or any(case not in self.evidence.eval_case_ids for case in prediction.at_risk_cases) + or any( + cluster not in self.evidence.memory_cluster_ids + for cluster in prediction.memory_candidates + ) + ): + raise CandidateError(CandidateErrorCode.EVIDENCE_MISMATCH, prediction.hypothesis) + if ( + not edits + or len(edits) > self.policy.maximum_files + or len({edit.path for edit in edits}) != len(edits) + or sum(len(edit.replacement.encode()) for edit in edits) + > self.policy.maximum_changed_bytes + ): + raise CandidateError(CandidateErrorCode.BUDGET_EXCEEDED, str(len(edits))) + for edit in edits: + component = self._component(edit.path) + if component not in self.policy.allowed_components: + raise CandidateError(CandidateErrorCode.FILE_NOT_EDITABLE, edit.path.as_posix()) + source = self.revision.root / edit.path + actual = _digest_file(source) + asset = self._asset(edit.path) + if actual != edit.expected_digest or actual != asset.digest: + raise CandidateError( + CandidateErrorCode.BASE_DIGEST_MISMATCH, + edit.path.as_posix(), + ) + if any( + component not in prediction.affected_components + for component in {self._component(edit.path) for edit in edits} + ): + raise CandidateError(CandidateErrorCode.EVIDENCE_MISMATCH, "affected components") + + def _asset(self, path: Path) -> HarnessAsset: + for component in self.revision.components: + for asset in component.assets: + if asset.source.relative_path == path and asset.access is AssetAccess.FIT_EDITABLE: + return asset + raise CandidateError(CandidateErrorCode.FILE_NOT_EDITABLE, path.as_posix()) + + def _component(self, path: Path) -> ComponentKind: + for component in self.revision.components: + if any( + asset.source.relative_path == path and asset.access is AssetAccess.FIT_EDITABLE + for asset in component.assets + ): + return component.kind + raise CandidateError(CandidateErrorCode.FILE_NOT_EDITABLE, path.as_posix()) + + def _worktree(self, candidate_id: CandidateId) -> CandidateWorkspace: + parent = Path(tempfile.mkdtemp(prefix="ofw-candidate-")) + root = parent / "worktree" + branch = CandidateBranch(f"ofw-{candidate_id.value[-16:]}") + try: + _git( + self.revision.root, + "worktree", + "add", + "-b", + branch.value, + str(root), + str(self.revision.repository.commit), + ) + workspace = CandidateWorkspace(self.revision.root, parent, root, branch) + try: + dirty = _git_bytes( + self.revision.root, + "diff", + "--binary", + "--no-ext-diff", + "HEAD", + "--", + ) + if dirty: + _git_with_input(root, dirty, "apply", "--binary", "-") + except Exception: + workspace.close() + raise + return workspace + except Exception: + shutil.rmtree(parent, ignore_errors=True) + raise + + def _validate_frozen(self, root: Path) -> None: + for component in self.revision.components: + for asset in component.assets: + if asset.access is AssetAccess.FROZEN: + actual = _digest_file(root / asset.source.relative_path) + if actual != asset.digest: + raise CandidateError( + CandidateErrorCode.FROZEN_ASSET_CHANGED, + asset.source.relative_path.as_posix(), + ) + + +def _apply_edit(root: Path, edit: FileEdit) -> None: + path = (root / edit.path).resolve(strict=True) + try: + path.relative_to(root.resolve(strict=True)) + except ValueError as error: + raise CandidateError(CandidateErrorCode.FILE_NOT_EDITABLE, edit.path.as_posix()) from error + if edit.selector is None: + path.write_text(edit.replacement, encoding="utf-8") + return + lines = path.read_text(encoding="utf-8").splitlines(keepends=True) + if edit.selector.end > len(lines): + raise CandidateError(CandidateErrorCode.SELECTOR_INVALID, edit.path.as_posix()) + replacement = edit.replacement.splitlines(keepends=True) + lines[edit.selector.start - 1 : edit.selector.end] = replacement + path.write_text("".join(lines), encoding="utf-8") + + +def _git(root: Path, *arguments: str) -> None: + result = subprocess.run( # nosec B603 + ("git", "-C", str(root), *arguments), + check=False, + capture_output=True, + ) + if result.returncode != 0: + raise CandidateError(CandidateErrorCode.WORKTREE_FAILED, arguments[0]) + + +def _git_bytes(root: Path, *arguments: str) -> bytes: + result = subprocess.run( # nosec B603 + ("git", "-C", str(root), *arguments), + check=False, + capture_output=True, + ) + if result.returncode != 0: + raise CandidateError(CandidateErrorCode.WORKTREE_FAILED, arguments[0]) + return result.stdout + + +def _git_with_input(root: Path, payload: bytes, *arguments: str) -> None: + result = subprocess.run( # nosec B603 + ("git", "-C", str(root), *arguments), + input=payload, + check=False, + capture_output=True, + ) + if result.returncode != 0: + raise CandidateError(CandidateErrorCode.WORKTREE_FAILED, arguments[0]) + + +def _digest_file(path: Path) -> Sha256Digest: + try: + return Sha256Digest(f"sha256:{hashlib.sha256(path.read_bytes()).hexdigest()}") + except OSError as error: + raise CandidateError(CandidateErrorCode.FILE_NOT_EDITABLE, str(path)) from error + + +def _digest_text(value: str) -> Sha256Digest: + return Sha256Digest(f"sha256:{hashlib.sha256(value.encode()).hexdigest()}") + + +def _component_sort_key(component: ComponentKind) -> str: + return component.value + + +def _selector_text(selector: LineRange | None) -> str: + return "all" if selector is None else f"{selector.start}:{selector.end}" diff --git a/tests/test_candidate.py b/tests/test_candidate.py new file mode 100644 index 0000000..d215931 --- /dev/null +++ b/tests/test_candidate.py @@ -0,0 +1,241 @@ +"""Controlled AHE candidate worktree and manifest behavior.""" + +from __future__ import annotations + +import hashlib +import subprocess +from pathlib import Path + +import pytest + +from ofw import ( + CandidateBuilder, + CandidateError, + CandidateErrorCode, + CandidateEvidence, + CandidatePolicy, + ChangePrediction, + ClusterId, + ComponentKind, + FileEdit, + Harness, + LineRange, + Sha256Digest, + Tool, + ofw, +) +from ofw.contracts import HarnessRevisionId + + +def _run_git(root: Path, *arguments: str) -> str: + completed = subprocess.run( + ("git", "-C", str(root), *arguments), + check=True, + capture_output=True, + text=True, + ) + return completed.stdout + + +def _harness(tmp_path: Path) -> Harness: + root = tmp_path / "candidate-agent" + root.mkdir() + (root / "prompt.md").write_text("Frozen prompt.\n", encoding="utf-8") + (root / "tool.py").write_text( + "def run(value: str) -> str:\n return value\n", + encoding="utf-8", + ) + (root / "verifier.py").write_text("VERIFIER = 'frozen'\n", encoding="utf-8") + _run_git(root, "init", "-q") + _run_git(root, "config", "user.email", "fixture@example.test") + _run_git(root, "config", "user.name", "FixtureCo") + _run_git(root, "add", ".") + _run_git(root, "commit", "-qm", "fixture baseline") + harness = Harness("candidate-agent", root=root) + harness.connect_prompt(Path("prompt.md")) + harness.connect_tools(Tool("run", ofw.editable(Path("tool.py")))) + harness.process() + return harness + + +def _digest(path: Path) -> Sha256Digest: + return Sha256Digest(f"sha256:{hashlib.sha256(path.read_bytes()).hexdigest()}") + + +def _evidence(revision_id: HarnessRevisionId) -> CandidateEvidence: + return CandidateEvidence( + revision_id, + (ClusterId("cluster-tool-schema"),), + ("frontier-case", "regression-case"), + (ClusterId("cluster-memory"),), + ) + + +def _prediction() -> ChangePrediction: + return ChangePrediction( + hypothesis="Normalize tool arguments before execution.", + target_clusters=(ClusterId("cluster-tool-schema"),), + at_risk_cases=("regression-case",), + affected_components=(ComponentKind.TOOL,), + memory_candidates=(ClusterId("cluster-memory"),), + expected_quality_delta=0.1, + expected_cost_delta=0.0, + expected_latency_delta=0.0, + ) + + +def _policy() -> CandidatePolicy: + return CandidatePolicy( + maximum_files=2, + maximum_changed_bytes=1024, + allowed_components=(ComponentKind.TOOL,), + ) + + +def test_candidate_edits_only_tool_in_isolated_branch_and_preserves_manifest( + tmp_path: Path, +) -> None: + harness = _harness(tmp_path) + revision = harness.current_revision + assert revision is not None + tool = revision.root / "tool.py" + edit = FileEdit( + Path("tool.py"), + _digest(tool), + "def run(value: str) -> str:\n return value.strip()\n", + ) + builder = CandidateBuilder(revision, _evidence(revision.id), _policy()) + + build = builder.create((edit,), _prediction()) + manifest_before = build.candidate.manifest_path.read_bytes() + try: + assert build.workspace.root != revision.root + assert (build.workspace.root / "prompt.md").read_text( + encoding="utf-8" + ) == "Frozen prompt.\n" + assert "strip" in (build.workspace.root / "tool.py").read_text(encoding="utf-8") + assert build.candidate.changed_components == (ComponentKind.TOOL,) + assert build.candidate.diff_path.is_file() + assert build.candidate.manifest_path.read_bytes() == manifest_before + finally: + branch = build.workspace.branch + root = build.workspace.root + build.workspace.close() + + assert not root.exists() + assert branch.value not in _run_git(revision.root, "branch", "--format=%(refname:short)") + + +@pytest.mark.parametrize("path", (Path("prompt.md"), Path("verifier.py"), Path(".env"))) +def test_frozen_or_undeclared_file_edit_is_rejected(path: Path, tmp_path: Path) -> None: + harness = _harness(tmp_path) + revision = harness.current_revision + assert revision is not None + source = revision.root / path + expected = _digest(source) if source.is_file() else Sha256Digest("sha256:missing") + edit = FileEdit(path, expected, "changed\n") + + with pytest.raises(CandidateError) as raised: + CandidateBuilder(revision, _evidence(revision.id), _policy()).create( + (edit,), + _prediction(), + ) + + assert raised.value.code is CandidateErrorCode.FILE_NOT_EDITABLE + + +def test_stale_base_digest_is_rejected_before_worktree_creation(tmp_path: Path) -> None: + harness = _harness(tmp_path) + revision = harness.current_revision + assert revision is not None + edit = FileEdit( + Path("tool.py"), + Sha256Digest("sha256:stale"), + "changed\n", + ) + + with pytest.raises(CandidateError) as raised: + CandidateBuilder(revision, _evidence(revision.id), _policy()).create( + (edit,), + _prediction(), + ) + + assert raised.value.code is CandidateErrorCode.BASE_DIGEST_MISMATCH + + +def test_line_selector_changes_only_declared_range(tmp_path: Path) -> None: + harness = _harness(tmp_path) + revision = harness.current_revision + assert revision is not None + tool = revision.root / "tool.py" + edit = FileEdit( + Path("tool.py"), + _digest(tool), + " return value.strip()\n", + selector=LineRange(2, 2), + ) + + build = CandidateBuilder(revision, _evidence(revision.id), _policy()).create( + (edit,), + _prediction(), + ) + try: + assert (build.workspace.root / "tool.py").read_text(encoding="utf-8") == ( + "def run(value: str) -> str:\n return value.strip()\n" + ) + finally: + build.workspace.close() + + +def test_prediction_must_reference_known_evidence(tmp_path: Path) -> None: + harness = _harness(tmp_path) + revision = harness.current_revision + assert revision is not None + unknown = ChangePrediction( + hypothesis="Unknown target.", + target_clusters=(ClusterId("cluster-unknown"),), + at_risk_cases=(), + affected_components=(ComponentKind.TOOL,), + memory_candidates=(), + expected_quality_delta=0.1, + expected_cost_delta=0.0, + expected_latency_delta=0.0, + ) + + with pytest.raises(CandidateError) as raised: + CandidateBuilder(revision, _evidence(revision.id), _policy()).create((), unknown) + + assert raised.value.code is CandidateErrorCode.EVIDENCE_MISMATCH + + +def test_candidate_budget_limits_file_count_and_bytes(tmp_path: Path) -> None: + harness = _harness(tmp_path) + revision = harness.current_revision + assert revision is not None + tool = revision.root / "tool.py" + oversized = FileEdit(Path("tool.py"), _digest(tool), "x" * 2048) + + with pytest.raises(CandidateError) as raised: + CandidateBuilder(revision, _evidence(revision.id), _policy()).create( + (oversized,), + _prediction(), + ) + + assert raised.value.code is CandidateErrorCode.BUDGET_EXCEEDED + + +def test_noop_edit_is_rejected_and_worktree_is_cleaned(tmp_path: Path) -> None: + harness = _harness(tmp_path) + revision = harness.current_revision + assert revision is not None + tool = revision.root / "tool.py" + branches_before = _run_git(revision.root, "branch", "--format=%(refname:short)") + + with pytest.raises(CandidateError) as raised: + CandidateBuilder(revision, _evidence(revision.id), _policy()).create( + (FileEdit(Path("tool.py"), _digest(tool), tool.read_text(encoding="utf-8")),), + _prediction(), + ) + + assert raised.value.code is CandidateErrorCode.NO_CHANGES + assert _run_git(revision.root, "branch", "--format=%(refname:short)") == branches_before From 114e41550aba7084e68847b979623cc7fea89626 Mon Sep 17 00:00:00 2001 From: divo12 Date: Sat, 22 Aug 2026 18:25:03 +0530 Subject: [PATCH 2/4] canonicalize candidate edit sets --- src/ofw/candidate.py | 22 ++++++++++++++++++---- tests/test_candidate.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/ofw/candidate.py b/src/ofw/candidate.py index a550bc3..5733d67 100644 --- a/src/ofw/candidate.py +++ b/src/ofw/candidate.py @@ -236,6 +236,7 @@ def create( prediction: ChangePrediction, ) -> CandidateBuild: self._validate_request(edits, prediction) + ordered_edits = tuple(sorted(edits, key=_edit_sort_key)) intents = tuple( EditIntent( edit.path, @@ -243,7 +244,7 @@ def create( _digest_text(edit.replacement), edit.selector, ) - for edit in edits + for edit in ordered_edits ) candidate_id = CandidateId( "candidate_" @@ -292,7 +293,7 @@ def create( write_artifact(manifest_path, f"{manifest.to_json()}\n".encode()) workspace = self._worktree(candidate_id) try: - for edit in edits: + for edit in ordered_edits: _apply_edit(workspace.root, edit) self._validate_frozen(workspace.root) diff = _git_bytes(workspace.root, "diff", "--binary", "--no-ext-diff", "HEAD", "--") @@ -302,7 +303,7 @@ def create( write_artifact(diff_path, diff) components = tuple( sorted( - {self._component(edit.path) for edit in edits}, + {self._component(edit.path) for edit in ordered_edits}, key=_component_sort_key, ) ) @@ -311,7 +312,7 @@ def create( self.revision.id, workspace.branch, workspace.root, - tuple(edit.path for edit in edits), + tuple(edit.path for edit in ordered_edits), components, manifest_path, diff_path, @@ -405,6 +406,7 @@ def _worktree(self, candidate_id: CandidateId) -> CandidateWorkspace: ) if dirty: _git_with_input(root, dirty, "apply", "--binary", "-") + _copy_revision_assets(self.revision, root) except Exception: workspace.close() raise @@ -489,5 +491,17 @@ def _component_sort_key(component: ComponentKind) -> str: return component.value +def _edit_sort_key(edit: FileEdit) -> str: + return edit.path.as_posix() + + def _selector_text(selector: LineRange | None) -> str: return "all" if selector is None else f"{selector.start}:{selector.end}" + + +def _copy_revision_assets(revision: HarnessRevision, root: Path) -> None: + for asset in revision.assets: + source = revision.root / asset.source.relative_path + destination = root / asset.source.relative_path + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) diff --git a/tests/test_candidate.py b/tests/test_candidate.py index d215931..24299ba 100644 --- a/tests/test_candidate.py +++ b/tests/test_candidate.py @@ -239,3 +239,31 @@ def test_noop_edit_is_rejected_and_worktree_is_cleaned(tmp_path: Path) -> None: assert raised.value.code is CandidateErrorCode.NO_CHANGES assert _run_git(revision.root, "branch", "--format=%(refname:short)") == branches_before + + +def test_equivalent_edit_order_produces_one_candidate_identity(tmp_path: Path) -> None: + harness = _harness(tmp_path) + second = harness.root / "second_tool.py" + second.write_text("def second() -> int:\n return 1\n", encoding="utf-8") + harness.connect_tools(Tool("second", ofw.editable(Path("second_tool.py")))) + revision = harness.process() + first_edit = FileEdit( + Path("tool.py"), + _digest(revision.root / "tool.py"), + "def run(value: str) -> str:\n return value.strip()\n", + ) + second_edit = FileEdit( + Path("second_tool.py"), + _digest(second), + "def second() -> int:\n return 2\n", + ) + builder = CandidateBuilder(revision, _evidence(revision.id), _policy()) + + first = builder.create((first_edit, second_edit), _prediction()) + first_id = first.candidate.id + first.workspace.close() + reversed_build = builder.create((second_edit, first_edit), _prediction()) + try: + assert reversed_build.candidate.id == first_id + finally: + reversed_build.workspace.close() From 4c9215d891875a519837ff117cca0525636f3e3a Mon Sep 17 00:00:00 2001 From: divo12 Date: Sat, 22 Aug 2026 18:26:50 +0530 Subject: [PATCH 3/4] enforce actual candidate diff budget --- src/ofw/candidate.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ofw/candidate.py b/src/ofw/candidate.py index 5733d67..f0b4cea 100644 --- a/src/ofw/candidate.py +++ b/src/ofw/candidate.py @@ -299,6 +299,8 @@ def create( diff = _git_bytes(workspace.root, "diff", "--binary", "--no-ext-diff", "HEAD", "--") if not diff: raise CandidateError(CandidateErrorCode.NO_CHANGES, str(candidate_id)) + if len(diff) > self.policy.maximum_changed_bytes: + raise CandidateError(CandidateErrorCode.BUDGET_EXCEEDED, str(len(diff))) diff_path = manifest_path.with_name("candidate.patch") write_artifact(diff_path, diff) components = tuple( From 93de1fd0d4608ca596fec296cb99be5a91e05d48 Mon Sep 17 00:00:00 2001 From: divo12 Date: Sat, 22 Aug 2026 18:30:55 +0530 Subject: [PATCH 4/4] isolate candidate edits from dirty baseline --- src/ofw/candidate.py | 19 +++++++++++++++ tests/test_candidate.py | 52 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/ofw/candidate.py b/src/ofw/candidate.py index f0b4cea..ce41917 100644 --- a/src/ofw/candidate.py +++ b/src/ofw/candidate.py @@ -39,6 +39,7 @@ class CandidateErrorCode(StrEnum): WORKTREE_FAILED = "worktree_failed" FROZEN_ASSET_CHANGED = "frozen_asset_changed" NO_CHANGES = "no_changes" + REVISION_STALE = "revision_stale" class CandidateError(Exception): @@ -235,6 +236,7 @@ def create( edits: tuple[FileEdit, ...], prediction: ChangePrediction, ) -> CandidateBuild: + self._validate_revision_assets() self._validate_request(edits, prediction) ordered_edits = tuple(sorted(edits, key=_edit_sort_key)) intents = tuple( @@ -409,6 +411,14 @@ def _worktree(self, candidate_id: CandidateId) -> CandidateWorkspace: if dirty: _git_with_input(root, dirty, "apply", "--binary", "-") _copy_revision_assets(self.revision, root) + _git(root, "add", "-A") + _git( + root, + "commit", + "--allow-empty", + "-m", + f"OFW private baseline {self.revision.id}", + ) except Exception: workspace.close() raise @@ -428,6 +438,15 @@ def _validate_frozen(self, root: Path) -> None: asset.source.relative_path.as_posix(), ) + def _validate_revision_assets(self) -> None: + for asset in self.revision.assets: + actual = _digest_file(self.revision.root / asset.source.relative_path) + if actual != asset.digest: + raise CandidateError( + CandidateErrorCode.REVISION_STALE, + asset.source.relative_path.as_posix(), + ) + def _apply_edit(root: Path, edit: FileEdit) -> None: path = (root / edit.path).resolve(strict=True) diff --git a/tests/test_candidate.py b/tests/test_candidate.py index 24299ba..821c771 100644 --- a/tests/test_candidate.py +++ b/tests/test_candidate.py @@ -267,3 +267,55 @@ def test_equivalent_edit_order_produces_one_candidate_identity(tmp_path: Path) - assert reversed_build.candidate.id == first_id finally: reversed_build.workspace.close() + + +def test_unrequested_editable_drift_rejects_entire_revision(tmp_path: Path) -> None: + harness = _harness(tmp_path) + second = harness.root / "second_tool.py" + second.write_text("def second() -> int:\n return 1\n", encoding="utf-8") + harness.connect_tools(Tool("second", ofw.editable(Path("second_tool.py")))) + revision = harness.process() + second.write_text("def second() -> int:\n return 99\n", encoding="utf-8") + tool = revision.root / "tool.py" + + with pytest.raises(CandidateError) as raised: + CandidateBuilder(revision, _evidence(revision.id), _policy()).create( + ( + FileEdit( + Path("tool.py"), + _digest(tool), + "def run(value: str) -> str:\n return value.strip()\n", + ), + ), + _prediction(), + ) + + assert raised.value.code is CandidateErrorCode.REVISION_STALE + + +def test_processed_dirty_state_is_baseline_not_candidate_diff(tmp_path: Path) -> None: + harness = _harness(tmp_path) + second = harness.root / "second_tool.py" + second.write_text("def second() -> int:\n return 1\n", encoding="utf-8") + _run_git(harness.root, "add", "second_tool.py") + _run_git(harness.root, "commit", "-qm", "add second tool") + harness.connect_tools(Tool("second", ofw.editable(Path("second_tool.py")))) + second.write_text("def second() -> int:\n return 2\n", encoding="utf-8") + revision = harness.process() + tool = revision.root / "tool.py" + edit = FileEdit( + Path("tool.py"), + _digest(tool), + "def run(value: str) -> str:\n return value.strip()\n", + ) + + build = CandidateBuilder(revision, _evidence(revision.id), _policy()).create( + (edit,), + _prediction(), + ) + try: + patch = build.candidate.diff_path.read_text(encoding="utf-8") + assert "tool.py" in patch + assert "second_tool.py" not in patch + finally: + build.workspace.close()