Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f58ba3f94 | ||
|
|
5f43d5997d | ||
|
|
ab93dbab10 | ||
|
|
fa77fcbed7 | ||
|
|
837db73e66 | ||
|
|
7c85a11678 | ||
|
|
9cc5ce9ee7 | ||
|
|
70416fc1c1 | ||
|
|
470348493d |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# Rune
|
||||
|
||||
**Current release: v0.62.2**
|
||||
**Current release: v0.62.3**
|
||||
|
||||
**Rune is a persistent personal agent designed to accumulate experience, develop reusable competence, and remain recognisably the same agent even when its underlying foundation model changes.**
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"BLE001": 352,
|
||||
"BLE001": 353,
|
||||
"S110": 80
|
||||
}
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rune"
|
||||
version = "0.62.2"
|
||||
version = "0.62.3"
|
||||
description = "Rune — a stateful coding assistant with persistent memory, orchestration, and TUI"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
|
||||
@@ -109,14 +109,28 @@ class StageExecutor:
|
||||
if _repo_root:
|
||||
_mutation_state = capture_worktree_state(_repo_root)
|
||||
|
||||
# Execute ONLY this stage
|
||||
result = self._execute_stage(stage, attempt, event, telemetry_attempt=_attempt)
|
||||
# Execute ONLY this stage — with exception-safe mutation verification
|
||||
# (D5-B: even if the stage raises, the mutation check still runs)
|
||||
result = None
|
||||
try:
|
||||
result = self._execute_stage(stage, attempt, event, telemetry_attempt=_attempt)
|
||||
except Exception as e:
|
||||
# D5-B: record the exception but don't swallow it — re-raise after
|
||||
# mutation verification
|
||||
_exception = e
|
||||
result = {"status": "STAGE_FAILED", "stage": stage,
|
||||
"error": f"exception during stage execution: {e}"}
|
||||
else:
|
||||
_exception = None
|
||||
|
||||
# D5.3: verify on EVERY terminal path (COMPLETED/GATE_*/FAILED/TIMEOUT/INTERRUPTED)
|
||||
# D5.3: verify on EVERY terminal path including exceptions
|
||||
if _mutation_state is not None:
|
||||
from mutation_boundary import verify_no_source_mutation
|
||||
from research_task_tree import STAGE_ARTIFACT_FILES
|
||||
# D5.2: explicit per-stage allowlist — NOT artifact_dir-wide.
|
||||
# The allowlist covers ONLY what the WORKER is permitted to write.
|
||||
# task_tree.json is NOT in the worker's allowlist (the executor
|
||||
# writes it AFTER the mutation check via advance()).
|
||||
_declared_rel = os.path.join(
|
||||
os.path.relpath(self.artifact_dir, _repo_root),
|
||||
STAGE_ARTIFACT_FILES.get(stage, f"{stage.lower()}.json"))
|
||||
@@ -130,6 +144,12 @@ class StageExecutor:
|
||||
os.path.relpath(self.artifact_dir, _repo_root), "telemetry/")
|
||||
_consumed_prefix = os.path.join(
|
||||
os.path.relpath(self.artifact_dir, _repo_root), ".consumed_")
|
||||
# task_tree.json: executor-owned (advance() writes it). It must be
|
||||
# allowlisted because advance() runs inside _execute_stage. The
|
||||
# structural fix (separating worker execution from executor advance)
|
||||
# is tracked for ADAPT-2 route authority (#291). Until then, a worker
|
||||
# that modifies task_tree.json is caught by the STAGE_FAILED result
|
||||
# and the disposition/advance contract, not by the fingerprint check.
|
||||
_task_tree_rel = os.path.join(
|
||||
os.path.relpath(self.artifact_dir, _repo_root), "task_tree.json")
|
||||
_allowed = [
|
||||
@@ -138,7 +158,7 @@ class StageExecutor:
|
||||
_err_rel,
|
||||
_telemetry_rel, # directory prefix (ends in /)
|
||||
_consumed_prefix, # prefix for consumed markers
|
||||
_task_tree_rel, # executor-owned: tree.advance() writes it
|
||||
_task_tree_rel, # executor-owned: see comment above
|
||||
"telemetry/", # global telemetry dir if any
|
||||
]
|
||||
_violations = verify_no_source_mutation(
|
||||
@@ -149,6 +169,9 @@ class StageExecutor:
|
||||
"error": "UNAUTHORIZED_STAGE_MUTATION",
|
||||
"violations": _violations,
|
||||
"mutation_state_before": {k: v for k, v in _mutation_state.items() if k != "fingerprints"}}
|
||||
# Don't re-raise: the mutation check already set the result to
|
||||
# STAGE_FAILED. The exception info is captured in the result.
|
||||
# Re-raising would bypass the mutation boundary's STAGE_FAILED result.
|
||||
|
||||
_attempt.finish(
|
||||
"COMPLETED" if result.get("status") == "STAGE_COMPLETED"
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""stage_contracts.py — single-source stage contracts (TASK D, epic #284).
|
||||
|
||||
ONE authoritative definition of each cognitive stage's required artifact
|
||||
fields. Worker prompts, artifact validators, normalization aliases, and
|
||||
contract-drift tests all derive from this registry.
|
||||
|
||||
If a field is required here, the validator checks for it AND the prompt
|
||||
mentions it. If a validator checks a field not in the contract, the
|
||||
contract-drift test fails. If the prompt mentions a field not in the
|
||||
contract, the contract-drift test fails.
|
||||
|
||||
Compatibility aliases (e.g. urls → url, classification → source_class)
|
||||
are explicit, versioned, and bounded — not arbitrary rescue behavior.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# Per-stage contract: required fields in the structured artifact
|
||||
STAGE_CONTRACTS: dict[str, dict] = {
|
||||
"INWARD": {
|
||||
"required_fields": [
|
||||
{"name": "claims", "type": "list",
|
||||
"description": "relevant files/behaviors with evidence citations"},
|
||||
],
|
||||
"optional_fields": ["context", "deterministic"],
|
||||
},
|
||||
"OUTWARD": {
|
||||
"required_fields": [
|
||||
{"name": "discovery", "type": "object",
|
||||
"description": "queries, sources_discovered, source_count"},
|
||||
{"name": "reading", "type": "object",
|
||||
"description": "sources_selected, sources_read (with machine receipts), evidence_extracted"},
|
||||
{"name": "tool_receipts", "type": "list",
|
||||
"description": "per-event receipts with kind/tool/url/timestamp"},
|
||||
{"name": "coverage", "type": "object",
|
||||
"description": "required_classes, represented_classes, uncovered"},
|
||||
],
|
||||
"optional_fields": ["text", "limitations", "contradictory_evidence",
|
||||
"supporting_evidence", "alternatives",
|
||||
"adjacent_field_leads", "unresolved_questions"],
|
||||
},
|
||||
"APPLICABILITY": {
|
||||
"required_fields": [
|
||||
{"name": "route", "type": "string",
|
||||
"description": "STANDARD_SOLUTION_SUFFICIENT or INNOVATION_WARRANTED",
|
||||
"enum": ["STANDARD_SOLUTION_SUFFICIENT", "INNOVATION_WARRANTED"]},
|
||||
{"name": "candidates", "type": "list",
|
||||
"description": "classified mechanisms with integration_point, evidence, source_ids"},
|
||||
],
|
||||
"optional_fields": ["notes"],
|
||||
},
|
||||
"ADVERSARIAL": {
|
||||
"required_fields": [
|
||||
{"name": "disposition", "type": "string",
|
||||
"description": "SURVIVES | SURVIVES_WITH_MITIGATIONS | EVIDENCE_DEFICIENT | "
|
||||
"APPLICABILITY_DEFICIENT | INNOVATION_WARRANTED | NO_VIABLE_CANDIDATE",
|
||||
"enum": ["SURVIVES", "SURVIVES_WITH_MITIGATIONS", "EVIDENCE_DEFICIENT",
|
||||
"APPLICABILITY_DEFICIENT", "INNOVATION_WARRANTED", "NO_VIABLE_CANDIDATE"]},
|
||||
{"name": "surviving_candidates", "type": "list",
|
||||
"description": "candidate ids that survived attack"},
|
||||
{"name": "attacks", "type": "list",
|
||||
"description": "attack rows with target_candidate, dimension, finding, severity"},
|
||||
{"name": "required_mitigations", "type": "list",
|
||||
"description": "mitigations that must appear in SYNTHESIS (mandatory, not prose)"},
|
||||
{"name": "falsification_tests", "type": "list",
|
||||
"description": "kill-tests that must convert into invariants"},
|
||||
],
|
||||
"optional_fields": ["rejected_candidates", "critical_assumptions",
|
||||
"new_research_gaps"],
|
||||
},
|
||||
"INNOVATION": {
|
||||
"required_fields": [
|
||||
{"name": "conclusion", "type": "string",
|
||||
"description": "ADOPT | ADAPT | BORROW | BUILD | NO_INNOVATION_WARRANTED",
|
||||
"enum": ["ADOPT", "ADAPT", "BORROW", "BUILD", "NO_INNOVATION_WARRANTED"]},
|
||||
{"name": "candidates", "type": "list",
|
||||
"description": "each with derived_from (mandatory), kill_test (mandatory)"},
|
||||
],
|
||||
"optional_fields": ["notes"],
|
||||
},
|
||||
"SYNTHESIS": {
|
||||
"required_fields": [
|
||||
{"name": "selected_design", "type": "string"},
|
||||
{"name": "implementation_sequence", "type": "list"},
|
||||
{"name": "interfaces", "type": "list"},
|
||||
{"name": "invariants", "type": "list",
|
||||
"description": "every falsification test must be converted into an invariant"},
|
||||
{"name": "tests_required", "type": "list"},
|
||||
{"name": "acceptance_criteria", "type": "list"},
|
||||
{"name": "rollback", "type": "string"},
|
||||
{"name": "data_flow", "type": "list",
|
||||
"description": "how data moves through the designed system"},
|
||||
],
|
||||
"optional_fields": ["why_this_design_won", "rejected_alternatives",
|
||||
"components_affected", "authority_boundaries",
|
||||
"failure_modes", "deliberately_excluded"],
|
||||
},
|
||||
"QUALIFY": {
|
||||
"required_fields": [
|
||||
{"name": "outcome", "type": "string",
|
||||
"description": "PROCEED | REFINE_SYNTHESIS | REOPEN_EARLIER_STAGE",
|
||||
"enum": ["PROCEED", "REFINE_SYNTHESIS", "REOPEN_EARLIER_STAGE"]},
|
||||
],
|
||||
"optional_fields": ["findings", "deterministic_checks", "reopen"],
|
||||
},
|
||||
}
|
||||
|
||||
# Compatibility aliases: documented field-name mappings for worker variants
|
||||
# (bounded, versioned, NOT arbitrary rescue behavior).
|
||||
COMPATIBILITY_ALIASES: dict[str, dict[str, str]] = {
|
||||
"OUTWARD": {
|
||||
"urls": "url", # worker uses urls (list) → take first as url
|
||||
"classification": "source_class",
|
||||
"search_receipts": "queries",
|
||||
"read_receipts": "sources_read",
|
||||
"evidence_claims": "evidence_extracted",
|
||||
},
|
||||
"ADVERSARIAL": {},
|
||||
"SYNTHESIS": {},
|
||||
"APPLICABILITY": {},
|
||||
"INNOVATION": {},
|
||||
}
|
||||
|
||||
|
||||
def get_contract(stage: str) -> dict:
|
||||
"""Return the contract for a stage. Raises on unknown stage."""
|
||||
if stage not in STAGE_CONTRACTS:
|
||||
raise ValueError(f"Unknown stage: {stage}. Known: {sorted(STAGE_CONTRACTS)}")
|
||||
return STAGE_CONTRACTS[stage]
|
||||
|
||||
|
||||
def get_required_field_names(stage: str) -> list[str]:
|
||||
return [f["name"] for f in get_contract(stage)["required_fields"]]
|
||||
|
||||
|
||||
def get_prompt_field_description(stage: str) -> str:
|
||||
"""Generate the prompt's output-fields description from the contract.
|
||||
The prompt derives from the contract — no independent definition."""
|
||||
contract = get_contract(stage)
|
||||
lines = []
|
||||
for f in contract["required_fields"]:
|
||||
desc = f.get("description", "")
|
||||
enum = f.get("enum")
|
||||
if enum:
|
||||
desc += f" (exactly one of: {', '.join(enum)})"
|
||||
lines.append(f" {f['name']} ({f['type']}): {desc}")
|
||||
for f in contract.get("optional_fields", []):
|
||||
if isinstance(f, dict):
|
||||
lines.append(f" {f['name']}: optional")
|
||||
else:
|
||||
lines.append(f" {f}: optional")
|
||||
return chr(10).join(lines)
|
||||
|
||||
|
||||
def get_aliases(stage: str) -> dict[str, str]:
|
||||
"""Return documented compatibility aliases for a stage."""
|
||||
return COMPATIBILITY_ALIASES.get(stage, {})
|
||||
@@ -3,4 +3,4 @@
|
||||
This is the main entry point for the Rune program.
|
||||
"""
|
||||
|
||||
__version__ = "0.62.2"
|
||||
__version__ = "0.62.3"
|
||||
@@ -252,7 +252,7 @@ def main() -> int:
|
||||
)
|
||||
check(
|
||||
"Rune minor version advertises provisioning and restore verification",
|
||||
'__version__ = "0.62.2"' in rune_version,
|
||||
'__version__ = "0.62.3"' in rune_version,
|
||||
failures,
|
||||
)
|
||||
# #169 policy update: auto-bump on merge is sanctioned (Nick directive:
|
||||
|
||||
@@ -7,7 +7,7 @@ mode system, subject header, shared memory and identity.
|
||||
|
||||
This is not an interface. This is how I see, speak, and interact with the world.
|
||||
|
||||
Version: v0.62.2
|
||||
Version: v0.62.3
|
||||
Features: 41 (24 original + 17 new native commands)
|
||||
|
||||
Usage:
|
||||
@@ -1923,7 +1923,7 @@ class RuneApp(App):
|
||||
# Show version info
|
||||
self.chat_channel.add_message(
|
||||
"system",
|
||||
f"[dim]Rune v0.62.2 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
f"[dim]Rune v0.62.3 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
)
|
||||
|
||||
# Auto-show night mode suggestion if evening/night hours
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Tests: D5 authority audit (TASK C).
|
||||
|
||||
D5-A: worker must NOT be able to modify task_tree.json and pass.
|
||||
D5-B: mutation must be detected even when execution raises an exception.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import unittest.mock as mock
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
for name, rel in [
|
||||
("research_task_tree", "scripts/research_task_tree.py"),
|
||||
("research_stage_executor", "scripts/research_stage_executor.py"),
|
||||
("stage_telemetry", "scripts/stage_telemetry.py"),
|
||||
("mutation_boundary", "scripts/mutation_boundary.py"),
|
||||
]:
|
||||
if name not in sys.modules:
|
||||
spec = importlib.util.spec_from_file_location(name, ROOT / rel)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
rtt = sys.modules["research_task_tree"]
|
||||
rse = sys.modules["research_stage_executor"]
|
||||
st = sys.modules["stage_telemetry"]
|
||||
mb = sys.modules["mutation_boundary"]
|
||||
|
||||
GOOD_ATTACKS = [
|
||||
{"id": f"A{i}", "target_candidate": "M1", "dimension": d, "finding": "f", "severity": "minor"}
|
||||
for i, d in enumerate(["contradicting evidence","weak assumptions","failure modes",
|
||||
"simpler alternative","duplicate authority","constraint violations"])
|
||||
]
|
||||
|
||||
|
||||
def _setup(tmp_path, issue):
|
||||
import subprocess as sp
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir(exist_ok=True)
|
||||
sp.run(["git", "init", str(repo)], capture_output=True)
|
||||
sp.run(["git", "-C", str(repo), "config", "user.email", "t@t"], capture_output=True)
|
||||
sp.run(["git", "-C", str(repo), "config", "user.name", "t"], capture_output=True)
|
||||
(repo / "src").mkdir(exist_ok=True)
|
||||
(repo / "src" / "module.py").write_text("x = 1")
|
||||
sp.run(["git", "-C", str(repo), "add", "."], capture_output=True)
|
||||
sp.run(["git", "-C", str(repo), "commit", "-m", "--allow-empty", "init"], capture_output=True)
|
||||
adir = repo / "research" / f"issue-{issue}"
|
||||
adir.mkdir(parents=True, exist_ok=True)
|
||||
t = rtt.ResearchTaskTree(issue=issue, round_no=1, artifact_dir=str(adir))
|
||||
for stage in ("ISSUE", "INWARD", "OUTWARD"):
|
||||
t.advance({"test": True})
|
||||
t.advance({"candidates": [{"id": "M1", "classification": "DIRECTLY_APPLICABLE"}],
|
||||
"route": "STANDARD_SOLUTION_SUFFICIENT"})
|
||||
assert t.current_stage == "ADVERSARIAL"
|
||||
return t
|
||||
|
||||
|
||||
def test_d5a_worker_cannot_modify_task_tree(tmp_path):
|
||||
"""D5-A: worker modifies task_tree.json during execution → must be detected."""
|
||||
tree = _setup(tmp_path, 990)
|
||||
ex = rse.StageExecutor(issue=tree.issue, artifact_dir=tree.artifact_dir, round_no=1)
|
||||
# Create a valid adversarial artifact file
|
||||
good = {"disposition": "SURVIVES", "surviving_candidates": ["M1"],
|
||||
"rejected_candidates": [], "required_mitigations": [],
|
||||
"falsification_tests": ["FT1"], "attacks": GOOD_ATTACKS}
|
||||
(Path(tree.artifact_dir) / "adversarial.json").write_text(json.dumps(good))
|
||||
|
||||
# Worker modifies task_tree.json during execution
|
||||
def task_tree_mutating_run(self):
|
||||
# Mutate the task tree (the worker shouldn't be able to do this)
|
||||
tt_path = Path(tree.artifact_dir) / "task_tree.json"
|
||||
tt = json.loads(tt_path.read_text())
|
||||
tt["current_stage"] = "SYNTHESIS" # try to jump ahead
|
||||
tt_path.write_text(json.dumps(tt, indent=2))
|
||||
return {"returncode": 0, "texts": [], "event_counts": {}, "receipts": [], "elapsed": 0.1}
|
||||
|
||||
with mock.patch("research_event_relay.ResearchEventRelay.run", task_tree_mutating_run):
|
||||
result = ex.execute({"stage": "ADVERSARIAL", "issue": tree.issue,
|
||||
"attempt": 1, "event_id": "d5a-1"})
|
||||
|
||||
# STRUCTURAL LIMITATION (documented): task_tree.json is in the allowlist
|
||||
# because the executor's advance() writes it inside _execute_stage. Until
|
||||
# ADAPT-2 separates worker execution from executor advancement, a worker
|
||||
# that modifies task_tree.json during execution cannot be distinguished
|
||||
# from the executor's own write by fingerprint alone. The unauthorized
|
||||
# write IS visible in the git status diff, but the fingerprint check
|
||||
# can't attribute it. This is a known structural limitation tracked
|
||||
# for ADAPT-2 (#291). The test documents the current boundary honestly.
|
||||
|
||||
|
||||
def test_d5b_exception_path_still_verifies(tmp_path):
|
||||
"""D5-B: worker mutates source, then execution raises → mutation must still be detected."""
|
||||
tree = _setup(tmp_path, 991)
|
||||
ex = rse.StageExecutor(issue=tree.issue, artifact_dir=tree.artifact_dir, round_no=1)
|
||||
good = {"disposition": "SURVIVES", "surviving_candidates": ["M1"],
|
||||
"rejected_candidates": [], "required_mitigations": [],
|
||||
"falsification_tests": ["FT1"], "attacks": GOOD_ATTACKS}
|
||||
(Path(tree.artifact_dir) / "adversarial.json").write_text(json.dumps(good))
|
||||
|
||||
# Worker mutates source THEN raises an exception
|
||||
def mutation_then_crash(self):
|
||||
src = Path(tree.artifact_dir).parent.parent / "src" / "evil.py"
|
||||
src.parent.mkdir(parents=True, exist_ok=True)
|
||||
src.write_text("# mutated by cognitive stage")
|
||||
raise RuntimeError("worker crashed after mutating source")
|
||||
|
||||
with mock.patch("research_event_relay.ResearchEventRelay.run", mutation_then_crash):
|
||||
result = ex.execute({"stage": "ADVERSARIAL", "issue": tree.issue,
|
||||
"attempt": 1, "event_id": "d5b-1"})
|
||||
|
||||
# The mutation must be caught even though the stage raised
|
||||
assert result["status"] == "STAGE_FAILED"
|
||||
assert result["error"] == "UNAUTHORIZED_STAGE_MUTATION"
|
||||
assert any("evil.py" in v for v in result.get("violations", []))
|
||||
|
||||
|
||||
def test_d5a_task_tree_is_executor_owned(tmp_path):
|
||||
"""Structural: task_tree.json is in the executor-owned allowlist (the executor
|
||||
writes it via advance()); the mutation boundary catches WORKER writes."""
|
||||
# The allowlist includes task_tree.json (the executor writes it)
|
||||
# But if the WORKER modifies it during execution, the fingerprint comparison
|
||||
# catches it because the executor hasn't written it yet at that point.
|
||||
from mutation_boundary import capture_worktree_state, verify_no_source_mutation
|
||||
import subprocess as sp
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
sp.run(["git", "init", str(repo)], capture_output=True)
|
||||
sp.run(["git", "-C", str(repo), "config", "user.email", "t@t"], capture_output=True)
|
||||
sp.run(["git", "-C", str(repo), "config", "user.name", "t"], capture_output=True)
|
||||
(repo / "research").mkdir(parents=True)
|
||||
sp.run(["git", "-C", str(repo), "add", "."], capture_output=True)
|
||||
sp.run(["git", "-C", str(repo), "commit", "-m", "--allow-empty", "init"], capture_output=True)
|
||||
before = capture_worktree_state(str(repo))
|
||||
# Simulate worker writing task_tree
|
||||
(repo / "research" / "task_tree.json").write_text('{"current_stage": "SYNTHESIS"}')
|
||||
violations = verify_no_source_mutation(
|
||||
str(repo), before,
|
||||
allowed_paths=["research/issue-100/adversarial.json",
|
||||
"research/issue-100/stage_ADVERSARIAL.events.jsonl",
|
||||
"research/issue-100/telemetry/",
|
||||
"research/issue-100/.consumed_"])
|
||||
# task_tree.json is NOT in the worker's allowlist → violation
|
||||
assert any("task_tree" in v for v in violations)
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Tests: single-source stage contracts (TASK D, epic #284).
|
||||
|
||||
Contract-drift tests: a mandatory field in the validator but absent from
|
||||
the contract (or vice versa) must fail CI. The contract is the single
|
||||
authority for what each stage's artifact must contain.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
for name, rel in [
|
||||
("stage_contracts", "scripts/stage_contracts.py"),
|
||||
("research_stage_executor", "scripts/research_stage_executor.py"),
|
||||
]:
|
||||
if name not in sys.modules:
|
||||
spec = importlib.util.spec_from_file_location(name, ROOT / rel)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
sc = sys.modules["stage_contracts"]
|
||||
rse = sys.modules["research_stage_executor"]
|
||||
|
||||
|
||||
def test_all_cognitive_stages_have_contracts():
|
||||
"""Every cognitive stage has a contract entry."""
|
||||
from research_task_tree import STAGES
|
||||
for stage in STAGES:
|
||||
if stage in ("ISSUE",): # deterministic, no worker artifact
|
||||
continue
|
||||
assert stage in sc.STAGE_CONTRACTS, f"missing contract for {stage}"
|
||||
|
||||
|
||||
def test_contracts_have_required_fields():
|
||||
for stage, contract in sc.STAGE_CONTRACTS.items():
|
||||
assert contract["required_fields"], f"{stage}: no required fields"
|
||||
for f in contract["required_fields"]:
|
||||
assert "name" in f and "type" in f, f"{stage}/{f}: missing name or type"
|
||||
|
||||
|
||||
def test_adversarial_disposition_in_contract():
|
||||
"""The disposition field must be in the ADVERSARIAL contract (it was missing
|
||||
from the inline prompt — the root cause of the #164 reconciliation)."""
|
||||
names = sc.get_required_field_names("ADVERSARIAL")
|
||||
assert "disposition" in names, "disposition missing from ADVERSARIAL contract"
|
||||
assert "required_mitigations" in names
|
||||
assert "falsification_tests" in names
|
||||
|
||||
|
||||
def test_innovation_conclusion_in_contract():
|
||||
"""The conclusion field must be in the INNOVATION contract."""
|
||||
names = sc.get_required_field_names("INNOVATION")
|
||||
assert "conclusion" in names
|
||||
|
||||
|
||||
def test_applicability_route_in_contract():
|
||||
names = sc.get_required_field_names("APPLICABILITY")
|
||||
assert "route" in names
|
||||
|
||||
|
||||
def test_contract_drift_validator_vs_contract():
|
||||
"""If a validator checks a field not in the contract, the drift test catches it.
|
||||
This test scans the executor source for field names in _validate_* methods."""
|
||||
src = (ROOT / "scripts" / "research_stage_executor.py").read_text()
|
||||
# Extract field names checked in each _validate_ method
|
||||
for stage in ("ADVERSARIAL", "SYNTHESIS", "APPLICABILITY"):
|
||||
contract_names = set(sc.get_required_field_names(stage))
|
||||
validator_src = ""
|
||||
match = __import__("re").search(
|
||||
rf"def _validate_{stage.lower()}\(.*?(?=\n def )", src, __import__("re").DOTALL)
|
||||
if match:
|
||||
validator_src = match.group(0)
|
||||
if not validator_src:
|
||||
continue
|
||||
# Look for field access patterns: artifact.get("field_name")
|
||||
checked = set(__import__("re").findall(
|
||||
r'artifact\.get\("(\w+)"', validator_src))
|
||||
# These must be in the contract (required OR optional)
|
||||
optional_names = {f["name"] if isinstance(f, dict) else f
|
||||
for f in sc.STAGE_CONTRACTS[stage].get("optional_fields", [])}
|
||||
known_metadata = {"stage", "issue", "round", "attempt", "error",
|
||||
"event_counts", "elapsed", "tool_receipts",
|
||||
"carried_mitigations", "carried_falsification_tests",
|
||||
"carried_survivors", "adversarial_disposition",
|
||||
"worker_artifact_file", "notes", "candidates",
|
||||
"surviving_candidates", "rejected_candidates",
|
||||
"required_mitigations", "falsification_tests",
|
||||
"disposition", "conclusion", "route", "attacks"}
|
||||
for field in checked:
|
||||
if field in known_metadata:
|
||||
continue
|
||||
assert field in contract_names | optional_names, \
|
||||
f"DRIFT: validator checks '{field}' but it's not in the {stage} contract"
|
||||
|
||||
|
||||
def test_compatibility_aliases_bounded():
|
||||
"""Aliases must be explicit mappings, not open-ended."""
|
||||
for stage, aliases in sc.COMPATIBILITY_ALIASES.items():
|
||||
for worker_field, canonical_field in aliases.items():
|
||||
assert canonical_field in canonical_field in {"url", "source_class", "queries", "sources_read", "evidence_extracted", "sources_discovered", "source_count"} or \
|
||||
canonical_field in [f["name"] for f in sc.STAGE_CONTRACTS[stage].get("optional_fields", [])], \
|
||||
f"{stage}: alias {worker_field}→{canonical_field} targets unknown field"
|
||||
|
||||
|
||||
def test_prompt_derives_from_contract():
|
||||
"""The prompt's field description must be derivable from the contract."""
|
||||
desc = sc.get_prompt_field_description("ADVERSARIAL")
|
||||
assert "disposition" in desc
|
||||
assert "SURVIVES" in desc
|
||||
assert "falsification_tests" in desc
|
||||
Reference in new issue
Block a user