Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ prompt and this file conflict, stop and ask.
- **Standard library only in `core/` and `evidence/`** (Python 3.12, ADR-002, ADR-004).
- Any third-party import needs its own ADR first. There is no standing exception: NetworkX is deferred (ADR-005).
- **Import boundaries R1–R7** are in `tests/contract/test_architecture_boundaries.py`. Never loosen that test or its allowlist unless the prompt names the ADR that allows it.
- **No Pydantic or SQLAlchemy in the core.** Pydantic is allowed only in `llm/`, from P7.
- **No Pydantic or SQLAlchemy.** `llm/` is standard library only as well (C-183).
- **Configuration.**
- Code lives in `core/application/config.py`; data lives in `config/`.
- New keys are added only through schema changes.
Expand Down
32 changes: 32 additions & 0 deletions core/application/candidate_tree.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""The hash of a candidate directory (C-188).

A built LLM candidate is bound to its directory by ``tree_hash``: SHA-256 over every regular file
under the directory, in sorted relative-path order, each as its POSIX path and its bytes. The
validator records it when it builds the candidate; ``LLMEvaluationRequest`` recomputes it before
anything is evaluated, so a directory edited after the build is refused and "the patch and the
candidate cannot disagree". A symbolic link anywhere in the tree is refused.

Standard library only.
"""

from __future__ import annotations

import hashlib
from pathlib import Path


def tree_hash(directory: Path) -> str:
if not directory.is_dir() or directory.is_symlink():
raise ValueError(f"tree_hash: {directory} is not a directory")
digest = hashlib.sha256()
entries = sorted(directory.rglob("*"), key=lambda p: p.relative_to(directory).as_posix())
for path in entries:
if path.is_symlink():
raise ValueError(f"tree_hash: {path.name} is a symbolic link")
if not path.is_file():
continue
name = path.relative_to(directory).as_posix().encode("utf-8")
content = path.read_bytes()
digest.update(len(name).to_bytes(8, "big") + name)
digest.update(len(content).to_bytes(8, "big") + content)
return digest.hexdigest()
44 changes: 38 additions & 6 deletions core/application/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@
from datetime import datetime
from enum import StrEnum
from pathlib import Path
from typing import Any
from typing import Any, ClassVar

from core.application import ids as oids
from core.application.candidate_tree import tree_hash
from core.application.recorder import StageRecorder
from core.application.scope_policy import VerificationScopePolicy
from core.dependency.build import build_graph
Expand Down Expand Up @@ -140,6 +141,7 @@ class EvaluationRequest:
bindings: tuple[SemanticBinding, ...]
context: PromotionContext
oracle_verdicts: tuple[OracleVerdict, ...] = ()
_PATCH_SOURCE: ClassVar[CandidateSource] = CandidateSource.FIXED_PATCH

def __post_init__(self) -> None:
for name in ("lineage_id", "candidate_id"):
Expand All @@ -148,11 +150,10 @@ def __post_init__(self) -> None:
value = getattr(self, name)
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
raise EvaluationInputError(f"EvaluationRequest.{name} must be an integer >= 1")
if (
not isinstance(self.patch, Patch)
or self.patch.source is not CandidateSource.FIXED_PATCH
):
raise EvaluationInputError("EvaluationRequest.patch must be a FIXED_PATCH Patch")
if not isinstance(self.patch, Patch) or self.patch.source is not self._PATCH_SOURCE:
raise EvaluationInputError(
f"{type(self).__name__}.patch must be a {self._PATCH_SOURCE.value} Patch"
)
if not isinstance(self.candidate_dir, Path):
raise EvaluationInputError("EvaluationRequest.candidate_dir must be a Path")
if not isinstance(self.context, PromotionContext):
Expand All @@ -171,6 +172,37 @@ def __post_init__(self) -> None:
object.__setattr__(self, "oracle_verdicts", verdicts)


@dataclass(frozen=True)
class LLMEvaluationRequest(EvaluationRequest):
"""An LLM candidate on the same evaluation path (P7b; C-188, Doc 10 §12, §40).

``patch`` is an LLM ``Patch`` (``source = LLM``, bound to its request); ``candidate_dir`` is the
directory the patch validator built from the parent and the patch, and ``built_tree_hash`` the
hash it recorded. The directory is hashed again here: one edited after the build is refused,
so the patch and the candidate cannot disagree (C-188). It is a sibling of the fixed-patch
request rather than a flag on it so that a FIXED_PATCH request still refuses an LLM patch, and
an LLM candidate cannot be evaluated without the record that binds its directory."""

built_tree_hash: str = ""
_PATCH_SOURCE: ClassVar[CandidateSource] = CandidateSource.LLM

def __post_init__(self) -> None:
super().__post_init__()
if self.patch.metadata.get("tree_hash") != self.built_tree_hash:
raise EvaluationInputError(
"LLMEvaluationRequest: the patch record does not name this built tree"
)
try:
actual = tree_hash(self.candidate_dir)
except (OSError, ValueError) as error:
raise EvaluationInputError(f"LLMEvaluationRequest: {error}") from error
if not self.built_tree_hash or actual != self.built_tree_hash:
raise EvaluationInputError(
"LLMEvaluationRequest: the candidate directory tree is not the one the patch "
"validator built (C-188)"
)


@dataclass(frozen=True)
class EvaluationResult:
"""What ``evaluate`` returns. ``decision`` is the stored decision (``None`` for a construction
Expand Down
122 changes: 122 additions & 0 deletions core/domain/change_scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""The allowed change scope of a generated candidate, and the operation check (Doc 10 §14-§15;
C-190, C-191).

§15: "AllowedChangeScope { resource_addresses[], resource_types[], attributes[],
operation_types = CREATE | MODIFY | DELETE | REPLACE }. The scope is generated by the
experiment/controller, not chosen by the LLM." It lives in ``core.domain`` because the
controller (``core.application``, later the experiment harness) builds it and ``core`` may not
import ``llm`` (R3).

``operation_breaches(report, scope)`` is the check of C-191: after DAIL's own identity engine has
compared the candidate with its parent, every change must be an allowed operation: CREATED is
CREATE, MODIFIED is MODIFY, DELETED is DELETE, REPLACED is REPLACE, and an UNCERTAIN match counts as
REPLACE (the cautious reading: identity could not exclude a replacement). Doc 10 §14 "Destructive
operations outside the defined experiment scope are rejected" is enforced on DAIL's identity, never
on the model's claim.

Standard library and ``core.domain`` only (R1).
"""

from __future__ import annotations

import re
from collections.abc import Iterable
from dataclasses import dataclass
from enum import StrEnum
from typing import Final

from core.domain.enums import ChangeType
from core.domain.errors import DomainValidationError
from core.domain.identity import IdentityReport


class OperationType(StrEnum):
"""§15 ``operation_types``."""

CREATE = "CREATE"
MODIFY = "MODIFY"
DELETE = "DELETE"
REPLACE = "REPLACE"


ALL_OPERATIONS: Final = (
OperationType.CREATE,
OperationType.MODIFY,
OperationType.DELETE,
OperationType.REPLACE,
)
OPERATION_OF_CHANGE: Final = {
ChangeType.CREATED: OperationType.CREATE,
ChangeType.MODIFIED: OperationType.MODIFY,
ChangeType.DELETED: OperationType.DELETE,
ChangeType.REPLACED: OperationType.REPLACE,
ChangeType.UNCERTAIN: OperationType.REPLACE,
}
_NAME = r"[A-Za-z_][A-Za-z0-9_-]*"
CONFIG_ADDRESS: Final = re.compile(rf"^({_NAME})\.({_NAME})$")


def _texts(values: Iterable[object], field: str) -> tuple[str, ...]:
if isinstance(values, str):
raise DomainValidationError(f"AllowedChangeScope.{field}: must be a list, not a string")
items: list[str] = []
for item in values:
if not isinstance(item, str) or item == "":
raise DomainValidationError(f"AllowedChangeScope.{field}: must hold non-empty strings")
items.append(item)
return tuple(sorted(set(items)))


@dataclass(frozen=True)
class AllowedChangeScope:
"""§15. Addresses are configuration addresses (``type.name``, C-189)."""

resource_addresses: tuple[str, ...]
resource_types: tuple[str, ...]
attributes: tuple[str, ...]
operation_types: tuple[OperationType, ...]

def __post_init__(self) -> None:
addresses = _texts(self.resource_addresses, "resource_addresses")
for address in addresses:
if CONFIG_ADDRESS.match(address) is None:
raise DomainValidationError(
f"AllowedChangeScope.resource_addresses: {address!r} is not type.name (C-189)"
)
object.__setattr__(self, "resource_addresses", addresses)
object.__setattr__(self, "resource_types", _texts(self.resource_types, "resource_types"))
object.__setattr__(self, "attributes", _texts(self.attributes, "attributes"))
operations = []
for item in self.operation_types:
try:
operations.append(OperationType(item))
except ValueError as error:
raise DomainValidationError(
f"AllowedChangeScope.operation_types: unknown operation {item!r}"
) from error
order = {op: i for i, op in enumerate(ALL_OPERATIONS)}
object.__setattr__(
self, "operation_types", tuple(sorted(set(operations), key=order.__getitem__))
)

def to_dict(self) -> dict[str, list[str]]:
return {
"resource_addresses": list(self.resource_addresses),
"resource_types": list(self.resource_types),
"attributes": list(self.attributes),
"operation_types": [op.value for op in self.operation_types],
}


def operation_breaches(report: IdentityReport, scope: AllowedChangeScope) -> tuple[str, ...]:
"""The changes of ``report`` whose operation ``scope`` does not allow (C-191), sorted."""
found = []
for match in report.resource_matches:
operation = OPERATION_OF_CHANGE.get(match.change_type)
if operation is None or operation in scope.operation_types:
continue
found.append(
f"{match.key}: {match.change_type.value} is {operation.value}, which the scope "
"does not allow (C-191)"
)
return tuple(sorted(found))
2 changes: 1 addition & 1 deletion docs/BUILD_SEQUENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The build is split into 18 work packages (N-06 in `DECISIONS_REGISTER.md`). Ever
| 17 | Nov 21–28 | D5 oracle labels + calibration (free route, N-08) |
| 18 | Nov 29–30 | D6 freeze TerraPreserve v1.0 |
| 19 | Dec 1–2 | P7a LLM adapter (fake provider, Ollama) |
| 20 | Dec 3–6 | P7b patch validation → P7 gate, M4 |
| 20 | Dec 3–6 (built 12 Nov 2026) | P7b patch validation: the C-188 patch meaning, the §13 validator, AllowedChangeScope and the operation check, `LLMEvaluationRequest` (C-188 to C-195) → P7 gate, M4 |
| 21 | — | ~~P9 AWS integration (free route) → M6~~ removed by C-71 (advisor, 11 Nov 2026): AWS stays fully offline, no deployment, no AWS account, no live calibration; milestone M6 (AWS Demonstration) is removed |
| 22 | Dec 12–14 | P8a experiment harness |
| 23 | Dec 15–18 | P8b metrics and reports → P8 gate, M5 |
Expand Down
Loading